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

@@ -12,6 +12,8 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter. Kernbereiche sind l
- Die Community ist hierarchisch aufgebaut: Kategorien -> Boards -> Threads -> Posts.
- Moderation ist vom normalen Community-Frontend getrennt.
- Standortfunktionen sind relevant für lokale Events, Termine und Treffen.
- Die bestehende `events`-Logik bleibt zunächst aktiv, wird aber künftig von einer allgemeineren Listing-/Ort-/Termin-Struktur abgelöst.
- Seiten-Admins erhalten eine eigene System-Sektion für globale Betriebs- und Diensteschalter.
## Konto / Mitgliederbereich
- Profil-Menü aktuell: `Profil`, `Kinder`, `Events`, `Community`, `Einstellungen`, `Abmelden`
@@ -26,6 +28,11 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter. Kernbereiche sind l
- Geschäftslogik unter `src/App/`
- Globales Frontend vor allem über `public/assets/js/app.js` und `public/assets/css/app.css`
## Neue Ausbaustufe: Termin-/Ort-System
- Neue Tabellenbasis: `listing_places`, `listings`, `listing_occurrences`, `listing_prices`, `listing_benefits`, `listing_categories`
- Ziel: spätere Unterscheidung zwischen eigenem Event, Partner-Angebot, allgemeinem Ort und redaktionellem Termin
- Google ist noch nicht aktiv integriert; das System wird nur vorbereitend über Settings und Datenfelder darauf ausgelegt
## Pflegehinweis
Bei Änderungen immer auch die parallelen Dateien in `Internal/en/` prüfen und inhaltlich gleichziehen.

View File

@@ -16,6 +16,8 @@ Stand: 2026-08-03
- aktive Styles: `src/App/Avatar/Lorelei.php`, `src/App/Avatar/Croodles.php`
- Profil-/Kinder-/Event-Logik im Mitgliederbereich: `partials/landing/account/dashboard.php`
- Consent- und rechtliche Texte: `public/page/datenschutz.php`
- System-Einstellungen: `src/App/SystemSettings.php`
- neue Listing-/Ort-Basis: `src/App/ListingCatalog.php`
## Dokumentationsregel
Kanonische interne Dokumentation:

View File

@@ -20,6 +20,8 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf lokale
- Konto-E-Mails sowie sensible Profilfelder werden app-seitig verschlüsselt gespeichert
- Kinder können angelegt, bearbeitet und gelöscht werden
- Profiladresse mit verschlüsselter Straße/Hausnummer, Adresssuche, Browser-Übernahme und Validierung
- System-Sektion für Seiten-Admins mit Wartungs- und Diensteschaltern
- neue Datenbasis für ein späteres Termin-, Ort- und Veranstaltungssystem angelegt
## Wording-Regel
- Primärbegriff im Produkt: `Events`
@@ -32,6 +34,7 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf lokale
- App-Logik: `src/App/`
- Assets: `public/assets/`
- Datenbankschema: `schema.sql`
- neue Basisklassen: `src/App/SystemSettings.php`, `src/App/ListingCatalog.php`
## Datenschutz und Sicherheit
- Passwort-Hashes über Argon2id

View File

@@ -12,6 +12,8 @@ Papa-Kind-Treff is a PHP-based platform for fathers. Core areas are local events
- The community is hierarchical: categories -> boards -> threads -> posts.
- Moderation is separated from the normal community frontend.
- Location features are important for local discovery.
- The existing `events` logic remains active for now, but future growth should move toward a broader listing, place, and occurrence model.
- Site admins get a dedicated system section for global operating and service flags.
## Account / Member Area
- Profile menu currently contains `Profile`, `Children`, `Events`, `Community`, `Settings`, `Logout`.
@@ -26,6 +28,11 @@ Papa-Kind-Treff is a PHP-based platform for fathers. Core areas are local events
- business logic under `src/App/`
- global frontend mainly via `public/assets/js/app.js` and `public/assets/css/app.css`
## New Expansion Stage: Listings and Places
- New table base: `listing_places`, `listings`, `listing_occurrences`, `listing_prices`, `listing_benefits`, `listing_categories`
- Goal: later distinguish between own event, partner offer, general place, and editorial event entry
- Google is not integrated productively yet; the system is only being prepared for it via settings and data fields
## Maintenance Note
- Whenever one of these files changes, the matching files in `Internal/de/` and `Internal/en/` must be kept aligned.
- The root `README.md` acts as the startup document for new chats and should be reviewed alongside major context changes.

View File

@@ -16,6 +16,8 @@ Updated: 2026-08-03
- active avatar styles: `src/App/Avatar/Lorelei.php`, `src/App/Avatar/Croodles.php`
- profile/children/event logic inside member area: `partials/landing/account/dashboard.php`
- consent and legal texts: `public/page/datenschutz.php`
- system settings: `src/App/SystemSettings.php`
- new listing/place base: `src/App/ListingCatalog.php`
## Documentation Rule
- canonical internal documentation lives in `Internal/de/` and `Internal/en/`

View File

@@ -20,6 +20,8 @@ Papa-Kind-Treff is a PHP-based platform for fathers focused on local events, app
- account emails and sensitive profile fields are stored encrypted application-side
- children can be created, edited, and deleted
- profile address supports encrypted street/house number, address search, browser import, and validation
- system section for site admins with maintenance and service toggles
- new data foundation prepared for a broader event, place, and listing system
## Core Wording Rule
- Primary product term: `Events`
@@ -31,6 +33,7 @@ Papa-Kind-Treff is a PHP-based platform for fathers focused on local events, app
- application logic: `src/App/`
- assets: `public/assets/`
- database schema: `schema.sql`
- new base classes: `src/App/SystemSettings.php`, `src/App/ListingCatalog.php`
## Privacy and Security
- password hashes via Argon2id

View File

@@ -27,12 +27,15 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf:
- lokale Events, Termine und Treffen
- Community-Austausch
- geschützten Mitgliederbereich mit Profil, Kinderinfos und eigenen Events
- neue Basis für ein übergreifendes Termin-, Ort- und Veranstaltungssystem
## Wichtige aktuelle Produktregeln
- Primärbegriff im Produkt: `Events`
- `Termine` und `Treffen` ergänzend in SEO- und Erklärungstexten
- Hauptnavigation aktuell: `Home`, `Event Suche`, `Community`
- Profil-Menü aktuell: `Profil`, `Kinder`, `Events`, `Community`, `Einstellungen`, `Abmelden`
- neue interne Grundstruktur: `listing_places`, `listings`, `listing_occurrences`, `listing_prices`, `listing_benefits`
- Seiten-Admins haben zusätzlich eine System-Sektion für globale Wartungs- und Diensteschalter
## Datenschutz und Sicherheit
- sensible Profilfelder werden app-seitig verschlüsselt gespeichert

View File

@@ -37,5 +37,47 @@ $app = \App\App::get();
$app->session()->start();
$clientId = $app->session()->ensureClientId();
try {
$pdo = $app->pdo();
if ($pdo) {
$settings = new \App\SystemSettings($pdo);
$settings->ensureSchema();
if ($settings->getBool('site_maintenance_mode')) {
$isAdminUser = false;
if (isset($_SESSION['user_id'])) {
$communityCfg = __DIR__ . '/community.php';
$communityConfig = file_exists($communityCfg) ? require $communityCfg : [];
$communityAccess = new \App\CommunityAccess($pdo, $communityConfig);
$isAdminUser = $communityAccess->canManageApplications((int)$_SESSION['user_id']);
}
$uriPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$uriPath = preg_replace('~/{2,}~', '/', $uriPath);
$uriPath = trim($uriPath, '/');
$maintenanceWhitelist = [
'login',
'logout',
'verify',
'reset',
'impressum',
'datenschutz',
];
if (!$isAdminUser && !in_array($uriPath, $maintenanceWhitelist, true)) {
http_response_code(503);
header('Retry-After: 600');
$message = htmlspecialchars(
$settings->get('site_maintenance_message', 'Papa-Kind-Treff ist gerade kurz in Wartung. Bitte versuche es in Kürze erneut.') ?? '',
ENT_QUOTES
);
echo '<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Wartung</title><style>body{margin:0;font-family:sans-serif;background:#f6efe5;color:#243b53;display:grid;place-items:center;min-height:100vh;padding:24px}.card{max-width:680px;background:#fff;border:1px solid #e5d7c3;border-radius:20px;padding:28px;box-shadow:0 20px 60px rgba(36,59,83,.08)}h1{margin:0 0 12px;font-size:2rem}p{margin:0;line-height:1.6;color:#486581}</style></head><body><main class="card"><h1>Papa-Kind-Treff ist kurz in Wartung</h1><p>' . $message . '</p></main></body></html>';
exit;
}
}
}
} catch (\Throwable) {
// Fail open to avoid locking the whole site on transient settings/schema issues.
}
// Optionally expose a single global for templates if desired
$GLOBALS['client_id'] = $clientId;

View File

@@ -20,6 +20,9 @@ $sectionLinks = [
'community' => 'Community',
'settings' => 'Einstellungen',
];
if (!empty($canManageSystemSettings)) {
$sectionLinks['system'] = 'System';
}
?>
<main class="section">
<div class="container" style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px;">
@@ -336,6 +339,76 @@ $sectionLinks = [
</section>
<?php endif; ?>
<?php if ($section === 'system' && !empty($canManageSystemSettings)): ?>
<section class="account-panel">
<div class="account-panel__head">
<h2>System-Einstellungen</h2>
<p class="muted">Globale Schalter für Wartung, externe Ortsdaten und die neue Termin-/Ort-Basis.</p>
</div>
<div class="account-panel__body">
<form method="post" class="stack gap-12">
<input type="hidden" name="action" value="system_settings_update">
<div class="card">
<strong>Wartung und Verfügbarkeit</strong>
<div class="stack gap-10" style="margin-top:12px;">
<label class="chip" style="cursor:pointer; align-items:flex-start;">
<input type="checkbox" name="forum_maintenance_mode" value="1" <?= !empty($systemSettingsValues['forum_maintenance_mode']) && $systemSettingsValues['forum_maintenance_mode'] === '1' ? 'checked' : '' ?> style="margin-right:8px; margin-top:4px;">
<span>Forum-Wartungsmodus aktivieren
<span class="muted small" style="display:block;">Die Community bleibt sichtbar, aber neue Themen und Antworten werden für normale Nutzer gesperrt.</span>
</span>
</label>
<label class="chip" style="cursor:pointer; align-items:flex-start;">
<input type="checkbox" name="site_maintenance_mode" value="1" <?= !empty($systemSettingsValues['site_maintenance_mode']) && $systemSettingsValues['site_maintenance_mode'] === '1' ? 'checked' : '' ?> style="margin-right:8px; margin-top:4px;">
<span>Seiten-Wartungsmodus aktivieren
<span class="muted small" style="display:block;">Normale Nutzer sehen dann statt der Website eine Wartungsseite. Seiten-Admins bleiben weiterhin im System.</span>
</span>
</label>
<div class="stack gap-6">
<label class="label" for="siteMaintenanceMessage">Wartungshinweis</label>
<textarea id="siteMaintenanceMessage" name="site_maintenance_message" class="textarea" rows="3"><?= htmlspecialchars((string)($systemSettingsValues['site_maintenance_message'] ?? ''), ENT_QUOTES) ?></textarea>
</div>
</div>
</div>
<div class="card">
<strong>Orts- und Bewertungsdienste</strong>
<div class="stack gap-10" style="margin-top:12px;">
<label class="chip" style="cursor:pointer; align-items:flex-start;">
<input type="checkbox" name="google_places_enabled" value="1" <?= !empty($systemSettingsValues['google_places_enabled']) && $systemSettingsValues['google_places_enabled'] === '1' ? 'checked' : '' ?> style="margin-right:8px; margin-top:4px;">
<span>Google Places vorbereitend aktivieren
<span class="muted small" style="display:block;">Aktuell noch ohne produktive Anbindung. Der Schalter ist als technische Vorbereitung für spätere Place-ID-, Bewertungs- und Review-Anreicherung gedacht.</span>
</span>
</label>
<div class="stack gap-6">
<label class="label" for="placeDataProvider">Standardanbieter für Ortsdaten</label>
<select id="placeDataProvider" name="place_data_provider" class="select">
<option value="osm" <?= (($systemSettingsValues['place_data_provider'] ?? 'osm') === 'osm') ? 'selected' : '' ?>>OpenStreetMap / Nominatim</option>
<option value="osm_google_optional" <?= (($systemSettingsValues['place_data_provider'] ?? 'osm') === 'osm_google_optional') ? 'selected' : '' ?>>OpenStreetMap primär, Google optional</option>
</select>
<p class="muted small">So bleibt eine kostenfreie Basis aktiv, während Google später zusätzlich vorbereitet werden kann.</p>
</div>
</div>
</div>
<div class="flex gap-12">
<button class="btn" type="submit">System-Einstellungen speichern</button>
</div>
</form>
<div class="card" style="margin-top:18px;">
<strong>Status der neuen Termin-/Ort-Basis</strong>
<ul class="dash-list" style="margin-top:12px;">
<li>Neue Tabellenbasis: <?= !empty($listingCatalogStatus['complete']) ? 'Vollständig initialisiert' : 'Noch unvollständig' ?></li>
<li>Geplante Grundtypen: Event, Partner-Angebot, Ort, redaktioneller Termin</li>
<li>Geplante Zusatzlogik: Kategorien, Serientermine, Preise, Gutscheine, Kapazitäten</li>
<li>Fehlende Tabellen: <?= !empty($listingCatalogStatus['missing']) ? htmlspecialchars(implode(', ', $listingCatalogStatus['missing']), ENT_QUOTES) : 'Keine' ?></li>
</ul>
</div>
</div>
</section>
<?php endif; ?>
</div>
</div>
</div>

View File

@@ -12,6 +12,9 @@ $boardSlug = trim((string)($_GET['board'] ?? ''));
$communityCfg = require __DIR__ . '/../../../config/community.php';
$community = $pdo ? new \App\Community($pdo, $communityCfg) : null;
$access = $pdo ? new \App\CommunityAccess($pdo, $communityCfg) : null;
$systemSettings = $pdo ? new \App\SystemSettings($pdo) : null;
$forumMaintenanceMode = $systemSettings ? $systemSettings->getBool('forum_maintenance_mode') : false;
$isForumAdmin = ($access && $userId) ? $access->canModerateForum((int)$userId) : false;
$forumCategories = $community ? $community->listForumCategories() : [];
$selectedBoard = ($community && $boardSlug !== '') ? $community->getBoardBySlug($boardSlug) : null;
@@ -23,6 +26,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $community && $access) {
if (!$userId) {
throw new \RuntimeException('Bitte einloggen, um Themen zu erstellen.');
}
if ($forumMaintenanceMode && !$isForumAdmin) {
throw new \RuntimeException('Die Community ist aktuell im Wartungsmodus. Neue Themen sind vorübergehend deaktiviert.');
}
if (!$access->canCreateThread((int)$userId)) {
$state = $access->getRestrictionState((int)$userId);
throw new \RuntimeException($state['reason'] ?: 'Du darfst aktuell keine Themen erstellen.');
@@ -67,8 +73,10 @@ $threads = $community
</div>
</form>
<div class="forum-hero__cta">
<?php if ($userId): ?>
<?php if ($userId && !$forumMaintenanceMode): ?>
<button class="btn" type="button" data-modal-open="modalThread">Neues Thema</button>
<?php elseif ($userId): ?>
<button class="btn ghost" type="button" disabled>Wartungsmodus aktiv</button>
<?php else: ?>
<a class="btn" href="/login">Einloggen und schreiben</a>
<?php endif; ?>
@@ -82,6 +90,11 @@ $threads = $community
<?php if ($info): ?>
<div class="toast-bar" style="margin-top:14px;"><?= htmlspecialchars($info, ENT_QUOTES) ?></div>
<?php endif; ?>
<?php if ($forumMaintenanceMode): ?>
<div class="toast-bar" style="margin-top:14px; border-color:#d4a857; color:#7c5a17;">
Die Community ist aktuell im Wartungsmodus. Lesen bleibt möglich, neue Themen und Antworten sind für normale Nutzer vorübergehend deaktiviert.
</div>
<?php endif; ?>
<?php if ($selectedBoard): ?>
<div class="forum-shell">
@@ -239,7 +252,7 @@ $threads = $community
</div>
</main>
<?php if ($userId): ?>
<?php if ($userId && !$forumMaintenanceMode): ?>
<div class="modal" id="modalThread">
<div class="panel">
<div class="head flex between center-y">

View File

@@ -12,6 +12,8 @@ $editPostId = (int)($_GET['edit_post'] ?? 0);
$communityCfg = require __DIR__ . '/../../../config/community.php';
$community = $pdo ? new \App\Community($pdo, $communityCfg) : null;
$access = $pdo ? new \App\CommunityAccess($pdo, $communityCfg) : null;
$systemSettings = $pdo ? new \App\SystemSettings($pdo) : null;
$forumMaintenanceMode = $systemSettings ? $systemSettings->getBool('forum_maintenance_mode') : false;
$forumCategories = $community ? $community->listForumCategories() : [];
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $community && $access) {
@@ -21,6 +23,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $community && $access) {
if (!$userId) {
throw new \RuntimeException('Bitte einloggen, um zu antworten.');
}
if ($forumMaintenanceMode && !($access && $access->canModerateForum((int)$userId))) {
throw new \RuntimeException('Die Community ist aktuell im Wartungsmodus. Antworten sind vorübergehend deaktiviert.');
}
if (!$access->canReply((int)$userId)) {
$state = $access->getRestrictionState((int)$userId);
throw new \RuntimeException($state['reason'] ?: 'Du darfst aktuell nicht antworten.');
@@ -151,6 +156,11 @@ $userVotes = ($access && $userId) ? $access->getUserPostVotes((int)$userId, $pos
<?php if ($info): ?>
<div class="toast-bar" style="margin-bottom:14px;"><?= htmlspecialchars($info, ENT_QUOTES) ?></div>
<?php endif; ?>
<?php if ($forumMaintenanceMode): ?>
<div class="toast-bar" style="margin-bottom:14px; border-color:#d4a857; color:#7c5a17;">
Die Community ist aktuell im Wartungsmodus. Lesen bleibt möglich, neue Antworten nur für Admins.
</div>
<?php endif; ?>
<article class="forum-post forum-post--lead">
<aside class="forum-post__author">
@@ -290,7 +300,7 @@ $userVotes = ($access && $userId) ? $access->getUserPostVotes((int)$userId, $pos
<?php endif; ?>
</div>
<?php if ($userId): ?>
<?php if ($userId && !$forumMaintenanceMode): ?>
<?php if ($userRestrictions['reply_blocked']): ?>
<div class="forum-login-note">Du darfst aktuell keine Antworten schreiben. <?= !empty($userRestrictions['reason']) ? htmlspecialchars((string)$userRestrictions['reason'], ENT_QUOTES) : '' ?></div>
<?php else: ?>
@@ -303,6 +313,8 @@ $userVotes = ($access && $userId) ? $access->getUserPostVotes((int)$userId, $pos
<button class="btn" type="submit">Antwort senden</button>
</form>
<?php endif; ?>
<?php elseif ($userId): ?>
<div class="forum-login-note">Die Community ist aktuell im Wartungsmodus. Antworten sind vorübergehend deaktiviert.</div>
<?php else: ?>
<div class="forum-login-note">Bitte <a href="/login">einloggen</a>, um zu antworten.</div>
<?php endif; ?>

View File

@@ -15,6 +15,7 @@ if (!in_array($childGender, ['male', 'female', 'mixed'], true)) {
$debugEnabled = defined('APP_DEBUG') && APP_DEBUG === true;
$locationTrackingPreference = 'prompt';
$showDebugTools = false;
$adminSystemNotice = null;
if (isset($_SESSION['user_id'])) {
try {
$pdo = $app->pdo();
@@ -25,10 +26,30 @@ if (isset($_SESSION['user_id'])) {
$communityConfig = file_exists($communityCfg) ? require $communityCfg : [];
$communityAccess = new \App\CommunityAccess($pdo, $communityConfig);
$showDebugTools = $debugEnabled && $communityAccess->hasRole((int)$_SESSION['user_id'], 'site_admin');
if ($communityAccess->canManageApplications((int)$_SESSION['user_id'])) {
$systemSettings = new \App\SystemSettings($pdo);
$siteMaintenanceMode = $systemSettings->getBool('site_maintenance_mode');
$forumMaintenanceMode = $systemSettings->getBool('forum_maintenance_mode');
$googlePlacesEnabled = $systemSettings->getBool('google_places_enabled');
$parts = [];
if ($siteMaintenanceMode) {
$parts[] = 'Seiten-Wartungsmodus aktiv';
}
if ($forumMaintenanceMode) {
$parts[] = 'Forum-Wartungsmodus aktiv';
}
if ($googlePlacesEnabled) {
$parts[] = 'Google-Places-Vorbereitung aktiv';
}
if ($parts !== []) {
$adminSystemNotice = implode(' · ', $parts);
}
}
}
} catch (\Throwable) {
$locationTrackingPreference = 'prompt';
$showDebugTools = false;
$adminSystemNotice = null;
}
}
$debugFiles = [];
@@ -105,6 +126,11 @@ if ($debugEnabled) {
</script>
<?php tpl('matomo', 'structure'); ?>
<?php tpl('nav', 'structure'); ?>
<?php if ($adminSystemNotice !== null): ?>
<div class="container" style="margin-top:14px;">
<div class="toast-bar" style="border-color:#d4a857; color:#7c5a17;"><?= htmlspecialchars($adminSystemNotice, ENT_QUOTES) ?></div>
</div>
<?php endif; ?>
<div class="cookie-consent" id="cookieConsentBanner" hidden>
<div class="cookie-consent__inner">
<div class="cookie-consent__copy">

View File

@@ -4,6 +4,7 @@ $isLoggedIn = isset($_SESSION['user_id']);
$displayName = 'Profil';
$profileInitial = 'P';
$showAdminLink = false;
$showSystemLink = false;
if ($isLoggedIn) {
try {
@@ -25,12 +26,14 @@ if ($isLoggedIn) {
$communityConfig = require $communityCfg;
$communityAccess = new \App\CommunityAccess($pdo, $communityConfig);
$showAdminLink = $communityAccess->canModerateForum((int)$_SESSION['user_id']);
$showSystemLink = $communityAccess->canManageApplications((int)$_SESSION['user_id']);
}
}
} catch (\Throwable) {
$displayName = 'Profil';
$profileInitial = 'P';
$showAdminLink = false;
$showSystemLink = false;
}
}
?>
@@ -61,6 +64,9 @@ if ($isLoggedIn) {
<a href="/dashboard?section=events" role="menuitem">Events</a>
<a href="/dashboard?section=community" role="menuitem">Community</a>
<a href="/dashboard?section=settings" role="menuitem">Einstellungen</a>
<?php if ($showSystemLink): ?>
<a href="/dashboard?section=system" role="menuitem">System</a>
<?php endif; ?>
<?php if ($showAdminLink): ?>
<a href="/community-admin" role="menuitem">Admin</a>
<?php endif; ?>
@@ -84,6 +90,9 @@ if ($isLoggedIn) {
<a class="btn ghost" href="/dashboard?section=events">Events</a>
<a class="btn ghost" href="/dashboard?section=community">Community</a>
<a class="btn ghost" href="/dashboard?section=settings">Einstellungen</a>
<?php if ($showSystemLink): ?>
<a class="btn ghost" href="/dashboard?section=system">System</a>
<?php endif; ?>
<?php if ($showAdminLink): ?>
<a class="btn ghost" href="/community-admin">Admin</a>
<?php endif; ?>

View File

@@ -115,6 +115,143 @@ CREATE TABLE event_participants (
CONSTRAINT fk_ep_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE 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;
CREATE TABLE 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 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 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 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 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 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 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;
-- Community / Forum
CREATE TABLE forum_categories (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,

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,
]);
}
}
}