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']; $app->flash()->set('success', 'Erfolgreich angemeldet.'); 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; $section = (string)($_GET['section'] ?? 'profile'); $canManageSystemSettings = $communityAccess ? $communityAccess->canManageApplications($userId) : false; $allowedSections = ['profile', 'children', 'events', 'community', 'settings']; if ($canManageSystemSettings) { $allowedSections[] = 'system'; } if ($systemSettings) { $systemSettings->ensureSchema(); } if ($listingCatalog) { $listingCatalog->ensureSchema(); } if ($_SERVER['REQUEST_METHOD'] === 'POST') { $action = $_POST['action'] ?? ''; try { if ($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 === '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.'); } $listingCatalog->saveDashboardEntry( $userId, $_POST, $action === 'event_update' ? (int)($_POST['listing_id'] ?? 0) : null ); $info = $entryKind === 'place' ? 'Ort gespeichert.' : 'Veranstaltung gespeichert.'; } else { $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; $needsGeocode = ($lat === null || $lng === null || $region === ''); if ($needsGeocode) { [$geoLat, $geoLng, $geoRegion] = self::geocodeAddress($street, $zip, $city, $region); if ($lat === null) { $lat = $geoLat; } if ($lng === null) { $lng = $geoLng; } if ($region === '' && $geoRegion) { $region = $geoRegion; } } if ($action === 'event_add') { $stmt = $pdo?->prepare('INSERT INTO events (created_by, title, teaser_public, description, location_label, street, zip, city, region, lat, lng, starts_at, allow_kids, visibility, status, created_at, updated_at) VALUES (:uid, :title, :teaser, :descr, :loc, :street, :zip, :city, :region, :lat, :lng, :start, :allow, :vis, :status, NOW(), NOW())'); $stmt?->execute([ 'uid' => $userId, 'title' => trim((string)$_POST['title']), 'teaser' => trim((string)$_POST['teaser']), 'descr' => trim((string)$_POST['description']), 'loc' => trim((string)$_POST['location_label']), 'street' => $street ?: null, 'zip' => $zip, 'city' => $city, 'region' => $region, 'lat' => $lat, 'lng' => $lng, 'start' => $_POST['starts_at'] ?? null, 'allow' => isset($_POST['allow_kids']) ? 0 : 1, 'vis' => $_POST['visibility'] ?? 'public', 'status' => 'published', ]); $info = 'Event gespeichert.'; 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, location_label=:loc, street=:street, zip=:zip, city=:city, region=:region, lat=:lat, lng=:lng, starts_at=:start, 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' => trim((string)$_POST['teaser']), 'descr' => trim((string)$_POST['description']), 'loc' => trim((string)$_POST['location_label']), 'street' => $street ?: null, 'zip' => $zip, 'city' => $city, 'region' => $region, 'lat' => $lat, 'lng' => $lng, 'start' => $_POST['starts_at'] ?? null, 'allow' => isset($_POST['allow_kids']) ? 0 : 1, 'vis' => $_POST['visibility'] ?? 'public', ]); $info = 'Event aktualisiert.'; } } } elseif ($action === 'event_delete') { $eventId = (int)($_POST['event_id'] ?? 0); $stmt = $pdo?->prepare('SELECT id, created_by, status, (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.'); } $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') { $listingId = (int)($_POST['listing_id'] ?? 0); if (!$listingCatalog) { throw new \RuntimeException('Die neue Eintragslogik ist aktuell nicht verfügbar.'); } $listingCatalog->deleteDashboardEntry($userId, $listingId); $info = 'Eintrag gelöscht.'; } 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.'; } } 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 = []; $editEvent = null; $otherListings = []; $editListing = null; $listingCategories = $listingCatalog ? $listingCatalog->listCategories(['place', 'food', 'event', 'family']) : []; $stmt = $pdo?->prepare( 'SELECT e.id, e.title, e.teaser_public, e.description, e.location_label, e.street, e.zip, e.city, e.region, e.starts_at, 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.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) ?: []; 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) { $otherListings = $listingCatalog->listDashboardEntries($userId); if (isset($_GET['edit_listing'])) { $editListingId = (int)$_GET['edit_listing']; if ($editListingId > 0) { $editListing = $listingCatalog->getDashboardEntry($userId, $editListingId); } } } $communityPoints = $community ? $community->computePoints($userId) : 0.0; $communityLevel = $community ? $community->membershipLevel($communityPoints) : ['label' => '', 'icon' => '']; $communityRoles = $communityAccess ? $communityAccess->getUserRoles($userId) : []; $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, ]; $systemSettingsValues = $systemSettings ? $systemSettings->getAll() : []; $listingCatalogStatus = $listingCatalog ? $listingCatalog->status() : ['complete' => false, 'missing' => [], 'tables' => []]; if (!in_array($section, $allowedSections, true)) { $section = 'profile'; } $avatarBuilder = AvatarManager::builderStyles($profile); return compact( 'flash', 'info', 'error', 'profile', 'children', 'editChild', 'eventsUpcoming', 'eventsPast', 'editEvent', 'otherListings', 'editListing', 'listingCategories', 'communityPoints', 'communityLevel', 'communityRoles', 'communityApplication', 'communityCanApply', 'communityRestrictions', 'canManageSystemSettings', 'systemSettingsValues', 'listingCatalogStatus', 'avatarBuilder', '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 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 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; } }