adasd
All checks were successful
Deploy / deploy (push) Successful in 58s

This commit is contained in:
2026-08-17 00:05:18 +02:00
parent d3ebe6fa68
commit 9d5bad8294
14 changed files with 451 additions and 13 deletions

View File

@@ -17,6 +17,7 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter. Kernbereiche sind l
## Konto / Mitgliederbereich ## Konto / Mitgliederbereich
- Profil-Menü aktuell: `Profil`, `Kinder`, `Events`, `Orte & Veranstaltungen`, `Community`, `Einstellungen`, `Abmelden` - Profil-Menü aktuell: `Profil`, `Kinder`, `Events`, `Orte & Veranstaltungen`, `Community`, `Einstellungen`, `Abmelden`
- Für `SiteOwner` kommt zusätzlich der Bereich `Profil-Levels` dazu.
- Kinder können angelegt, bearbeitet und gelöscht werden. - Kinder können angelegt, bearbeitet und gelöscht werden.
- Wenn ein Geburtsdatum gesetzt ist, wird das Alter automatisch berechnet und später bei Bedarf aktualisiert. - Wenn ein Geburtsdatum gesetzt ist, wird das Alter automatisch berechnet und später bei Bedarf aktualisiert.
- Die Konto-E-Mail wird app-seitig verschlüsselt gespeichert und über einen separaten HMAC-Lookup-Hash adressiert. - Die Konto-E-Mail wird app-seitig verschlüsselt gespeichert und über einen separaten HMAC-Lookup-Hash adressiert.
@@ -43,6 +44,9 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter. Kernbereiche sind l
- Bei `Veranstaltung aus Datenbank` muss das Event-Datum innerhalb des hinterlegten Gültigkeitszeitraums dieser Veranstaltungs-Quelle liegen. - Bei `Veranstaltung aus Datenbank` muss das Event-Datum innerhalb des hinterlegten Gültigkeitszeitraums dieser Veranstaltungs-Quelle liegen.
- Kategorien werden im Mitgliederbereich per Sucheingabe mit bestehenden Vorschlägen und automatischer Neuanlage gepflegt. - Kategorien werden im Mitgliederbereich per Sucheingabe mit bestehenden Vorschlägen und automatischer Neuanlage gepflegt.
- Eigene Events unterstützen optionalen Beschreibungstext, Ja/Nein-Angabe `Mit Kindern`, optionale Platzzahl, optionales Bild sowie eine Location aus Datenbank oder benutzerdefinierte Adresse. - Eigene Events unterstützen optionalen Beschreibungstext, Ja/Nein-Angabe `Mit Kindern`, optionale Platzzahl, optionales Bild sowie eine Location aus Datenbank oder benutzerdefinierte Adresse.
- `Profil-Levels` ist ein Owner-Bereich für Community-Level und die Übersicht der System-Level.
- Community-Level sind jetzt über `system_settings.community_levels_json` editierbar und enthalten aktuell die Rechte `Hilfreiche Antworten hervorheben` und `Bewerbung als Forum-Admin`.
- System-Level bleiben getrennt als Rollen `Forum-Admin`, `Site-Admin` und `SiteOwner`.
## Technischer Rahmen ## Technischer Rahmen
- eigener Front-Controller in `public/index.php` - eigener Front-Controller in `public/index.php`

View File

@@ -20,6 +20,7 @@ Stand: 2026-08-10
- Event-Quellen aus Datenbank plus Speicherung der Event-Location-Herkunft: `partials/landing/account/dashboard.php`, `src/App/AccountPages.php`, `Internal/db/schema.sql` - Event-Quellen aus Datenbank plus Speicherung der Event-Location-Herkunft: `partials/landing/account/dashboard.php`, `src/App/AccountPages.php`, `Internal/db/schema.sql`
- Consent- und rechtliche Texte: `public/page/datenschutz.php` - Consent- und rechtliche Texte: `public/page/datenschutz.php`
- System-Einstellungen: `src/App/SystemSettings.php` - System-Einstellungen: `src/App/SystemSettings.php`
- Profil-Levels für Owner: `partials/landing/account/dashboard.php`, `src/App/AccountPages.php`, `src/App/Community.php`, `src/App/CommunityAccess.php`, `src/App/SystemSettings.php`
- neue Listing-/Ort-Basis: `src/App/ListingCatalog.php` - neue Listing-/Ort-Basis: `src/App/ListingCatalog.php`
- Kalender-Export und abonnierbarer Feed: `src/App/CalendarSync.php` plus `public/page/calendar/export.php` und `public/page/calendar/feed.php` - Kalender-Export und abonnierbarer Feed: `src/App/CalendarSync.php` plus `public/page/calendar/export.php` und `public/page/calendar/feed.php`
- UI für getrennte Bereiche `Events` und `Orte & Veranstaltungen` im Mitgliederbereich: `partials/landing/account/dashboard.php` - UI für getrennte Bereiche `Events` und `Orte & Veranstaltungen` im Mitgliederbereich: `partials/landing/account/dashboard.php`

View File

@@ -17,6 +17,7 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf lokale
- standortbasierte Sortierung für die neuesten Events - standortbasierte Sortierung für die neuesten Events
- Mitgliederbereich mit linker Bereichsnavigation - Mitgliederbereich mit linker Bereichsnavigation
- Profil-Menü mit `Profil`, `Kinder`, `Events`, `Orte & Veranstaltungen`, `Community`, `Einstellungen` - Profil-Menü mit `Profil`, `Kinder`, `Events`, `Orte & Veranstaltungen`, `Community`, `Einstellungen`
- Für `SiteOwner` gibt es zusätzlich den Punkt `Profil-Levels`
- Konto-E-Mails sowie sensible Profilfelder werden app-seitig verschlüsselt gespeichert - Konto-E-Mails sowie sensible Profilfelder werden app-seitig verschlüsselt gespeichert
- Kinder können angelegt, bearbeitet und gelöscht werden - Kinder können angelegt, bearbeitet und gelöscht werden
- Profiladresse mit verschlüsselter Straße/Hausnummer, Adresssuche, Browser-Übernahme und Validierung - Profiladresse mit verschlüsselter Straße/Hausnummer, Adresssuche, Browser-Übernahme und Validierung

View File

@@ -17,6 +17,7 @@ Papa-Kind-Treff is a PHP-based platform for fathers. Core areas are local events
## Account / Member Area ## Account / Member Area
- Profile menu currently contains `Profile`, `Children`, `Events`, `Places & Events`, `Community`, `Settings`, `Logout`. - Profile menu currently contains `Profile`, `Children`, `Events`, `Places & Events`, `Community`, `Settings`, `Logout`.
- `SiteOwner` additionally gets the area `Profile Levels`.
- Children can be created, edited, and deleted. - Children can be created, edited, and deleted.
- If a birth date is set, age is calculated automatically and updated later when needed. - If a birth date is set, age is calculated automatically and updated later when needed.
- Account email is encrypted application-side and addressed through a separate HMAC lookup hash. - Account email is encrypted application-side and addressed through a separate HMAC lookup hash.
@@ -43,6 +44,9 @@ Papa-Kind-Treff is a PHP-based platform for fathers. Core areas are local events
- When `event from database` is selected, the event date must fall inside that source event's valid date window. - When `event from database` is selected, the event date must fall inside that source event's valid date window.
- Categories in the member area are handled via a search input with existing suggestions and automatic creation when needed. - Categories in the member area are handled via a search input with existing suggestions and automatic creation when needed.
- Own events support an optional description, a clear `with children` yes/no field, optional capacity, an optional image, and either a database-backed location or a custom address. - Own events support an optional description, a clear `with children` yes/no field, optional capacity, an optional image, and either a database-backed location or a custom address.
- `Profile Levels` is an owner-only area for community levels and the overview of system levels.
- Community levels are now editable through `system_settings.community_levels_json` and currently carry the rights `highlight helpful replies` and `apply for forum admin`.
- System levels remain separate as the roles `Forum Admin`, `Site Admin`, and `SiteOwner`.
## Technical Frame ## Technical Frame
- custom front controller in `public/index.php` - custom front controller in `public/index.php`

View File

@@ -20,6 +20,7 @@ Updated: 2026-08-10
- database-backed event location sources plus persisted event location origin: `partials/landing/account/dashboard.php`, `src/App/AccountPages.php`, `Internal/db/schema.sql` - database-backed event location sources plus persisted event location origin: `partials/landing/account/dashboard.php`, `src/App/AccountPages.php`, `Internal/db/schema.sql`
- consent and legal texts: `public/page/datenschutz.php` - consent and legal texts: `public/page/datenschutz.php`
- system settings: `src/App/SystemSettings.php` - system settings: `src/App/SystemSettings.php`
- owner profile-level management: `partials/landing/account/dashboard.php`, `src/App/AccountPages.php`, `src/App/Community.php`, `src/App/CommunityAccess.php`, `src/App/SystemSettings.php`
- new listing/place base: `src/App/ListingCatalog.php` - new listing/place base: `src/App/ListingCatalog.php`
- calendar export and subscribable feed: `src/App/CalendarSync.php` plus `public/page/calendar/export.php` and `public/page/calendar/feed.php` - calendar export and subscribable feed: `src/App/CalendarSync.php` plus `public/page/calendar/export.php` and `public/page/calendar/feed.php`
- separated `Events` and `Places & Events` UI in member area: `partials/landing/account/dashboard.php` - separated `Events` and `Places & Events` UI in member area: `partials/landing/account/dashboard.php`

View File

@@ -17,6 +17,7 @@ Papa-Kind-Treff is a PHP-based platform for fathers focused on local events, app
- location-based ordering for the newest events - location-based ordering for the newest events
- member area with left-side section navigation - member area with left-side section navigation
- profile menu with `Profile`, `Children`, `Events`, `Places & Events`, `Community`, `Settings` - profile menu with `Profile`, `Children`, `Events`, `Places & Events`, `Community`, `Settings`
- `SiteOwner` additionally gets the member-area item `Profile Levels`
- account emails and sensitive profile fields are stored encrypted application-side - account emails and sensitive profile fields are stored encrypted application-side
- children can be created, edited, and deleted - children can be created, edited, and deleted
- profile address supports encrypted street/house number, address search, browser import, and validation - profile address supports encrypted street/house number, address search, browser import, and validation

View File

@@ -37,6 +37,7 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf:
- `Termine` und `Treffen` ergänzend in SEO- und Erklärungstexten - `Termine` und `Treffen` ergänzend in SEO- und Erklärungstexten
- Hauptnavigation aktuell: `Home`, `Event Suche`, `Community` - Hauptnavigation aktuell: `Home`, `Event Suche`, `Community`
- Profil-Menü aktuell: `Profil`, `Kinder`, `Events`, `Orte & Veranstaltungen`, `Community`, `Einstellungen`, `Abmelden` - Profil-Menü aktuell: `Profil`, `Kinder`, `Events`, `Orte & Veranstaltungen`, `Community`, `Einstellungen`, `Abmelden`
- Für `SiteOwner` gibt es zusätzlich den Mitgliederbereichspunkt `Profil-Levels`
- neue interne Grundstruktur: `listing_places`, `listings`, `listing_occurrences`, `listing_prices`, `listing_benefits` - 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 - Seiten-Admins haben zusätzlich eine System-Sektion für globale Wartungs- und Diensteschalter
- Im Mitgliederbereich ist `Events` jetzt wieder auf echte eigene Events und Event-Teilnahmen beschränkt - Im Mitgliederbereich ist `Events` jetzt wieder auf echte eigene Events und Event-Teilnahmen beschränkt

View File

@@ -142,6 +142,9 @@ $sectionLinks = [
'community' => 'Community', 'community' => 'Community',
'settings' => 'Einstellungen', 'settings' => 'Einstellungen',
]; ];
if (!empty($canManageProfileLevels)) {
$sectionLinks['profile-levels'] = 'Profil-Levels';
}
$showEventTypeChoice = !$editing && (($section ?? 'events') === 'places') && $requestedCreateListingKind === null; $showEventTypeChoice = !$editing && (($section ?? 'events') === 'places') && $requestedCreateListingKind === null;
$modalSectionTarget = ($editingListing || ($section ?? '') === 'places') ? 'places' : 'events'; $modalSectionTarget = ($editingListing || ($section ?? '') === 'places') ? 'places' : 'events';
if (!empty($canManageSystemSettings)) { if (!empty($canManageSystemSettings)) {
@@ -321,6 +324,141 @@ if (!empty($canManageSystemSettings)) {
</section> </section>
<?php endif; ?> <?php endif; ?>
<?php if ($section === 'profile-levels' && !empty($canManageProfileLevels)): ?>
<?php
$systemRoleAssignmentsByRole = [];
foreach (($systemRoleAssignments ?? []) as $assignment) {
$roleKey = (string)($assignment['role'] ?? '');
if ($roleKey === '') {
continue;
}
$systemRoleAssignmentsByRole[$roleKey][] = $assignment;
}
?>
<section class="account-panel">
<div class="account-panel__head">
<h2>Profil-Levels</h2>
</div>
<div class="account-panel__body">
<div class="card">
<strong>Community-Levels</strong>
<div class="stack gap-12" style="margin-top:14px;">
<?php foreach (($communityLevelDefinitions ?? []) as $levelDefinition): ?>
<details class="card" style="padding:14px;">
<summary style="cursor:pointer; font-weight:700;">
<?= htmlspecialchars((string)($levelDefinition['label'] ?? ''), ENT_QUOTES) ?> · ab <?= number_format((float)($levelDefinition['min'] ?? 0), 1, ',', '.') ?> Punkten
</summary>
<form method="post" class="stack gap-12" style="margin-top:14px;">
<input type="hidden" name="action" value="community_level_save">
<input type="hidden" name="level_id" value="<?= htmlspecialchars((string)($levelDefinition['id'] ?? ''), ENT_QUOTES) ?>">
<div class="form-grid">
<div class="stack gap-6">
<label class="label" for="communityLevelLabel<?= htmlspecialchars((string)($levelDefinition['id'] ?? ''), ENT_QUOTES) ?>">Name</label>
<input id="communityLevelLabel<?= htmlspecialchars((string)($levelDefinition['id'] ?? ''), ENT_QUOTES) ?>" name="label" class="input" value="<?= htmlspecialchars((string)($levelDefinition['label'] ?? ''), ENT_QUOTES) ?>" required>
</div>
<div class="stack gap-6">
<label class="label" for="communityLevelMin<?= htmlspecialchars((string)($levelDefinition['id'] ?? ''), ENT_QUOTES) ?>">Mindestpunkte</label>
<input id="communityLevelMin<?= htmlspecialchars((string)($levelDefinition['id'] ?? ''), ENT_QUOTES) ?>" name="min_points" class="input" type="number" min="0" step="0.1" value="<?= htmlspecialchars((string)($levelDefinition['min'] ?? '0'), ENT_QUOTES) ?>" required>
</div>
</div>
<div class="stack gap-6">
<label class="label" for="communityLevelIcon<?= htmlspecialchars((string)($levelDefinition['id'] ?? ''), ENT_QUOTES) ?>">Icon (optional)</label>
<input id="communityLevelIcon<?= htmlspecialchars((string)($levelDefinition['id'] ?? ''), ENT_QUOTES) ?>" name="icon" class="input" value="<?= htmlspecialchars((string)($levelDefinition['icon'] ?? ''), ENT_QUOTES) ?>">
</div>
<div class="stack gap-8">
<span class="label">Rechte</span>
<div class="chips" style="flex-wrap:wrap;">
<?php foreach (($communityLevelRightDefinitions ?? []) as $rightKey => $rightDefinition): ?>
<label class="chip" style="cursor:pointer;">
<input type="hidden" name="rights[<?= htmlspecialchars((string)$rightKey, ENT_QUOTES) ?>]" value="0">
<input type="checkbox" name="rights[<?= htmlspecialchars((string)$rightKey, ENT_QUOTES) ?>]" value="1" <?= !empty($levelDefinition['rights'][$rightKey]) ? 'checked' : '' ?> style="margin-right:6px;">
<?= htmlspecialchars((string)($rightDefinition['label'] ?? $rightKey), ENT_QUOTES) ?>
</label>
<?php endforeach; ?>
</div>
</div>
<div class="flex gap-12" style="flex-wrap:wrap;">
<button class="btn" type="submit">Speichern</button>
</div>
</form>
<form method="post" onsubmit="return confirm('Community-Level wirklich löschen?');">
<input type="hidden" name="action" value="community_level_delete">
<input type="hidden" name="level_id" value="<?= htmlspecialchars((string)($levelDefinition['id'] ?? ''), ENT_QUOTES) ?>">
<button class="btn ghost" type="submit">Löschen</button>
</form>
</details>
<?php endforeach; ?>
</div>
</div>
<div class="card" style="margin-top:18px;">
<strong>Neues Community-Level</strong>
<form method="post" class="stack gap-12" style="margin-top:14px;">
<input type="hidden" name="action" value="community_level_save">
<input type="hidden" name="level_id" value="">
<div class="form-grid">
<div class="stack gap-6">
<label class="label" for="communityLevelLabelNew">Name</label>
<input id="communityLevelLabelNew" name="label" class="input" required>
</div>
<div class="stack gap-6">
<label class="label" for="communityLevelMinNew">Mindestpunkte</label>
<input id="communityLevelMinNew" name="min_points" class="input" type="number" min="0" step="0.1" value="0" required>
</div>
</div>
<div class="stack gap-6">
<label class="label" for="communityLevelIconNew">Icon (optional)</label>
<input id="communityLevelIconNew" name="icon" class="input">
</div>
<div class="stack gap-8">
<span class="label">Rechte</span>
<div class="chips" style="flex-wrap:wrap;">
<?php foreach (($communityLevelRightDefinitions ?? []) as $rightKey => $rightDefinition): ?>
<label class="chip" style="cursor:pointer;">
<input type="hidden" name="rights[<?= htmlspecialchars((string)$rightKey, ENT_QUOTES) ?>]" value="0">
<input type="checkbox" name="rights[<?= htmlspecialchars((string)$rightKey, ENT_QUOTES) ?>]" value="1" style="margin-right:6px;">
<?= htmlspecialchars((string)($rightDefinition['label'] ?? $rightKey), ENT_QUOTES) ?>
</label>
<?php endforeach; ?>
</div>
</div>
<div class="flex gap-12">
<button class="btn" type="submit">Community-Level anlegen</button>
</div>
</form>
</div>
<div class="card" style="margin-top:18px;">
<strong>System-Level</strong>
<div class="stack gap-12" style="margin-top:14px;">
<?php foreach (($systemLevelDefinitions ?? []) as $systemLevel): ?>
<div class="card" style="padding:14px;">
<strong><?= htmlspecialchars((string)($systemLevel['label'] ?? ''), ENT_QUOTES) ?></strong>
<?php if (!empty($systemRoleAssignmentsByRole[(string)($systemLevel['key'] ?? '')])): ?>
<div class="muted small" style="margin-top:8px;">
Aktuell:
<?php
$assignmentLabels = [];
foreach ($systemRoleAssignmentsByRole[(string)$systemLevel['key']] as $assignment) {
$assignmentLabels[] = (string)($assignment['display_name'] ?? 'Mitglied');
}
echo htmlspecialchars(implode(', ', $assignmentLabels), ENT_QUOTES);
?>
</div>
<?php endif; ?>
<ul class="dash-list" style="margin-top:12px;">
<?php foreach (($systemLevel['rights'] ?? []) as $systemRight): ?>
<li><?= htmlspecialchars((string)$systemRight, ENT_QUOTES) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
</section>
<?php endif; ?>
<?php if ($section === 'events'): ?> <?php if ($section === 'events'): ?>
<section class="account-panel" id="events"> <section class="account-panel" id="events">
<div class="account-panel__head"> <div class="account-panel__head">

View File

@@ -78,7 +78,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $community && $access) {
} }
$points = $community->computePoints((int)$userId); $points = $community->computePoints((int)$userId);
if (!$access->canHighlightHelpful($points) && !$access->canModerateForum((int)$userId)) { if (!$access->canHighlightHelpful($points) && !$access->canModerateForum((int)$userId)) {
throw new \RuntimeException('Dafür ist mindestens der Rang Mentor-Vater erforderlich.'); throw new \RuntimeException('Dein Community-Level darf hilfreiche Antworten aktuell noch nicht hervorheben.');
} }
$postId = (int)($_POST['post_id'] ?? 0); $postId = (int)($_POST['post_id'] ?? 0);
$highlight = ((string)($_POST['highlight'] ?? '1')) === '1'; $highlight = ((string)($_POST['highlight'] ?? '1')) === '1';

View File

@@ -5,6 +5,7 @@ $displayName = 'Profil';
$profileInitial = 'P'; $profileInitial = 'P';
$showAdminLink = false; $showAdminLink = false;
$showSystemLink = false; $showSystemLink = false;
$showProfileLevelsLink = false;
if ($isLoggedIn) { if ($isLoggedIn) {
try { try {
@@ -27,6 +28,7 @@ if ($isLoggedIn) {
$communityAccess = new \App\CommunityAccess($pdo, $communityConfig); $communityAccess = new \App\CommunityAccess($pdo, $communityConfig);
$showAdminLink = $communityAccess->canModerateForum((int)$_SESSION['user_id']); $showAdminLink = $communityAccess->canModerateForum((int)$_SESSION['user_id']);
$showSystemLink = $communityAccess->canManageApplications((int)$_SESSION['user_id']); $showSystemLink = $communityAccess->canManageApplications((int)$_SESSION['user_id']);
$showProfileLevelsLink = $communityAccess->canManageRoles((int)$_SESSION['user_id']);
} }
} }
} catch (\Throwable) { } catch (\Throwable) {
@@ -34,6 +36,7 @@ if ($isLoggedIn) {
$profileInitial = 'P'; $profileInitial = 'P';
$showAdminLink = false; $showAdminLink = false;
$showSystemLink = false; $showSystemLink = false;
$showProfileLevelsLink = false;
} }
} }
?> ?>
@@ -65,6 +68,9 @@ if ($isLoggedIn) {
<a href="/dashboard?section=places" role="menuitem">Orte &amp; Veranstaltungen</a> <a href="/dashboard?section=places" role="menuitem">Orte &amp; Veranstaltungen</a>
<a href="/dashboard?section=community" role="menuitem">Community</a> <a href="/dashboard?section=community" role="menuitem">Community</a>
<a href="/dashboard?section=settings" role="menuitem">Einstellungen</a> <a href="/dashboard?section=settings" role="menuitem">Einstellungen</a>
<?php if ($showProfileLevelsLink): ?>
<a href="/dashboard?section=profile-levels" role="menuitem">Profil-Levels</a>
<?php endif; ?>
<?php if ($showSystemLink): ?> <?php if ($showSystemLink): ?>
<a href="/dashboard?section=system" role="menuitem">System</a> <a href="/dashboard?section=system" role="menuitem">System</a>
<?php endif; ?> <?php endif; ?>
@@ -92,6 +98,9 @@ if ($isLoggedIn) {
<a class="btn ghost" href="/dashboard?section=places">Orte &amp; Veranstaltungen</a> <a class="btn ghost" href="/dashboard?section=places">Orte &amp; Veranstaltungen</a>
<a class="btn ghost" href="/dashboard?section=community">Community</a> <a class="btn ghost" href="/dashboard?section=community">Community</a>
<a class="btn ghost" href="/dashboard?section=settings">Einstellungen</a> <a class="btn ghost" href="/dashboard?section=settings">Einstellungen</a>
<?php if ($showProfileLevelsLink): ?>
<a class="btn ghost" href="/dashboard?section=profile-levels">Profil-Levels</a>
<?php endif; ?>
<?php if ($showSystemLink): ?> <?php if ($showSystemLink): ?>
<a class="btn ghost" href="/dashboard?section=system">System</a> <a class="btn ghost" href="/dashboard?section=system">System</a>
<?php endif; ?> <?php endif; ?>

View File

@@ -179,10 +179,14 @@ final class AccountPages
$calendarSync = $pdo ? new CalendarSync($app) : null; $calendarSync = $pdo ? new CalendarSync($app) : null;
$section = (string)($_GET['section'] ?? 'profile'); $section = (string)($_GET['section'] ?? 'profile');
$canManageSystemSettings = $communityAccess ? $communityAccess->canManageApplications($userId) : false; $canManageSystemSettings = $communityAccess ? $communityAccess->canManageApplications($userId) : false;
$canManageProfileLevels = $communityAccess ? $communityAccess->canManageRoles($userId) : false;
$allowedSections = ['profile', 'children', 'events', 'places', 'community', 'settings']; $allowedSections = ['profile', 'children', 'events', 'places', 'community', 'settings'];
if ($canManageSystemSettings) { if ($canManageSystemSettings) {
$allowedSections[] = 'system'; $allowedSections[] = 'system';
} }
if ($canManageProfileLevels) {
$allowedSections[] = 'profile-levels';
}
if ($systemSettings) { if ($systemSettings) {
$systemSettings->ensureSchema(); $systemSettings->ensureSchema();
@@ -641,6 +645,68 @@ final class AccountPages
} }
$communityAccess->submitApplication($userId, (string)($_POST['motivation'] ?? '')); $communityAccess->submitApplication($userId, (string)($_POST['motivation'] ?? ''));
$info = 'Deine Bewerbung wurde eingereicht.'; $info = 'Deine Bewerbung wurde eingereicht.';
} elseif ($action === 'community_level_save') {
if (!$canManageProfileLevels || !$community) {
throw new \RuntimeException('Keine Berechtigung für Profil-Levels.');
}
$levelId = trim((string)($_POST['level_id'] ?? ''));
$label = trim((string)($_POST['label'] ?? ''));
$minValueRaw = trim((string)($_POST['min_points'] ?? '0'));
$icon = trim((string)($_POST['icon'] ?? ''));
if ($label === '') {
throw new \RuntimeException('Bitte gib einen Namen für das Community-Level an.');
}
if ($minValueRaw === '' || !is_numeric($minValueRaw)) {
throw new \RuntimeException('Bitte gib eine gültige Mindestpunktzahl an.');
}
$rights = [];
foreach (array_keys(Community::membershipRightDefinitions()) as $rightKey) {
$rights[$rightKey] = ((string)($_POST['rights'][$rightKey] ?? '0')) === '1';
}
$levels = $community->listMembershipLevels();
$updated = false;
foreach ($levels as &$level) {
if ((string)($level['id'] ?? '') !== $levelId) {
continue;
}
$level['label'] = $label;
$level['min'] = (float)$minValueRaw;
$level['icon'] = $icon;
$level['rights'] = $rights;
$updated = true;
break;
}
unset($level);
if (!$updated) {
$levels[] = [
'id' => $levelId !== '' ? $levelId : self::slugifyValue($label . '-' . $minValueRaw . '-' . bin2hex(random_bytes(3))),
'label' => $label,
'min' => (float)$minValueRaw,
'icon' => $icon,
'rights' => $rights,
];
}
$community->saveMembershipLevels($levels, $userId);
$info = $updated ? 'Community-Level gespeichert.' : 'Community-Level angelegt.';
} elseif ($action === 'community_level_delete') {
if (!$canManageProfileLevels || !$community) {
throw new \RuntimeException('Keine Berechtigung für Profil-Levels.');
}
$levelId = trim((string)($_POST['level_id'] ?? ''));
$levels = array_values(array_filter(
$community->listMembershipLevels(),
static fn(array $level): bool => (string)($level['id'] ?? '') !== $levelId
));
if ($levels === []) {
throw new \RuntimeException('Mindestens ein Community-Level muss erhalten bleiben.');
}
$community->saveMembershipLevels($levels, $userId);
$info = 'Community-Level gelöscht.';
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
$error = $e->getMessage(); $error = $e->getMessage();
@@ -843,7 +909,10 @@ final class AccountPages
$communityPoints = $community ? $community->computePoints($userId) : 0.0; $communityPoints = $community ? $community->computePoints($userId) : 0.0;
$communityLevel = $community ? $community->membershipLevel($communityPoints) : ['label' => '', 'icon' => '']; $communityLevel = $community ? $community->membershipLevel($communityPoints) : ['label' => '', 'icon' => ''];
$communityLevelDefinitions = $community ? $community->listMembershipLevels() : [];
$communityLevelRightDefinitions = Community::membershipRightDefinitions();
$communityRoles = $communityAccess ? $communityAccess->getUserRoles($userId) : []; $communityRoles = $communityAccess ? $communityAccess->getUserRoles($userId) : [];
$systemRoleAssignments = $communityAccess && $canManageProfileLevels ? $communityAccess->listRoleAssignments() : [];
$communityApplication = $communityAccess ? $communityAccess->getLatestApplication($userId) : null; $communityApplication = $communityAccess ? $communityAccess->getLatestApplication($userId) : null;
$communityCanApply = $communityAccess ? $communityAccess->canApplyForForumAdmin($userId, $communityPoints) : false; $communityCanApply = $communityAccess ? $communityAccess->canApplyForForumAdmin($userId, $communityPoints) : false;
$communityRestrictions = $communityAccess ? $communityAccess->getRestrictionState($userId) : [ $communityRestrictions = $communityAccess ? $communityAccess->getRestrictionState($userId) : [
@@ -851,6 +920,35 @@ final class AccountPages
'reply_blocked' => false, 'reply_blocked' => false,
'reason' => null, '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() : []; $systemSettingsValues = $systemSettings ? $systemSettings->getAll() : [];
$listingCatalogStatus = $listingCatalog ? $listingCatalog->status() : ['complete' => false, 'missing' => [], 'tables' => []]; $listingCatalogStatus = $listingCatalog ? $listingCatalog->status() : ['complete' => false, 'missing' => [], 'tables' => []];
if (!in_array($section, $allowedSections, true)) { if (!in_array($section, $allowedSections, true)) {
@@ -889,11 +987,16 @@ final class AccountPages
'eventLocationOptions', 'eventLocationOptions',
'communityPoints', 'communityPoints',
'communityLevel', 'communityLevel',
'communityLevelDefinitions',
'communityLevelRightDefinitions',
'communityRoles', 'communityRoles',
'systemRoleAssignments',
'communityApplication', 'communityApplication',
'communityCanApply', 'communityCanApply',
'communityRestrictions', 'communityRestrictions',
'systemLevelDefinitions',
'canManageSystemSettings', 'canManageSystemSettings',
'canManageProfileLevels',
'systemSettingsValues', 'systemSettingsValues',
'listingCatalogStatus', 'listingCatalogStatus',
'avatarBuilder', 'avatarBuilder',

View File

@@ -8,6 +8,7 @@ use App\Avatar\AvatarManager;
final class Community final class Community
{ {
private ?array $forumStructure = null; private ?array $forumStructure = null;
private ?array $membershipLevelCache = null;
private array $tableCache = []; private array $tableCache = [];
private array $columnCache = []; private array $columnCache = [];
@@ -418,18 +419,122 @@ final class Community
public function membershipLevel(float $points): array public function membershipLevel(float $points): array
{ {
$levels = $this->config['levels'] ?? []; $level = $this->membershipLevelMeta($points);
usort($levels, fn($a,$b) => ($b['min'] ?? 0) <=> ($a['min'] ?? 0)); return [
foreach ($levels as $lvl) { 'label' => (string)($level['label'] ?? 'Neuer Vater'),
if ($points >= (float)($lvl['min'] ?? 0)) { 'icon' => (string)($level['icon'] ?? ''),
return [ ];
'label' => $lvl['label'] ?? 'New Daddy', }
'icon' => $lvl['icon'] ?? '',
]; public function membershipLevelMeta(float $points): array
{
$levels = $this->listMembershipLevels();
$sortedLevels = $levels;
usort($sortedLevels, fn(array $a, array $b): int => ((float)($b['min'] ?? 0.0)) <=> ((float)($a['min'] ?? 0.0)));
foreach ($sortedLevels as $level) {
if ($points >= (float)($level['min'] ?? 0.0)) {
return $level;
} }
} }
$fallback = $levels ? $levels[count($levels)-1] : ['label' => 'New Daddy','icon' => ''];
return ['label' => $fallback['label'], 'icon' => $fallback['icon'] ?? '']; return $sortedLevels !== [] ? $sortedLevels[array_key_last($sortedLevels)] : $this->normalizeMembershipLevel(['label' => 'Neuer Vater', 'min' => 0], 0);
}
public function listMembershipLevels(): array
{
if ($this->membershipLevelCache !== null) {
return $this->membershipLevelCache;
}
$levels = $this->config['levels'] ?? [];
try {
$settings = new SystemSettings($this->pdo);
$rawLevels = trim((string)$settings->get('community_levels_json', ''));
if ($rawLevels !== '') {
$decodedLevels = json_decode($rawLevels, true);
if (is_array($decodedLevels)) {
$levels = $decodedLevels;
}
}
} catch (\Throwable) {
}
$normalizedLevels = [];
foreach ($levels as $index => $level) {
if (!is_array($level)) {
continue;
}
$normalizedLevels[] = $this->normalizeMembershipLevel($level, (int)$index);
}
if ($normalizedLevels === []) {
$normalizedLevels[] = $this->normalizeMembershipLevel(['label' => 'Neuer Vater', 'min' => 0], 0);
}
usort($normalizedLevels, function (array $left, array $right): int {
$minCompare = ((float)($left['min'] ?? 0.0)) <=> ((float)($right['min'] ?? 0.0));
if ($minCompare !== 0) {
return $minCompare;
}
return strcmp((string)($left['label'] ?? ''), (string)($right['label'] ?? ''));
});
return $this->membershipLevelCache = array_values($normalizedLevels);
}
public function saveMembershipLevels(array $levels, ?int $updatedBy = null): void
{
$normalizedLevels = [];
foreach ($levels as $index => $level) {
if (!is_array($level)) {
continue;
}
$normalizedLevels[] = $this->normalizeMembershipLevel($level, (int)$index);
}
if ($normalizedLevels === []) {
throw new \RuntimeException('Mindestens ein Community-Level muss vorhanden sein.');
}
$seenIds = [];
foreach ($normalizedLevels as $level) {
$levelId = (string)($level['id'] ?? '');
if ($levelId === '' || isset($seenIds[$levelId])) {
throw new \RuntimeException('Jedes Community-Level braucht eine eindeutige Kennung.');
}
$seenIds[$levelId] = true;
}
usort($normalizedLevels, function (array $left, array $right): int {
$minCompare = ((float)($left['min'] ?? 0.0)) <=> ((float)($right['min'] ?? 0.0));
if ($minCompare !== 0) {
return $minCompare;
}
return strcmp((string)($left['label'] ?? ''), (string)($right['label'] ?? ''));
});
$settings = new SystemSettings($this->pdo);
$settings->ensureSchema();
$settings->set(
'community_levels_json',
json_encode($normalizedLevels, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) ?: '[]',
$updatedBy
);
$this->membershipLevelCache = array_values($normalizedLevels);
}
public static function membershipRightDefinitions(): array
{
return [
'can_highlight_helpful' => [
'label' => 'Hilfreiche Antworten hervorheben',
],
'can_apply_for_forum_admin' => [
'label' => 'Bewerbung als Forum-Admin',
],
];
} }
private function forumStructure(): array private function forumStructure(): array
@@ -554,4 +659,63 @@ final class Community
return implode(', ', $columns); return implode(', ', $columns);
} }
private function normalizeMembershipLevel(array $level, int $index): array
{
$label = trim((string)($level['label'] ?? ''));
if ($label === '') {
$label = 'Level ' . ($index + 1);
}
$minValue = is_numeric($level['min'] ?? null) ? (float)$level['min'] : 0.0;
if ($minValue < 0) {
$minValue = 0.0;
}
$levelId = trim((string)($level['id'] ?? ''));
if ($levelId === '') {
$levelId = $this->slugifyLevelIdentifier($label . '-' . (string)$minValue . '-' . (string)$index);
}
$rightsInput = is_array($level['rights'] ?? null) ? $level['rights'] : [];
$rights = [];
foreach (array_keys(self::membershipRightDefinitions()) as $rightKey) {
if (array_key_exists($rightKey, $rightsInput)) {
$rights[$rightKey] = $this->toBool($rightsInput[$rightKey]);
continue;
}
$rights[$rightKey] = match ($rightKey) {
'can_highlight_helpful' => $minValue >= 500.0,
'can_apply_for_forum_admin' => $minValue >= 750.0,
default => false,
};
}
return [
'id' => $levelId,
'min' => $minValue,
'label' => $label,
'icon' => trim((string)($level['icon'] ?? '')),
'rights' => $rights,
];
}
private function slugifyLevelIdentifier(string $value): string
{
$value = mb_strtolower(trim($value));
$value = strtr($value, ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss']);
$value = preg_replace('/[^a-z0-9]+/u', '-', $value) ?: '';
$value = trim($value, '-');
return $value !== '' ? $value : 'community-level';
}
private function toBool(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
return in_array((string)$value, ['1', 'true', 'yes', 'on'], true);
}
} }

View File

@@ -119,7 +119,10 @@ final class CommunityAccess
public function canApplyForForumAdmin(int $userId, float $points): bool public function canApplyForForumAdmin(int $userId, float $points): bool
{ {
if ($userId <= 0 || $points < 750.0 || !$this->hasTable('community_admin_applications')) { if ($userId <= 0 || !$this->hasTable('community_admin_applications')) {
return false;
}
if (!$this->resolveLevelRight($points, 'can_apply_for_forum_admin')) {
return false; return false;
} }
@@ -450,7 +453,7 @@ final class CommunityAccess
public function canHighlightHelpful(float $points): bool public function canHighlightHelpful(float $points): bool
{ {
return $points >= 500.0; return $this->resolveLevelRight($points, 'can_highlight_helpful');
} }
public function supportsApplications(): bool public function supportsApplications(): bool
@@ -492,4 +495,11 @@ final class CommunityAccess
return $this->tableCache[$table] = false; return $this->tableCache[$table] = false;
} }
} }
private function resolveLevelRight(float $points, string $rightKey): bool
{
$community = new Community($this->pdo, $this->communityConfig);
$level = $community->membershipLevelMeta($points);
return !empty($level['rights'][$rightKey]);
}
} }

View File

@@ -13,6 +13,7 @@ final class SystemSettings
'site_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.', 'site_maintenance_message' => 'Papa-Kind-Treff ist gerade kurz in Wartung. Bitte versuche es in Kürze erneut.',
'place_data_provider' => 'osm', 'place_data_provider' => 'osm',
'community_levels_json' => '',
]; ];
public function __construct(private \PDO $pdo) public function __construct(private \PDO $pdo)