Files
papa-kind-treff.info/src/App/AccountPages.php
Lars Gebhardt-Kusche 9d5bad8294
All checks were successful
Deploy / deploy (push) Successful in 58s
adasd
2026-08-17 00:05:18 +02:00

1352 lines
68 KiB
PHP
Executable File

<?php
declare(strict_types=1);
namespace App;
use App\Avatar\AvatarManager;
use App\Avatar\Lorelei;
final class AccountPages
{
private const EVENT_DRAFT_SESSION_KEY = 'dashboard_event_draft';
private const ENCRYPTED_PROFILE_FIELDS = [
'first_name',
'last_name',
'street',
'contact_phone',
'profession',
'languages',
'about',
];
public static function register(App $app): array
{
$flash = $app->flash()->get();
$isLoggedIn = isset($_SESSION['user_id']);
$error = '';
$displayName = '';
$email = '';
if ($isLoggedIn) {
redirect('/dashboard');
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$displayName = trim((string)($_POST['display_name'] ?? ''));
$email = trim((string)($_POST['email'] ?? ''));
$password = (string)($_POST['password'] ?? '');
$password2 = (string)($_POST['password_confirm'] ?? '');
if ($password !== $password2) {
$error = 'Passwörter stimmen nicht überein.';
} elseif (strlen($password) < 8) {
$error = 'Passwort muss mindestens 8 Zeichen haben.';
} else {
try {
$auth = new Auth($app);
$userId = $auth->register($displayName, $email, $password);
$code = $auth->createVerifyCode($userId, $email);
$mailer = new Mailer($app);
$mailer->sendTemplate('registration_confirm', $email, [
'code' => $code,
'display_name' => $displayName,
]);
$_SESSION['verify_email'] = $email;
$app->flash()->set('info', 'Bitte bestätige deine Registrierung mit dem Code aus der E-Mail.');
redirect('/verify');
} catch (\Throwable $e) {
$error = $e->getMessage();
}
}
}
return compact('flash', 'error', 'displayName', 'email');
}
public static function login(App $app): array
{
$flash = $app->flash()->get();
$isLoggedIn = isset($_SESSION['user_id']);
$error = '';
$emailPrefill = '';
if ($isLoggedIn) {
redirect('/dashboard');
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$email = trim((string)($_POST['email'] ?? ''));
$emailPrefill = $email;
$password = (string)($_POST['password'] ?? '');
try {
$auth = new Auth($app);
$res = $auth->login($email, $password);
if ($res['status'] === 'pending') {
$code = $auth->createVerifyCode($res['id'], $email);
$mailer = new Mailer($app);
$mailer->sendTemplate('registration_confirm', $email, [
'code' => $code,
'display_name' => $email,
]);
$_SESSION['verify_email'] = $email;
$app->flash()->set('info', 'Bitte bestätige deine Registrierung mit dem Code aus der E-Mail.');
redirect('/verify');
}
$_SESSION['user_id'] = $res['id'];
redirect('/dashboard');
} catch (\Throwable $e) {
$error = $e->getMessage();
}
}
return compact('flash', 'error', 'emailPrefill', 'isLoggedIn');
}
public static function verify(App $app): array
{
$flash = $app->flash()->get();
$error = '';
$info = '';
$email = $_SESSION['verify_email'] ?? '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? 'verify';
$email = trim((string)($_POST['email'] ?? ''));
$code = strtoupper(trim((string)($_POST['code'] ?? '')));
$auth = new Auth($app);
$mailer = new Mailer($app);
if ($action === 'resend') {
try {
$row = $auth->findUserMetaByEmail($email);
if (!$row) {
throw new \RuntimeException('E-Mail nicht gefunden.');
}
$userId = (int)$row['id'];
$storedEmail = $auth->getEmailByUserId($userId);
$codeNew = $auth->createVerifyCode($userId, $storedEmail);
$mailer->sendTemplate('registration_resend_code', $storedEmail, [
'code' => $codeNew,
'display_name' => $row['display_name'] ?? '',
]);
$info = 'Neuer Code wurde versendet.';
$_SESSION['verify_email'] = $storedEmail;
} catch (\Throwable $e) {
$error = $e->getMessage();
}
} else {
try {
$userId = $auth->verifyCode($email, $code);
$_SESSION['user_id'] = $userId;
unset($_SESSION['verify_email']);
$storedEmail = $auth->getEmailByUserId($userId);
$meta = $auth->findUserMetaByEmail($storedEmail);
$mailer->sendTemplate('registration_welcome', $storedEmail, [
'display_name' => $meta['display_name'] ?? $storedEmail,
]);
$app->flash()->set('success', 'Registrierung bestätigt. Willkommen!');
redirect('/dashboard');
} catch (\Throwable $e) {
$error = $e->getMessage();
}
}
}
return compact('flash', 'error', 'info', 'email');
}
public static function dashboard(App $app): array
{
if (!isset($_SESSION['user_id'])) {
redirect('/login');
}
$pdo = $app->pdo();
$flash = $app->flash()->get();
$userId = (int)$_SESSION['user_id'];
$error = '';
$info = '';
$crypto = null;
try { $crypto = new Crypto($app->config()); } catch (\Throwable) {}
$communityCfg = dirname(__DIR__, 2) . '/config/community.php';
$communityConfig = file_exists($communityCfg) ? require $communityCfg : [];
$community = $pdo ? new Community($pdo, $communityConfig) : null;
$communityAccess = $pdo ? new CommunityAccess($pdo, $communityConfig) : null;
$communityMigration = $pdo ? new CommunityMigration($pdo) : null;
$profileSettings = $pdo ? new ProfileSettings($pdo) : null;
$systemSettings = $pdo ? new SystemSettings($pdo) : null;
$listingCatalog = $pdo ? new ListingCatalog($pdo) : null;
$calendarSync = $pdo ? new CalendarSync($app) : null;
$section = (string)($_GET['section'] ?? 'profile');
$canManageSystemSettings = $communityAccess ? $communityAccess->canManageApplications($userId) : false;
$canManageProfileLevels = $communityAccess ? $communityAccess->canManageRoles($userId) : false;
$allowedSections = ['profile', 'children', 'events', 'places', 'community', 'settings'];
if ($canManageSystemSettings) {
$allowedSections[] = 'system';
}
if ($canManageProfileLevels) {
$allowedSections[] = 'profile-levels';
}
if ($systemSettings) {
$systemSettings->ensureSchema();
}
if ($listingCatalog) {
$listingCatalog->ensureSchema();
}
if ($calendarSync) {
$calendarSync->ensureSchema();
}
if ($pdo) {
self::ensureLegacyEventSchema($pdo);
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
try {
if ($action === 'event_draft_store') {
$targetKind = (string)($_POST['target_kind'] ?? '');
if (!in_array($targetKind, ['place', 'editorial_event'], true)) {
throw new \RuntimeException('Ungültiger Zieltyp für den Zwischenschritt.');
}
self::storeEventDraftInSession($_POST);
redirect('/dashboard?section=places&create_listing=' . rawurlencode($targetKind) . '#places');
} elseif ($action === 'profile') {
$crypto = self::requireCrypto($crypto, 'Profil');
$email = trim((string)($_POST['email'] ?? ''));
$languages = $_POST['languages'] ?? '';
if (is_array($languages)) {
$languages = implode(', ', array_map('trim', $languages));
}
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new \RuntimeException('Bitte gib eine gueltige E-Mail-Adresse an.');
}
$firstNameEnc = self::encryptOptionalProfileField($crypto, trim((string)$_POST['first_name']));
$lastNameEnc = self::encryptOptionalProfileField($crypto, trim((string)$_POST['last_name']));
$streetEnc = self::encryptOptionalProfileField($crypto, trim((string)($_POST['street'] ?? '')));
$phoneEnc = self::encryptOptionalProfileField($crypto, trim((string)$_POST['contact_phone']));
$professionEnc = self::encryptOptionalProfileField($crypto, trim((string)$_POST['profession']));
$languagesEnc = self::encryptOptionalProfileField($crypto, trim((string)$languages));
$aboutEnc = self::encryptOptionalProfileField($crypto, trim((string)$_POST['about']));
$locationPreference = (string)($_POST['location_tracking_preference'] ?? 'prompt');
$street = trim((string)($_POST['street'] ?? ''));
$zip = trim((string)($_POST['zip'] ?? ''));
$city = trim((string)($_POST['city'] ?? ''));
$region = trim((string)($_POST['region'] ?? ''));
$lat = isset($_POST['lat']) && $_POST['lat'] !== '' ? (float)$_POST['lat'] : null;
$lng = isset($_POST['lng']) && $_POST['lng'] !== '' ? (float)$_POST['lng'] : null;
$hasAddress = $street !== '' || $zip !== '' || $city !== '' || $region !== '';
if ($hasAddress) {
$resolvedAddress = null;
if ($lat !== null && $lng !== null) {
$resolvedAddress = self::reverseGeocodeAddress($lat, $lng);
}
if ($resolvedAddress === null) {
$searchResults = self::geocodeAddressCandidates($street, $zip, $city, $region, 5);
if (!$searchResults) {
throw new \RuntimeException('Die Profiladresse konnte nicht validiert werden. Bitte wähle eine Adresse aus der Suche oder über den Browser-Standort.');
}
$resolvedAddress = $searchResults[0];
}
$street = $resolvedAddress['street'] !== '' ? $resolvedAddress['street'] : $street;
$zip = $resolvedAddress['zip'] !== '' ? $resolvedAddress['zip'] : $zip;
$city = $resolvedAddress['city'] !== '' ? $resolvedAddress['city'] : $city;
$region = $resolvedAddress['region'] !== '' ? $resolvedAddress['region'] : $region;
$lat = $resolvedAddress['lat'];
$lng = $resolvedAddress['lng'];
} else {
$lat = null;
$lng = null;
$region = '';
}
$streetEnc = self::encryptOptionalProfileField($crypto, $street);
if ($profileSettings) {
$profileSettings->ensureSchema();
}
$stmt = $pdo?->prepare('UPDATE user_profiles SET display_name=:name, first_name=:fname, last_name=:lname, street=:street, zip=:zip, city=:city, region=:region, lat=:lat, lng=:lng, profession=:prof, languages=:langs, about=:about, contact_phone=:phone, location_tracking_preference=:locationPref, updated_at=NOW() WHERE user_id=:id');
$stmt?->execute([
'name' => trim((string)$_POST['display_name']),
'fname' => $firstNameEnc,
'lname' => $lastNameEnc,
'street' => $streetEnc,
'zip' => $zip,
'city' => $city,
'region' => $region,
'lat' => $lat,
'lng' => $lng,
'prof' => $professionEnc,
'langs' => $languagesEnc,
'about' => $aboutEnc,
'phone' => $phoneEnc,
'locationPref' => in_array($locationPreference, ['disabled', 'prompt', 'enabled'], true) ? $locationPreference : 'prompt',
'id' => $userId,
]);
if ($pdo) {
$emailStore = new UserEmailStore($pdo);
$emailStore->ensureSchema();
$emailStore->updateUserEmail($userId, $email);
}
$info = 'Profil gespeichert.';
} elseif ($action === 'avatar_update') {
if ($profileSettings) {
$profileSettings->updateAvatar($userId, $_POST);
}
$info = 'Profilbild gespeichert.';
} elseif ($action === 'settings_location') {
$locationPreference = (string)($_POST['location_tracking_preference'] ?? 'prompt');
if ($profileSettings) {
$profileSettings->updateLocationTrackingPreference(
$userId,
in_array($locationPreference, ['disabled', 'prompt', 'enabled'], true) ? $locationPreference : 'prompt'
);
}
$info = 'Einstellungen gespeichert.';
} elseif ($action === 'system_settings_update') {
if (!$canManageSystemSettings || !$systemSettings) {
throw new \RuntimeException('Keine Berechtigung für die System-Einstellungen.');
}
$siteMaintenanceMessage = trim((string)($_POST['site_maintenance_message'] ?? ''));
if ($siteMaintenanceMessage === '') {
$siteMaintenanceMessage = 'Papa-Kind-Treff ist gerade kurz in Wartung. Bitte versuche es in Kürze erneut.';
}
$placeDataProvider = (string)($_POST['place_data_provider'] ?? 'osm');
if (!in_array($placeDataProvider, ['osm', 'osm_google_optional'], true)) {
$placeDataProvider = 'osm';
}
$systemSettings->updateMany([
'google_places_enabled' => isset($_POST['google_places_enabled']) ? '1' : '0',
'forum_maintenance_mode' => isset($_POST['forum_maintenance_mode']) ? '1' : '0',
'site_maintenance_mode' => isset($_POST['site_maintenance_mode']) ? '1' : '0',
'site_maintenance_message' => $siteMaintenanceMessage,
'place_data_provider' => $placeDataProvider,
], $userId);
$info = 'System-Einstellungen gespeichert.';
} elseif ($action === 'category_merge') {
if (!$canManageSystemSettings || !$listingCatalog) {
throw new \RuntimeException('Keine Berechtigung für die Kategorien-Verwaltung.');
}
$listingCatalog->mergeCategories(
(string)($_POST['source_category_slug'] ?? ''),
(string)($_POST['target_category_slug'] ?? '')
);
$info = 'Kategorie zusammengeführt.';
} elseif ($action === 'child_add' || $action === 'child_update') {
$crypto = self::requireCrypto($crypto, 'Kinder');
$childId = (int)($_POST['child_id'] ?? 0);
$firstName = trim((string)($_POST['first_name'] ?? ''));
$gender = (string)($_POST['gender'] ?? 'unknown');
$birthdate = trim((string)($_POST['birthdate'] ?? ''));
$ageYearsRaw = trim((string)($_POST['age_years'] ?? ''));
$ageYears = $ageYearsRaw !== '' ? max(0, (int)$ageYearsRaw) : null;
$note = trim((string)($_POST['note'] ?? ''));
if ($firstName === '') {
throw new \RuntimeException('Bitte gib einen Vornamen an.');
}
if (!in_array($gender, ['male', 'female', 'diverse', 'unknown'], true)) {
$gender = 'unknown';
}
if ($birthdate !== '') {
$calculatedAge = self::calculateAgeFromBirthdate($birthdate);
if ($calculatedAge === null) {
throw new \RuntimeException('Bitte gib ein gueltiges Geburtsdatum an.');
}
$ageYears = $calculatedAge;
}
$firstNameEnc = $crypto->encrypt($firstName);
$noteEnc = $note !== '' ? $crypto->encrypt($note) : '';
if ($action === 'child_add') {
$stmt = $pdo?->prepare('INSERT INTO children (user_id, gender, birthdate, age_years, encrypted_first_name, note, created_at, updated_at) VALUES (:uid, :gender, :birthdate, :age, :name, :note, NOW(), NOW())');
$stmt?->execute([
'uid' => $userId,
'gender' => $gender,
'birthdate' => $birthdate !== '' ? $birthdate : null,
'age' => $ageYears,
'name' => $firstNameEnc,
'note' => $noteEnc,
]);
$info = 'Kind hinzugefügt.';
} else {
$stmt = $pdo?->prepare('UPDATE children SET gender = :gender, birthdate = :birthdate, age_years = :age, encrypted_first_name = :name, note = :note, updated_at = NOW() WHERE id = :childId AND user_id = :uid');
$stmt?->execute([
'uid' => $userId,
'childId' => $childId,
'gender' => $gender,
'birthdate' => $birthdate !== '' ? $birthdate : null,
'age' => $ageYears,
'name' => $firstNameEnc,
'note' => $noteEnc,
]);
if ($stmt && $stmt->rowCount() < 1) {
throw new \RuntimeException('Kind nicht gefunden.');
}
$info = 'Kind gespeichert.';
}
} elseif ($action === 'child_delete') {
$childId = (int)($_POST['child_id'] ?? 0);
$stmt = $pdo?->prepare('DELETE FROM children WHERE id = :childId AND user_id = :uid');
$stmt?->execute([
'childId' => $childId,
'uid' => $userId,
]);
if ($stmt && $stmt->rowCount() < 1) {
throw new \RuntimeException('Kind nicht gefunden.');
}
$info = 'Kind gelöscht.';
} elseif ($action === 'event_add' || $action === 'event_update') {
$entryKind = (string)($_POST['entry_kind'] ?? 'event');
if (in_array($entryKind, ['place', 'editorial_event'], true)) {
if (!$listingCatalog) {
throw new \RuntimeException('Die neue Eintragslogik ist aktuell nicht verfügbar.');
}
$payload = $_POST;
$existingListing = null;
if ($action === 'event_update') {
$existingListingId = (int)($_POST['listing_id'] ?? 0);
$existingListing = $existingListingId > 0 ? $listingCatalog->getMemberEntry($existingListingId) : null;
if (!is_array($existingListing) || (string)($existingListing['status'] ?? '') !== 'published') {
throw new \RuntimeException('Eintrag nicht gefunden.');
}
}
$street = trim((string)($_POST['street'] ?? ''));
$zip = trim((string)($_POST['zip'] ?? ''));
$city = trim((string)($_POST['city'] ?? ''));
$region = trim((string)($_POST['region'] ?? ''));
$lat = isset($_POST['lat']) && $_POST['lat'] !== '' ? (float)$_POST['lat'] : null;
$lng = isset($_POST['lng']) && $_POST['lng'] !== '' ? (float)$_POST['lng'] : null;
$hasAddress = $street !== '' || $zip !== '' || $city !== '' || $region !== '';
if ($hasAddress) {
$resolvedAddress = null;
if ($lat !== null && $lng !== null) {
$resolvedAddress = self::reverseGeocodeAddress($lat, $lng);
}
if ($resolvedAddress === null) {
$searchResults = self::geocodeAddressCandidates($street, $zip, $city, $region, 5);
if (!$searchResults) {
throw new \RuntimeException('Die Adresse konnte nicht validiert werden. Bitte wähle einen passenden Adress-Treffer.');
}
$resolvedAddress = $searchResults[0];
}
$payload['street'] = $resolvedAddress['street'] !== '' ? $resolvedAddress['street'] : $street;
$payload['zip'] = $resolvedAddress['zip'] !== '' ? $resolvedAddress['zip'] : $zip;
$payload['city'] = $resolvedAddress['city'] !== '' ? $resolvedAddress['city'] : $city;
$payload['region'] = $resolvedAddress['region'] !== '' ? $resolvedAddress['region'] : $region;
$payload['lat'] = $resolvedAddress['lat'];
$payload['lng'] = $resolvedAddress['lng'];
}
$payload['image_path'] = self::storeUploadedImage($_FILES['image_file'] ?? null, is_array($existingListing) ? (string)($existingListing['image_path'] ?? '') : '');
$payload['teaser'] = self::generateTeaser(
trim((string)($payload['description'] ?? '')),
trim((string)($payload['title'] ?? ''))
);
if ($action === 'event_update') {
$listingCatalog->submitUpdateRequest(
$userId,
(int)($_POST['listing_id'] ?? 0),
$payload,
(string)($_POST['moderation_reason'] ?? '')
);
$info = $entryKind === 'place' ? 'Änderungsanfrage für den Ort eingereicht.' : 'Änderungsanfrage für die Veranstaltung eingereicht.';
} else {
$createdListingId = $listingCatalog->submitCreateSuggestion($userId, $payload);
$info = $entryKind === 'place' ? 'Ort zur Freigabe eingereicht.' : 'Veranstaltung zur Freigabe eingereicht.';
if (self::hasStoredEventDraft()) {
self::attachListingToStoredEventDraft($entryKind, $createdListingId);
$app->flash()->set('info', $info);
redirect('/dashboard?section=events&restore_event_draft=1#events');
}
}
} else {
$existingEvent = null;
if ($action === 'event_update') {
$existingEventId = (int)($_POST['event_id'] ?? 0);
if ($existingEventId > 0) {
$stmt = $pdo?->prepare('SELECT * FROM events WHERE id = :id AND created_by = :uid LIMIT 1');
$stmt?->execute(['id' => $existingEventId, 'uid' => $userId]);
$existingEvent = $stmt?->fetch(\PDO::FETCH_ASSOC) ?: null;
}
}
$imagePath = self::storeUploadedImage($_FILES['image_file'] ?? null, is_array($existingEvent) ? (string)($existingEvent['image_path'] ?? '') : '');
$allowKids = ((string)($_POST['with_children'] ?? 'yes')) === 'yes' ? 1 : 0;
$capacity = isset($_POST['max_participants']) && $_POST['max_participants'] !== '' ? (int)$_POST['max_participants'] : null;
$eventStartValue = self::normalizeDateForStorage((string)($_POST['starts_at'] ?? ''), true);
$sourceMode = (string)($_POST['event_location_mode'] ?? 'custom');
$sourceListingId = isset($_POST['event_location_source_id']) && $_POST['event_location_source_id'] !== '' ? (int)$_POST['event_location_source_id'] : null;
$locationLabel = null;
$street = trim((string)($_POST['street'] ?? ''));
$zip = trim((string)($_POST['zip'] ?? ''));
$city = trim((string)($_POST['city'] ?? ''));
$region = trim((string)($_POST['region'] ?? ''));
$lat = isset($_POST['lat']) && $_POST['lat'] !== '' ? (float)$_POST['lat'] : null;
$lng = isset($_POST['lng']) && $_POST['lng'] !== '' ? (float)$_POST['lng'] : null;
$categoryInput = trim((string)($_POST['category_input'] ?? $_POST['category_slug'] ?? ''));
$categorySlug = '';
$sourceListing = null;
if (in_array($sourceMode, ['place', 'editorial_event'], true)) {
if (!$listingCatalog || $sourceListingId === null || $sourceListingId <= 0) {
throw new \RuntimeException('Bitte wähle einen gültigen Ort oder eine gültige Veranstaltung aus der Datenbank.');
}
$sourceListing = $listingCatalog->getEventLocationSource($sourceListingId);
if (!$sourceListing || (string)($sourceListing['listing_type'] ?? '') !== $sourceMode) {
throw new \RuntimeException('Die gewählte Location ist nicht verfügbar.');
}
if ($sourceMode === 'editorial_event') {
$eventDate = substr($eventStartValue, 0, 10);
$sourceStart = !empty($sourceListing['starts_at']) ? substr((string)$sourceListing['starts_at'], 0, 10) : '';
$sourceUntil = !empty($sourceListing['recurrence_until']) ? substr((string)$sourceListing['recurrence_until'], 0, 10) : '';
if ($eventDate === '' || $sourceStart === '' || $sourceUntil === '' || $eventDate < $sourceStart || $eventDate > $sourceUntil) {
throw new \RuntimeException('Die gewählte Veranstaltungs-Location passt zeitlich nicht zum Event-Datum.');
}
}
$street = trim((string)($sourceListing['street'] ?? ''));
$zip = trim((string)($sourceListing['zip'] ?? ''));
$city = trim((string)($sourceListing['city'] ?? ''));
$region = trim((string)($sourceListing['region'] ?? ''));
$lat = isset($sourceListing['lat']) && $sourceListing['lat'] !== '' ? (float)$sourceListing['lat'] : null;
$lng = isset($sourceListing['lng']) && $sourceListing['lng'] !== '' ? (float)$sourceListing['lng'] : null;
$locationLabel = trim((string)($sourceListing['title'] ?? '')) ?: null;
$categorySlug = trim((string)($sourceListing['category_slug'] ?? ''));
} else {
$hasAddress = $street !== '' || $zip !== '' || $city !== '' || $region !== '';
if (!$hasAddress) {
throw new \RuntimeException('Bitte gib für eine benutzerdefinierte Location eine Adresse an.');
}
$resolvedAddress = null;
if ($lat !== null && $lng !== null) {
$resolvedAddress = self::reverseGeocodeAddress($lat, $lng);
}
if ($resolvedAddress === null) {
$searchResults = self::geocodeAddressCandidates($street, $zip, $city, $region, 5);
if (!$searchResults) {
throw new \RuntimeException('Die Adresse konnte nicht validiert werden. Bitte wähle einen passenden Adress-Treffer.');
}
$resolvedAddress = $searchResults[0];
}
$street = $resolvedAddress['street'] !== '' ? $resolvedAddress['street'] : $street;
$zip = $resolvedAddress['zip'] !== '' ? $resolvedAddress['zip'] : $zip;
$city = $resolvedAddress['city'] !== '' ? $resolvedAddress['city'] : $city;
$region = $resolvedAddress['region'] !== '' ? $resolvedAddress['region'] : $region;
$lat = $resolvedAddress['lat'];
$lng = $resolvedAddress['lng'];
if ($categoryInput !== '' && $listingCatalog) {
$category = $listingCatalog->ensureCategoryForInput($categoryInput, 'event');
$categorySlug = (string)($category['slug'] ?? '');
}
}
if ($action === 'event_add') {
$stmt = $pdo?->prepare('INSERT INTO events (created_by, title, teaser_public, description, category_slug, image_path, location_label, location_source_type, location_source_listing_id, street, zip, city, region, lat, lng, starts_at, max_participants, allow_kids, visibility, status, created_at, updated_at) VALUES (:uid, :title, :teaser, :descr, :categorySlug, :imagePath, :loc, :sourceType, :sourceListingId, :street, :zip, :city, :region, :lat, :lng, :start, :capacity, :allow, :vis, :status, NOW(), NOW())');
$stmt?->execute([
'uid' => $userId,
'title' => trim((string)$_POST['title']),
'teaser' => self::generateTeaser(trim((string)$_POST['description']), trim((string)$_POST['title'])),
'descr' => trim((string)$_POST['description']),
'categorySlug' => $categorySlug !== '' ? $categorySlug : null,
'imagePath' => $imagePath !== '' ? $imagePath : null,
'loc' => $locationLabel,
'sourceType' => $sourceMode,
'sourceListingId' => $sourceMode === 'custom' ? null : $sourceListingId,
'street' => $street ?: null,
'zip' => $zip,
'city' => $city,
'region' => $region,
'lat' => $lat,
'lng' => $lng,
'start' => $eventStartValue,
'capacity' => $capacity,
'allow' => $allowKids,
'vis' => $_POST['visibility'] ?? 'public',
'status' => 'published',
]);
$info = 'Event gespeichert.';
self::clearStoredEventDraft();
try {
$cfgPath = dirname(__DIR__, 2) . '/config/community.php';
$communityCfg = file_exists($cfgPath) ? require $cfgPath : [];
$community = new Community($pdo, $communityCfg);
$community->addPoints($userId, 'event', 'create', ['event_id' => $pdo?->lastInsertId()]);
} catch (\Throwable) {
}
} else {
$eventId = (int)($_POST['event_id'] ?? 0);
$stmt = $pdo?->prepare('UPDATE events SET title=:title, teaser_public=:teaser, description=:descr, category_slug=:categorySlug, image_path=:imagePath, location_label=:loc, location_source_type=:sourceType, location_source_listing_id=:sourceListingId, street=:street, zip=:zip, city=:city, region=:region, lat=:lat, lng=:lng, starts_at=:start, max_participants=:capacity, allow_kids=:allow, visibility=:vis, updated_at=NOW() WHERE id=:id AND created_by=:uid');
$stmt?->execute([
'id' => $eventId,
'uid' => $userId,
'title' => trim((string)$_POST['title']),
'teaser' => self::generateTeaser(trim((string)$_POST['description']), trim((string)$_POST['title'])),
'descr' => trim((string)$_POST['description']),
'categorySlug' => $categorySlug !== '' ? $categorySlug : null,
'imagePath' => $imagePath !== '' ? $imagePath : null,
'loc' => $locationLabel,
'sourceType' => $sourceMode,
'sourceListingId' => $sourceMode === 'custom' ? null : $sourceListingId,
'street' => $street ?: null,
'zip' => $zip,
'city' => $city,
'region' => $region,
'lat' => $lat,
'lng' => $lng,
'start' => $eventStartValue,
'capacity' => $capacity,
'allow' => $allowKids,
'vis' => $_POST['visibility'] ?? 'public',
]);
$info = 'Event aktualisiert.';
self::clearStoredEventDraft();
}
}
} elseif ($action === 'event_delete') {
$eventId = (int)($_POST['event_id'] ?? 0);
$stmt = $pdo?->prepare('SELECT id, created_by, status, image_path, (SELECT COUNT(*) FROM event_participants ep WHERE ep.event_id = events.id) AS participant_count FROM events WHERE id = :id LIMIT 1');
$stmt?->execute(['id' => $eventId]);
$ev = $stmt?->fetch(\PDO::FETCH_ASSOC);
if (!$ev || (int)$ev['created_by'] !== $userId) {
throw new \RuntimeException('Event nicht gefunden.');
}
if ((int)$ev['participant_count'] > 0) {
throw new \RuntimeException('Event hat Anmeldungen und kann nicht gelöscht werden.');
}
self::deleteStoredImage((string)($ev['image_path'] ?? ''));
$pdo?->prepare('DELETE FROM events WHERE id = :id')->execute(['id' => $eventId]);
$info = 'Event gelöscht.';
} elseif ($action === 'event_cancel') {
$eventId = (int)($_POST['event_id'] ?? 0);
$stmt = $pdo?->prepare('SELECT id, created_by FROM events WHERE id = :id LIMIT 1');
$stmt?->execute(['id' => $eventId]);
$ev = $stmt?->fetch(\PDO::FETCH_ASSOC);
if (!$ev || (int)$ev['created_by'] !== $userId) {
throw new \RuntimeException('Event nicht gefunden.');
}
$pdo?->prepare('UPDATE events SET status = :st, updated_at = NOW() WHERE id = :id')->execute([
'st' => 'cancelled',
'id' => $eventId,
]);
$info = 'Event wurde abgesagt.';
} elseif ($action === 'listing_delete_request') {
$listingId = (int)($_POST['listing_id'] ?? 0);
if (!$listingCatalog) {
throw new \RuntimeException('Die neue Eintragslogik ist aktuell nicht verfügbar.');
}
$listingCatalog->submitDeleteRequest($userId, $listingId, (string)($_POST['moderation_reason'] ?? ''));
$info = 'Löschanfrage wurde eingereicht.';
} elseif ($action === 'community_admin_apply') {
if (!$community || !$communityAccess) {
throw new \RuntimeException('Community-Funktionen sind aktuell nicht verfügbar.');
}
$points = $community->computePoints($userId);
if (!$communityAccess->canApplyForForumAdmin($userId, $points)) {
throw new \RuntimeException('Du kannst dich aktuell nicht als Forum-Admin bewerben.');
}
$communityAccess->submitApplication($userId, (string)($_POST['motivation'] ?? ''));
$info = 'Deine Bewerbung wurde eingereicht.';
} elseif ($action === 'community_level_save') {
if (!$canManageProfileLevels || !$community) {
throw new \RuntimeException('Keine Berechtigung für Profil-Levels.');
}
$levelId = trim((string)($_POST['level_id'] ?? ''));
$label = trim((string)($_POST['label'] ?? ''));
$minValueRaw = trim((string)($_POST['min_points'] ?? '0'));
$icon = trim((string)($_POST['icon'] ?? ''));
if ($label === '') {
throw new \RuntimeException('Bitte gib einen Namen für das Community-Level an.');
}
if ($minValueRaw === '' || !is_numeric($minValueRaw)) {
throw new \RuntimeException('Bitte gib eine gültige Mindestpunktzahl an.');
}
$rights = [];
foreach (array_keys(Community::membershipRightDefinitions()) as $rightKey) {
$rights[$rightKey] = ((string)($_POST['rights'][$rightKey] ?? '0')) === '1';
}
$levels = $community->listMembershipLevels();
$updated = false;
foreach ($levels as &$level) {
if ((string)($level['id'] ?? '') !== $levelId) {
continue;
}
$level['label'] = $label;
$level['min'] = (float)$minValueRaw;
$level['icon'] = $icon;
$level['rights'] = $rights;
$updated = true;
break;
}
unset($level);
if (!$updated) {
$levels[] = [
'id' => $levelId !== '' ? $levelId : self::slugifyValue($label . '-' . $minValueRaw . '-' . bin2hex(random_bytes(3))),
'label' => $label,
'min' => (float)$minValueRaw,
'icon' => $icon,
'rights' => $rights,
];
}
$community->saveMembershipLevels($levels, $userId);
$info = $updated ? 'Community-Level gespeichert.' : 'Community-Level angelegt.';
} elseif ($action === 'community_level_delete') {
if (!$canManageProfileLevels || !$community) {
throw new \RuntimeException('Keine Berechtigung für Profil-Levels.');
}
$levelId = trim((string)($_POST['level_id'] ?? ''));
$levels = array_values(array_filter(
$community->listMembershipLevels(),
static fn(array $level): bool => (string)($level['id'] ?? '') !== $levelId
));
if ($levels === []) {
throw new \RuntimeException('Mindestens ein Community-Level muss erhalten bleiben.');
}
$community->saveMembershipLevels($levels, $userId);
$info = 'Community-Level gelöscht.';
}
} catch (\Throwable $e) {
$error = $e->getMessage();
}
}
// Daten laden
$profile = [
'display_name' => '',
'first_name' => '',
'last_name' => '',
'zip' => '',
'city' => '',
'region' => '',
'lat' => null,
'lng' => null,
'street' => '',
'profession' => '',
'languages' => '',
'about' => '',
'email' => '',
'contact_phone' => '',
'location_tracking_preference' => 'prompt',
'avatar_style' => AvatarManager::defaultStyle(),
'avatar_seed' => '',
'avatar_config_json' => '',
'avatar_preset' => 'papa-kind-treff',
'avatar_lorelei_eyes_variant' => '',
'avatar_lorelei_eyebrows_variant' => '',
'avatar_lorelei_mouth_variant' => '',
'avatar_lorelei_glasses_variant' => '',
'avatar_lorelei_hair_variant' => '',
'avatar_lorelei_beard_variant' => '',
'avatar_lorelei_earrings_variant' => '',
];
if ($profileSettings) {
$profileSettings->ensureSchema();
}
$avatarColumns = implode(', ', array_map(
static fn(string $column): string => 'p.' . $column,
AvatarManager::allProfileColumns()
));
$stmt = $pdo?->prepare("SELECT u.status, p.display_name, p.first_name, p.last_name, p.street, p.zip, p.city, p.region, p.lat, p.lng, p.profession, p.languages, p.about, p.contact_phone, p.location_tracking_preference, $avatarColumns FROM users u LEFT JOIN user_profiles p ON p.user_id = u.id WHERE u.id = :id LIMIT 1");
$stmt?->execute(['id' => $userId]);
$row = $stmt?->fetch(\PDO::FETCH_ASSOC);
if ($row) {
$profile = array_merge($profile, array_filter($row, fn($v) => $v !== null));
foreach (self::ENCRYPTED_PROFILE_FIELDS as $field) {
$profile[$field] = self::decodeStoredProfileField(
$profile[$field] ?? '',
$crypto,
$pdo,
'user_profiles',
$field,
'user_id',
$userId
);
}
}
$profile['email'] = (new Auth($app))->getEmailByUserId($userId);
$profile = AvatarManager::normalizeProfile($profile, $userId);
$editChildId = isset($_GET['edit_child']) ? (int)$_GET['edit_child'] : 0;
$editChild = null;
$children = [];
$stmt = $pdo?->prepare('SELECT id, encrypted_first_name AS first_name, note, gender, birthdate, age_years FROM children WHERE user_id = :id ORDER BY id DESC');
$stmt?->execute(['id' => $userId]);
$childrenRaw = $stmt?->fetchAll(\PDO::FETCH_ASSOC) ?: [];
foreach ($childrenRaw as $c) {
$c['first_name'] = self::decodeStoredProfileField(
$c['first_name'] ?? '',
$crypto,
$pdo,
'children',
'encrypted_first_name',
'id',
(int)$c['id']
);
$c['note'] = self::decodeStoredProfileField(
$c['note'] ?? '',
$crypto,
$pdo,
'children',
'note',
'id',
(int)$c['id']
);
if (!empty($c['birthdate'])) {
$calculatedAge = self::calculateAgeFromBirthdate((string)$c['birthdate']);
if ($calculatedAge !== null) {
$storedAge = isset($c['age_years']) && $c['age_years'] !== null ? (int)$c['age_years'] : null;
$c['age_years'] = $calculatedAge;
if ($storedAge !== $calculatedAge) {
$updateChildAgeStmt = $updateChildAgeStmt ?? $pdo?->prepare('UPDATE children SET age_years = :age, updated_at = NOW() WHERE id = :id AND user_id = :uid');
$updateChildAgeStmt?->execute([
'age' => $calculatedAge,
'id' => (int)$c['id'],
'uid' => $userId,
]);
}
}
}
$children[] = $c;
if ($editChildId > 0 && (int)$c['id'] === $editChildId) {
$editChild = $c;
}
}
$eventsUpcoming = [];
$eventsPast = [];
$eventsJoinedUpcoming = [];
$eventsJoinedPast = [];
$editEvent = null;
$directoryListings = [];
$userSubmittedListings = [];
$listingRequestMap = [];
$editListing = null;
$restoreEventDraftActive = $section === 'events' && ((string)($_GET['restore_event_draft'] ?? '')) === '1' && self::hasStoredEventDraft();
$restoredEventDraft = $restoreEventDraftActive ? self::getStoredEventDraft() : null;
$listingCategories = $listingCatalog ? $listingCatalog->listCategories(['general', 'place', 'food', 'event', 'family']) : [];
$categoryReviewItems = $listingCatalog && $canManageSystemSettings ? $listingCatalog->listCategoryReviewItems() : [];
$placeSuggestions = $listingCatalog ? $listingCatalog->listPlaceSuggestions(60) : [];
$eventLocationOptions = $listingCatalog ? $listingCatalog->listEventLocationOptions(120) : [];
$stmt = $pdo?->prepare(
'SELECT e.id, e.title, e.teaser_public, e.description, e.category_slug, e.image_path, e.location_label, e.location_source_type, e.location_source_listing_id, e.street, e.zip, e.city, e.region, e.starts_at, e.max_participants, e.allow_kids, e.visibility, e.status, e.lat, e.lng,
(SELECT COUNT(*) FROM event_participants ep WHERE ep.event_id = e.id) AS participant_count
FROM events e
WHERE e.created_by = :id AND e.starts_at >= NOW()
ORDER BY e.starts_at ASC'
);
$stmt?->execute(['id' => $userId]);
$eventsUpcoming = $stmt?->fetchAll(\PDO::FETCH_ASSOC) ?: [];
$stmt = $pdo?->prepare(
'SELECT e.id, e.title, e.teaser_public, e.image_path, e.starts_at, e.city, e.visibility, e.status,
(SELECT COUNT(*) FROM event_participants ep WHERE ep.event_id = e.id) AS participant_count
FROM events e
WHERE e.created_by = :id AND e.starts_at < NOW()
ORDER BY e.starts_at DESC'
);
$stmt?->execute(['id' => $userId]);
$eventsPast = $stmt?->fetchAll(\PDO::FETCH_ASSOC) ?: [];
$stmt = $pdo?->prepare(
'SELECT e.id, e.title, e.teaser_public, e.image_path, e.starts_at, e.city, e.visibility, e.status,
ep.status AS participation_status,
COALESCE(up.display_name, "Mitglied") AS host_name
FROM event_participants ep
INNER JOIN events e ON e.id = ep.event_id
INNER JOIN users u ON u.id = e.created_by
LEFT JOIN user_profiles up ON up.user_id = u.id
WHERE ep.user_id = :participantId AND e.created_by <> :ownerId AND e.starts_at >= NOW()
ORDER BY e.starts_at ASC'
);
$stmt?->execute([
'participantId' => $userId,
'ownerId' => $userId,
]);
$eventsJoinedUpcoming = $stmt?->fetchAll(\PDO::FETCH_ASSOC) ?: [];
$stmt = $pdo?->prepare(
'SELECT e.id, e.title, e.teaser_public, e.image_path, e.starts_at, e.city, e.visibility, e.status,
ep.status AS participation_status,
COALESCE(up.display_name, "Mitglied") AS host_name
FROM event_participants ep
INNER JOIN events e ON e.id = ep.event_id
INNER JOIN users u ON u.id = e.created_by
LEFT JOIN user_profiles up ON up.user_id = u.id
WHERE ep.user_id = :participantId AND e.created_by <> :ownerId AND e.starts_at < NOW()
ORDER BY e.starts_at DESC'
);
$stmt?->execute([
'participantId' => $userId,
'ownerId' => $userId,
]);
$eventsJoinedPast = $stmt?->fetchAll(\PDO::FETCH_ASSOC) ?: [];
if (isset($_GET['edit_event'])) {
$editId = (int)$_GET['edit_event'];
$stmt = $pdo?->prepare('SELECT * FROM events WHERE id = :id AND created_by = :uid AND starts_at >= NOW() LIMIT 1');
$stmt?->execute(['id' => $editId, 'uid' => $userId]);
$editEvent = $stmt?->fetch(\PDO::FETCH_ASSOC) ?: null;
}
if ($listingCatalog) {
$directoryListings = $listingCatalog->listPublishedDirectoryEntries();
$userSubmittedListings = $listingCatalog->listUserSubmittedEntries($userId);
$listingRequestMap = $listingCatalog->listUserOpenModerationRequestMap($userId);
if (isset($_GET['edit_listing'])) {
$editListingId = (int)$_GET['edit_listing'];
if ($editListingId > 0) {
$candidate = $listingCatalog->getMemberEntry($editListingId);
if (is_array($candidate) && (string)($candidate['status'] ?? '') === 'published') {
$editListing = $candidate;
} else {
$error = 'Der gewünschte Eintrag ist für eine Änderungsanfrage nicht verfügbar.';
}
}
}
}
$communityPoints = $community ? $community->computePoints($userId) : 0.0;
$communityLevel = $community ? $community->membershipLevel($communityPoints) : ['label' => '', 'icon' => ''];
$communityLevelDefinitions = $community ? $community->listMembershipLevels() : [];
$communityLevelRightDefinitions = Community::membershipRightDefinitions();
$communityRoles = $communityAccess ? $communityAccess->getUserRoles($userId) : [];
$systemRoleAssignments = $communityAccess && $canManageProfileLevels ? $communityAccess->listRoleAssignments() : [];
$communityApplication = $communityAccess ? $communityAccess->getLatestApplication($userId) : null;
$communityCanApply = $communityAccess ? $communityAccess->canApplyForForumAdmin($userId, $communityPoints) : false;
$communityRestrictions = $communityAccess ? $communityAccess->getRestrictionState($userId) : [
'thread_create_blocked' => false,
'reply_blocked' => false,
'reason' => null,
];
$systemLevelDefinitions = [
[
'key' => 'forum_admin',
'label' => 'Forum-Admin',
'rights' => [
'Themen und Antworten moderieren',
'Community-Sperren setzen und aufheben',
'Meldungen bearbeiten',
],
],
[
'key' => 'site_admin',
'label' => 'Site-Admin',
'rights' => [
'Beinhaltet alle Rechte von Forum-Admin',
'Community-Admin-Bewerbungen bearbeiten',
'System-Einstellungen verwalten',
],
],
[
'key' => 'owner',
'label' => 'SiteOwner',
'rights' => [
'Beinhaltet alle Rechte von Site-Admin',
'System-Rollen vergeben und entziehen',
'Profil-Levels verwalten',
],
],
];
$systemSettingsValues = $systemSettings ? $systemSettings->getAll() : [];
$listingCatalogStatus = $listingCatalog ? $listingCatalog->status() : ['complete' => false, 'missing' => [], 'tables' => []];
if (!in_array($section, $allowedSections, true)) {
$section = 'profile';
}
$avatarBuilder = AvatarManager::builderStyles($profile);
$calendarExportUrl = null;
$calendarFeedUrl = null;
if ($calendarSync) {
$calendarToken = $calendarSync->getOrCreateFeedToken($userId);
$calendarExportUrl = CalendarSync::buildAbsoluteUrl('/calendar/export');
$calendarFeedUrl = CalendarSync::buildAbsoluteUrl('/calendar/feed?token=' . rawurlencode($calendarToken));
}
return compact(
'flash',
'info',
'error',
'profile',
'children',
'editChild',
'eventsUpcoming',
'eventsPast',
'eventsJoinedUpcoming',
'eventsJoinedPast',
'editEvent',
'directoryListings',
'userSubmittedListings',
'listingRequestMap',
'restoreEventDraftActive',
'restoredEventDraft',
'editListing',
'listingCategories',
'categoryReviewItems',
'placeSuggestions',
'eventLocationOptions',
'communityPoints',
'communityLevel',
'communityLevelDefinitions',
'communityLevelRightDefinitions',
'communityRoles',
'systemRoleAssignments',
'communityApplication',
'communityCanApply',
'communityRestrictions',
'systemLevelDefinitions',
'canManageSystemSettings',
'canManageProfileLevels',
'systemSettingsValues',
'listingCatalogStatus',
'avatarBuilder',
'calendarExportUrl',
'calendarFeedUrl',
'section',
'allowedSections'
);
}
private static function calculateAgeFromBirthdate(string $birthdate): ?int
{
try {
$birthDateValue = new \DateTimeImmutable($birthdate);
} catch (\Throwable) {
return null;
}
$today = new \DateTimeImmutable('today');
if ($birthDateValue > $today) {
return null;
}
return $birthDateValue->diff($today)->y;
}
private static function generateTeaser(string $description, string $title = ''): string
{
$source = trim($description) !== '' ? trim($description) : trim($title);
if ($source === '') {
return '';
}
$collapsed = preg_replace('/\s+/u', ' ', $source) ?: $source;
$collapsed = trim($collapsed);
if (mb_strlen($collapsed) <= 140) {
return $collapsed;
}
return rtrim(mb_substr($collapsed, 0, 137)) . '...';
}
private static function requireCrypto(?Crypto $crypto, string $context): Crypto
{
if ($crypto instanceof Crypto) {
return $crypto;
}
throw new \RuntimeException($context . '-Daten können derzeit nicht sicher verarbeitet werden. Bitte später erneut versuchen.');
}
private static function encryptOptionalProfileField(Crypto $crypto, string $value): string
{
return $value !== '' ? $crypto->encrypt($value) : '';
}
private static function decodeStoredProfileField(
mixed $value,
?Crypto $crypto,
?\PDO $pdo,
string $table,
string $column,
string $idColumn,
int $id
): string {
$rawValue = (string)$value;
if ($rawValue === '') {
return '';
}
if (Crypto::looksEncrypted($rawValue)) {
return $crypto ? ($crypto->decrypt($rawValue) ?: '') : '';
}
if ($crypto && $pdo && $id > 0) {
$stmt = $pdo->prepare(sprintf('UPDATE %s SET %s = :value, updated_at = NOW() WHERE %s = :id', $table, $column, $idColumn));
$stmt->execute([
'value' => $crypto->encrypt($rawValue),
'id' => $id,
]);
}
return $rawValue;
}
private static function geocodeAddress(?string $street, ?string $zip, ?string $city, ?string $region): array
{
$candidates = self::geocodeAddressCandidates($street, $zip, $city, $region, 1);
if (!$candidates) {
return [null, null, null];
}
$best = $candidates[0];
return [$best['lat'], $best['lng'], $best['region'] !== '' ? $best['region'] : null];
}
private static function geocodeAddressCandidates(?string $street, ?string $zip, ?string $city, ?string $region, int $limit = 5): array
{
$parts = array_filter([
$street ?: null,
$zip ?: null,
$city ?: null,
$region ?: null,
]);
if (!$parts) {
return [null, null, null];
}
$query = implode(', ', $parts);
$url = 'https://nominatim.openstreetmap.org/search?' . http_build_query([
'format' => 'jsonv2',
'limit' => max(1, min(10, $limit)),
'addressdetails' => 1,
'q' => $query,
]);
$resp = self::fetchGeoJson($url);
if ($resp === null) {
return [];
}
$json = json_decode($resp, true);
if (!is_array($json)) {
return [];
}
$results = [];
foreach ($json as $item) {
if (!is_array($item) || empty($item['lat']) || empty($item['lon'])) {
continue;
}
$results[] = self::normalizeGeoAddressResult($item);
}
return $results;
}
private static function ensureLegacyEventSchema(\PDO $pdo): void
{
$columns = [
'category_slug' => 'ALTER TABLE events ADD COLUMN category_slug VARCHAR(120) NULL AFTER description',
'image_path' => 'ALTER TABLE events ADD COLUMN image_path VARCHAR(255) NULL AFTER category_slug',
'location_source_type' => 'ALTER TABLE events ADD COLUMN location_source_type ENUM("custom","place","editorial_event") NOT NULL DEFAULT "custom" AFTER location_label',
'location_source_listing_id' => 'ALTER TABLE events ADD COLUMN location_source_listing_id BIGINT UNSIGNED NULL AFTER location_source_type',
];
foreach ($columns as $column => $sql) {
$stmt = $pdo->prepare('SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = "events" AND column_name = :column LIMIT 1');
$stmt->execute(['column' => $column]);
if (!$stmt->fetchColumn()) {
$pdo->exec($sql);
}
}
}
private static function storeUploadedImage(mixed $file, string $currentPath = ''): string
{
if (!is_array($file) || (int)($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {
return $currentPath;
}
if ((int)($file['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) {
throw new \RuntimeException('Der Bild-Upload konnte nicht verarbeitet werden.');
}
$tmpName = (string)($file['tmp_name'] ?? '');
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
throw new \RuntimeException('Die hochgeladene Datei ist ungültig.');
}
if ((int)($file['size'] ?? 0) > 5 * 1024 * 1024) {
throw new \RuntimeException('Bitte ein Bild mit maximal 5 MB hochladen.');
}
$imageInfo = @getimagesize($tmpName);
if (!is_array($imageInfo) || empty($imageInfo['mime'])) {
throw new \RuntimeException('Bitte eine gültige Bilddatei hochladen.');
}
$mime = (string)$imageInfo['mime'];
$extension = match ($mime) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
default => '',
};
if ($extension === '') {
throw new \RuntimeException('Erlaubt sind JPG, PNG oder WebP.');
}
$uploadDir = dirname(__DIR__, 2) . '/public/uploads/listings';
if (!is_dir($uploadDir) && !mkdir($uploadDir, 0775, true) && !is_dir($uploadDir)) {
throw new \RuntimeException('Der Upload-Ordner konnte nicht erstellt werden.');
}
$filename = 'listing-' . date('Ymd-His') . '-' . bin2hex(random_bytes(6)) . '.' . $extension;
$targetPath = $uploadDir . '/' . $filename;
if (!move_uploaded_file($tmpName, $targetPath)) {
throw new \RuntimeException('Das Bild konnte nicht gespeichert werden.');
}
self::deleteStoredImage($currentPath);
return '/uploads/listings/' . $filename;
}
private static function deleteStoredImage(string $path): void
{
$path = trim($path);
if ($path === '' || !str_starts_with($path, '/uploads/listings/')) {
return;
}
$absolutePath = dirname(__DIR__, 2) . '/public' . $path;
if (is_file($absolutePath)) {
@unlink($absolutePath);
}
}
private static function slugifyValue(string $value): string
{
$value = mb_strtolower(trim($value));
$value = strtr($value, ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss']);
$value = preg_replace('/[^a-z0-9]+/u', '-', $value) ?: '';
return trim($value, '-');
}
private static function normalizeDateForStorage(string $value, bool $endOfDay = false): ?string
{
$value = trim($value);
if ($value === '') {
return null;
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1) {
return $value . ($endOfDay ? ' 23:59:59' : ' 00:00:00');
}
return $value;
}
private static function reverseGeocodeAddress(float $lat, float $lng): ?array
{
$url = 'https://nominatim.openstreetmap.org/reverse?' . http_build_query([
'format' => 'jsonv2',
'addressdetails' => 1,
'lat' => $lat,
'lon' => $lng,
]);
$resp = self::fetchGeoJson($url);
if ($resp === null) {
return null;
}
$json = json_decode($resp, true);
if (!is_array($json) || empty($json['lat']) || empty($json['lon'])) {
return null;
}
return self::normalizeGeoAddressResult($json);
}
private static function normalizeGeoAddressResult(array $item): array
{
$addr = is_array($item['address'] ?? null) ? $item['address'] : [];
$street = trim(implode(' ', array_filter([
(string)($addr['road'] ?? ''),
(string)($addr['house_number'] ?? ''),
])));
$city = (string)($addr['city'] ?? $addr['town'] ?? $addr['village'] ?? '');
$region = (string)($addr['city_district'] ?? $addr['suburb'] ?? $addr['state'] ?? $addr['county'] ?? $addr['region'] ?? $addr['state_district'] ?? '');
return [
'label' => trim((string)($item['display_name'] ?? '')),
'street' => $street,
'zip' => (string)($addr['postcode'] ?? ''),
'city' => $city,
'region' => $region,
'lat' => round((float)$item['lat'], 7),
'lng' => round((float)$item['lon'], 7),
];
}
private static function fetchGeoJson(string $url): ?string
{
$ctx = stream_context_create([
'http' => [
'method' => 'GET',
'header' => "User-Agent: papa-kind-treff/1.0\r\nAccept-Language: de\r\n",
'timeout' => 6,
],
]);
$resp = @file_get_contents($url, false, $ctx);
return $resp === false ? null : $resp;
}
private static function storeEventDraftInSession(array $source): void
{
$_SESSION[self::EVENT_DRAFT_SESSION_KEY] = self::normalizeEventDraft($source);
}
private static function getStoredEventDraft(): ?array
{
$draft = $_SESSION[self::EVENT_DRAFT_SESSION_KEY] ?? null;
return is_array($draft) ? $draft : null;
}
private static function hasStoredEventDraft(): bool
{
return is_array($_SESSION[self::EVENT_DRAFT_SESSION_KEY] ?? null);
}
private static function clearStoredEventDraft(): void
{
unset($_SESSION[self::EVENT_DRAFT_SESSION_KEY]);
}
private static function attachListingToStoredEventDraft(string $listingType, int $listingId): void
{
$draft = self::getStoredEventDraft();
if (!is_array($draft) || $listingId <= 0 || !in_array($listingType, ['place', 'editorial_event'], true)) {
return;
}
$draft['event_location_mode'] = $listingType;
$draft['event_location_source_id'] = (string)$listingId;
$draft['street'] = '';
$draft['zip'] = '';
$draft['city'] = '';
$draft['region'] = '';
$draft['lat'] = '';
$draft['lng'] = '';
$draft['category_input'] = '';
$draft['category_slug'] = '';
$_SESSION[self::EVENT_DRAFT_SESSION_KEY] = $draft;
}
private static function normalizeEventDraft(array $source): array
{
return [
'title' => trim((string)($source['title'] ?? '')),
'description' => trim((string)($source['description'] ?? '')),
'starts_at' => trim((string)($source['starts_at'] ?? '')),
'with_children' => ((string)($source['with_children'] ?? 'yes')) === 'no' ? 'no' : 'yes',
'max_participants' => trim((string)($source['max_participants'] ?? '')),
'visibility' => in_array((string)($source['visibility'] ?? 'public'), ['public', 'members'], true) ? (string)$source['visibility'] : 'public',
'event_location_mode' => in_array((string)($source['event_location_mode'] ?? 'custom'), ['custom', 'place', 'editorial_event'], true) ? (string)$source['event_location_mode'] : 'custom',
'event_location_source_id' => trim((string)($source['event_location_source_id'] ?? '')),
'street' => trim((string)($source['street'] ?? '')),
'zip' => trim((string)($source['zip'] ?? '')),
'city' => trim((string)($source['city'] ?? '')),
'region' => trim((string)($source['region'] ?? '')),
'lat' => trim((string)($source['lat'] ?? '')),
'lng' => trim((string)($source['lng'] ?? '')),
'category_input' => trim((string)($source['category_input'] ?? '')),
'category_slug' => trim((string)($source['category_slug'] ?? '')),
];
}
}