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
- 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.
- 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.
@@ -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.
- 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.
- `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
- 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`
- Consent- und rechtliche Texte: `public/page/datenschutz.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`
- 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`

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
- Mitgliederbereich mit linker Bereichsnavigation
- 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
- Kinder können angelegt, bearbeitet und gelöscht werden
- 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
- 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.
- 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.
@@ -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.
- 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.
- `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
- 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`
- consent and legal texts: `public/page/datenschutz.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`
- 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`

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
- member area with left-side section navigation
- 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
- children can be created, edited, and deleted
- 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
- Hauptnavigation aktuell: `Home`, `Event Suche`, `Community`
- 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`
- 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

View File

@@ -142,6 +142,9 @@ $sectionLinks = [
'community' => 'Community',
'settings' => 'Einstellungen',
];
if (!empty($canManageProfileLevels)) {
$sectionLinks['profile-levels'] = 'Profil-Levels';
}
$showEventTypeChoice = !$editing && (($section ?? 'events') === 'places') && $requestedCreateListingKind === null;
$modalSectionTarget = ($editingListing || ($section ?? '') === 'places') ? 'places' : 'events';
if (!empty($canManageSystemSettings)) {
@@ -321,6 +324,141 @@ if (!empty($canManageSystemSettings)) {
</section>
<?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'): ?>
<section class="account-panel" id="events">
<div class="account-panel__head">

View File

@@ -78,7 +78,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $community && $access) {
}
$points = $community->computePoints((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);
$highlight = ((string)($_POST['highlight'] ?? '1')) === '1';

View File

@@ -5,6 +5,7 @@ $displayName = 'Profil';
$profileInitial = 'P';
$showAdminLink = false;
$showSystemLink = false;
$showProfileLevelsLink = false;
if ($isLoggedIn) {
try {
@@ -27,6 +28,7 @@ if ($isLoggedIn) {
$communityAccess = new \App\CommunityAccess($pdo, $communityConfig);
$showAdminLink = $communityAccess->canModerateForum((int)$_SESSION['user_id']);
$showSystemLink = $communityAccess->canManageApplications((int)$_SESSION['user_id']);
$showProfileLevelsLink = $communityAccess->canManageRoles((int)$_SESSION['user_id']);
}
}
} catch (\Throwable) {
@@ -34,6 +36,7 @@ if ($isLoggedIn) {
$profileInitial = 'P';
$showAdminLink = 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=community" role="menuitem">Community</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): ?>
<a href="/dashboard?section=system" role="menuitem">System</a>
<?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=community">Community</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): ?>
<a class="btn ghost" href="/dashboard?section=system">System</a>
<?php endif; ?>

View File

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

View File

@@ -8,6 +8,7 @@ use App\Avatar\AvatarManager;
final class Community
{
private ?array $forumStructure = null;
private ?array $membershipLevelCache = null;
private array $tableCache = [];
private array $columnCache = [];
@@ -418,18 +419,122 @@ final class Community
public function membershipLevel(float $points): array
{
$levels = $this->config['levels'] ?? [];
usort($levels, fn($a,$b) => ($b['min'] ?? 0) <=> ($a['min'] ?? 0));
foreach ($levels as $lvl) {
if ($points >= (float)($lvl['min'] ?? 0)) {
return [
'label' => $lvl['label'] ?? 'New Daddy',
'icon' => $lvl['icon'] ?? '',
];
$level = $this->membershipLevelMeta($points);
return [
'label' => (string)($level['label'] ?? 'Neuer Vater'),
'icon' => (string)($level['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
@@ -554,4 +659,63 @@ final class Community
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
{
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;
}
@@ -450,7 +453,7 @@ final class CommunityAccess
public function canHighlightHelpful(float $points): bool
{
return $points >= 500.0;
return $this->resolveLevelRight($points, 'can_highlight_helpful');
}
public function supportsApplications(): bool
@@ -492,4 +495,11 @@ final class CommunityAccess
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_message' => 'Papa-Kind-Treff ist gerade kurz in Wartung. Bitte versuche es in Kürze erneut.',
'place_data_provider' => 'osm',
'community_levels_json' => '',
];
public function __construct(private \PDO $pdo)