1410 lines
71 KiB
PHP
Executable File
1410 lines
71 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.';
|
|
} elseif ($action === 'community_points_adjust') {
|
|
if (!$canManageProfileLevels || !$community) {
|
|
throw new \RuntimeException('Keine Berechtigung für Community-Punkte.');
|
|
}
|
|
|
|
$targetUserId = (int)($_POST['target_user_id'] ?? 0);
|
|
$amountRaw = trim((string)($_POST['points_amount'] ?? ''));
|
|
$reason = trim((string)($_POST['points_reason'] ?? ''));
|
|
if ($targetUserId <= 0) {
|
|
throw new \RuntimeException('Bitte wähle einen Benutzer aus.');
|
|
}
|
|
if ($amountRaw === '' || !is_numeric($amountRaw)) {
|
|
throw new \RuntimeException('Bitte gib eine gültige Punktzahl an.');
|
|
}
|
|
|
|
$amount = (float)$amountRaw;
|
|
if ($amount <= 0) {
|
|
throw new \RuntimeException('Es können hier nur zusätzliche Punkte vergeben werden.');
|
|
}
|
|
if ($reason === '') {
|
|
throw new \RuntimeException('Bitte gib eine Begründung für die Punktevergabe an.');
|
|
}
|
|
|
|
$community->adjustPoints($targetUserId, $amount, $reason, $userId);
|
|
$info = 'Community-Punkte wurden erhöht.';
|
|
}
|
|
} 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() : [];
|
|
$recentHighLevelUsers = [];
|
|
$recentHighLevelThresholdLabel = '';
|
|
$profileLevelUserSearchQuery = '';
|
|
$profileLevelUserSearchResults = [];
|
|
if ($communityAccess && $community && $canManageProfileLevels) {
|
|
$highLevelThreshold = null;
|
|
foreach ($communityLevelDefinitions as $levelDefinition) {
|
|
if ((string)($levelDefinition['label'] ?? '') === 'Säule der Väter-Community') {
|
|
$highLevelThreshold = (float)($levelDefinition['min'] ?? 0.0);
|
|
$recentHighLevelThresholdLabel = (string)($levelDefinition['label'] ?? '');
|
|
break;
|
|
}
|
|
}
|
|
if ($highLevelThreshold !== null && $highLevelThreshold > 0) {
|
|
$recentHighLevelUsers = $community->listRecentlyReachedLevelUsers($highLevelThreshold, 30, 12);
|
|
}
|
|
|
|
$profileLevelUserSearchQuery = trim((string)($_GET['profile_level_user_query'] ?? ''));
|
|
if ($profileLevelUserSearchQuery !== '') {
|
|
foreach ($communityAccess->searchUsers($profileLevelUserSearchQuery, 12) as $searchRow) {
|
|
$targetSearchUserId = (int)($searchRow['id'] ?? 0);
|
|
$targetPoints = $community->computePoints($targetSearchUserId);
|
|
$searchRow['community_points'] = $targetPoints;
|
|
$searchRow['community_level'] = $community->membershipLevel($targetPoints);
|
|
$searchRow['roles'] = $communityAccess->getUserRoles($targetSearchUserId);
|
|
$profileLevelUserSearchResults[] = $searchRow;
|
|
}
|
|
}
|
|
}
|
|
$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',
|
|
'recentHighLevelUsers',
|
|
'recentHighLevelThresholdLabel',
|
|
'profileLevelUserSearchQuery',
|
|
'profileLevelUserSearchResults',
|
|
'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'] ?? '')),
|
|
];
|
|
}
|
|
}
|