ycxyxc
All checks were successful
Deploy / deploy (push) Successful in 1m3s

This commit is contained in:
2026-08-04 00:42:40 +02:00
parent fc34e87622
commit fa6e975df4
17 changed files with 701 additions and 3 deletions

View File

@@ -174,8 +174,21 @@ final class AccountPages
$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'] ?? '';
@@ -273,6 +286,26 @@ final class AccountPages
);
}
$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);
@@ -588,6 +621,8 @@ final class AccountPages
'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';
}
@@ -609,6 +644,9 @@ final class AccountPages
'communityApplication',
'communityCanApply',
'communityRestrictions',
'canManageSystemSettings',
'systemSettingsValues',
'listingCatalogStatus',
'avatarBuilder',
'section',
'allowedSections'

214
src/App/ListingCatalog.php Normal file
View File

@@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
namespace App;
final class ListingCatalog
{
private array $tableCache = [];
public function __construct(private \PDO $pdo)
{
}
public function ensureSchema(): void
{
$statements = [
'CREATE TABLE IF NOT EXISTS listing_categories (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(120) NOT NULL UNIQUE,
title VARCHAR(160) NOT NULL,
category_group ENUM("general","event","place","food","family","partner") NOT NULL DEFAULT "general",
sort_order SMALLINT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci',
'CREATE TABLE IF NOT EXISTS listing_places (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
created_by BIGINT UNSIGNED NULL,
source_type ENUM("user","partner","admin","system") NOT NULL DEFAULT "user",
title VARCHAR(180) NOT NULL,
description TEXT NULL,
street VARCHAR(180) NULL,
zip CHAR(5) NULL,
city VARCHAR(120) NULL,
region VARCHAR(120) NULL,
lat DECIMAL(10,7) NULL,
lng DECIMAL(10,7) NULL,
website_url VARCHAR(255) NULL,
phone VARCHAR(60) NULL,
place_kind VARCHAR(80) NULL,
provider_hint ENUM("manual","osm","google") NOT NULL DEFAULT "manual",
external_place_id VARCHAR(190) NULL,
google_place_id VARCHAR(190) NULL,
rating_value DECIMAL(3,2) NULL,
rating_count INT UNSIGNED NULL,
status ENUM("draft","published","archived") NOT NULL DEFAULT "published",
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_listing_places_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_listing_places_city (city),
INDEX idx_listing_places_region (region),
INDEX idx_listing_places_latlng (lat, lng),
INDEX idx_listing_places_kind (place_kind)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci',
'CREATE TABLE IF NOT EXISTS listings (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
created_by BIGINT UNSIGNED NULL,
owner_type ENUM("user","partner","admin","system") NOT NULL DEFAULT "user",
listing_type ENUM("event","partner_offer","place","editorial_event") NOT NULL DEFAULT "event",
primary_place_id BIGINT UNSIGNED NULL,
title VARCHAR(200) NOT NULL,
teaser_public VARCHAR(280) NOT NULL,
description TEXT NOT NULL,
visibility ENUM("public","members") NOT NULL DEFAULT "public",
status ENUM("draft","published","cancelled","archived") NOT NULL DEFAULT "draft",
supports_registration TINYINT(1) NOT NULL DEFAULT 0,
supports_capacity TINYINT(1) NOT NULL DEFAULT 0,
supports_pricing TINYINT(1) NOT NULL DEFAULT 0,
is_recurring TINYINT(1) NOT NULL DEFAULT 0,
legacy_event_id BIGINT UNSIGNED NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_listings_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_listings_place FOREIGN KEY (primary_place_id) REFERENCES listing_places(id) ON DELETE SET NULL,
CONSTRAINT fk_listings_legacy_event FOREIGN KEY (legacy_event_id) REFERENCES events(id) ON DELETE SET NULL,
INDEX idx_listings_type (listing_type),
INDEX idx_listings_status (status),
INDEX idx_listings_owner (owner_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci',
'CREATE TABLE IF NOT EXISTS listing_category_map (
listing_id BIGINT UNSIGNED NOT NULL,
category_id BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (listing_id, category_id),
CONSTRAINT fk_lcm_listing FOREIGN KEY (listing_id) REFERENCES listings(id) ON DELETE CASCADE,
CONSTRAINT fk_lcm_category FOREIGN KEY (category_id) REFERENCES listing_categories(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci',
'CREATE TABLE IF NOT EXISTS listing_occurrences (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
listing_id BIGINT UNSIGNED NOT NULL,
occurrence_type ENUM("single","series","range","open_ended") NOT NULL DEFAULT "single",
starts_at DATETIME NULL,
ends_at DATETIME NULL,
recurrence_rule VARCHAR(255) NULL,
recurrence_until DATETIME NULL,
capacity_total SMALLINT UNSIGNED NULL,
booking_url VARCHAR(255) NULL,
status ENUM("scheduled","cancelled","sold_out") NOT NULL DEFAULT "scheduled",
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_listing_occurrences_listing FOREIGN KEY (listing_id) REFERENCES listings(id) ON DELETE CASCADE,
INDEX idx_listing_occurrences_start (starts_at),
INDEX idx_listing_occurrences_type (occurrence_type),
INDEX idx_listing_occurrences_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci',
'CREATE TABLE IF NOT EXISTS listing_prices (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
listing_id BIGINT UNSIGNED NOT NULL,
occurrence_id BIGINT UNSIGNED NULL,
label VARCHAR(120) NOT NULL,
audience ENUM("general","adult","child","family","group") NOT NULL DEFAULT "general",
price_type ENUM("free","fixed","from","up_to","range","request") NOT NULL DEFAULT "fixed",
amount DECIMAL(10,2) NULL,
amount_secondary DECIMAL(10,2) NULL,
currency CHAR(3) NOT NULL DEFAULT "EUR",
note VARCHAR(255) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_listing_prices_listing FOREIGN KEY (listing_id) REFERENCES listings(id) ON DELETE CASCADE,
CONSTRAINT fk_listing_prices_occurrence FOREIGN KEY (occurrence_id) REFERENCES listing_occurrences(id) ON DELETE CASCADE,
INDEX idx_listing_prices_listing (listing_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci',
'CREATE TABLE IF NOT EXISTS listing_benefits (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
listing_id BIGINT UNSIGNED NOT NULL,
occurrence_id BIGINT UNSIGNED NULL,
benefit_type ENUM("voucher_code","voucher_hint","discount_text") NOT NULL DEFAULT "voucher_hint",
title VARCHAR(160) NOT NULL,
code VARCHAR(120) NULL,
description TEXT NULL,
valid_from DATETIME NULL,
valid_until DATETIME NULL,
is_public TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_listing_benefits_listing FOREIGN KEY (listing_id) REFERENCES listings(id) ON DELETE CASCADE,
CONSTRAINT fk_listing_benefits_occurrence FOREIGN KEY (occurrence_id) REFERENCES listing_occurrences(id) ON DELETE CASCADE,
INDEX idx_listing_benefits_listing (listing_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci',
];
foreach ($statements as $sql) {
$this->pdo->exec($sql);
}
$seed = $this->pdo->prepare(
'INSERT INTO listing_categories (slug, title, category_group, sort_order)
VALUES (:slug, :title, :groupName, :sortOrder)
ON DUPLICATE KEY UPDATE title = VALUES(title), category_group = VALUES(category_group), sort_order = VALUES(sort_order), updated_at = CURRENT_TIMESTAMP'
);
foreach ($this->defaultCategories() as $index => $category) {
$seed->execute([
'slug' => $category['slug'],
'title' => $category['title'],
'groupName' => $category['group'],
'sortOrder' => $index + 1,
]);
}
}
public function status(): array
{
$tables = [
'listing_categories',
'listing_places',
'listings',
'listing_category_map',
'listing_occurrences',
'listing_prices',
'listing_benefits',
];
$missing = [];
foreach ($tables as $table) {
if (!$this->hasTable($table)) {
$missing[] = $table;
}
}
return [
'complete' => $missing === [],
'missing' => $missing,
'tables' => $tables,
];
}
private function hasTable(string $table): bool
{
if (array_key_exists($table, $this->tableCache)) {
return $this->tableCache[$table];
}
try {
$stmt = $this->pdo->prepare('SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = :table LIMIT 1');
$stmt->execute(['table' => $table]);
return $this->tableCache[$table] = (bool)$stmt->fetchColumn();
} catch (\Throwable) {
return $this->tableCache[$table] = false;
}
}
private function defaultCategories(): array
{
return [
['slug' => 'spielplatz', 'title' => 'Spielplatz', 'group' => 'place'],
['slug' => 'cafe', 'title' => 'Café', 'group' => 'food'],
['slug' => 'restaurant', 'title' => 'Restaurant', 'group' => 'food'],
['slug' => 'indoor-spielort', 'title' => 'Indoor-Spielort', 'group' => 'place'],
['slug' => 'zirkus', 'title' => 'Zirkus', 'group' => 'event'],
['slug' => 'huepfburgen', 'title' => 'Hüpfburgen', 'group' => 'event'],
['slug' => 'workshop', 'title' => 'Workshop', 'group' => 'event'],
['slug' => 'kurs', 'title' => 'Kurs', 'group' => 'event'],
['slug' => 'familienfest', 'title' => 'Familienfest', 'group' => 'family'],
];
}
}

107
src/App/SystemSettings.php Normal file
View File

@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App;
final class SystemSettings
{
private array $tableCache = [];
private const DEFAULTS = [
'google_places_enabled' => '0',
'forum_maintenance_mode' => '0',
'site_maintenance_mode' => '0',
'site_maintenance_message' => 'Papa-Kind-Treff ist gerade kurz in Wartung. Bitte versuche es in Kürze erneut.',
'place_data_provider' => 'osm',
];
public function __construct(private \PDO $pdo)
{
}
public function ensureSchema(): void
{
$this->pdo->exec(
'CREATE TABLE IF NOT EXISTS system_settings (
`key` VARCHAR(120) NOT NULL PRIMARY KEY,
`value` TEXT NULL,
updated_by BIGINT UNSIGNED NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_system_settings_updated_by FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
);
$stmt = $this->pdo->prepare(
'INSERT INTO system_settings (`key`, `value`, updated_by)
VALUES (:key, :value, NULL)
ON DUPLICATE KEY UPDATE `value` = `value`'
);
foreach (self::DEFAULTS as $key => $value) {
$stmt->execute([
'key' => $key,
'value' => $value,
]);
}
}
public function getAll(): array
{
$this->ensureSchema();
$settings = self::DEFAULTS;
$stmt = $this->pdo->query('SELECT `key`, `value` FROM system_settings');
foreach ($stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [] as $row) {
$settings[(string)$row['key']] = (string)($row['value'] ?? '');
}
return $settings;
}
public function get(string $key, ?string $default = null): ?string
{
$this->ensureSchema();
$stmt = $this->pdo->prepare('SELECT `value` FROM system_settings WHERE `key` = :key LIMIT 1');
$stmt->execute(['key' => $key]);
$value = $stmt->fetchColumn();
if ($value === false) {
return $default ?? (self::DEFAULTS[$key] ?? null);
}
return (string)$value;
}
public function getBool(string $key, bool $default = false): bool
{
$value = $this->get($key, $default ? '1' : '0');
return in_array((string)$value, ['1', 'true', 'yes', 'on'], true);
}
public function set(string $key, string $value, ?int $updatedBy = null): void
{
$this->ensureSchema();
$stmt = $this->pdo->prepare(
'INSERT INTO system_settings (`key`, `value`, updated_by)
VALUES (:key, :value, :updatedBy)
ON DUPLICATE KEY UPDATE `value` = VALUES(`value`), updated_by = VALUES(updated_by), updated_at = CURRENT_TIMESTAMP'
);
$stmt->execute([
'key' => $key,
'value' => $value,
'updatedBy' => $updatedBy,
]);
}
public function updateMany(array $values, ?int $updatedBy = null): void
{
$this->ensureSchema();
$stmt = $this->pdo->prepare(
'INSERT INTO system_settings (`key`, `value`, updated_by)
VALUES (:key, :value, :updatedBy)
ON DUPLICATE KEY UPDATE `value` = VALUES(`value`), updated_by = VALUES(updated_by), updated_at = CURRENT_TIMESTAMP'
);
foreach ($values as $key => $value) {
$stmt->execute([
'key' => (string)$key,
'value' => (string)$value,
'updatedBy' => $updatedBy,
]);
}
}
}