diff --git a/partials/landing/community/thread.php b/partials/landing/community/thread.php
index fecb14b..30de1de 100644
--- a/partials/landing/community/thread.php
+++ b/partials/landing/community/thread.php
@@ -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';
diff --git a/partials/structure/nav.php b/partials/structure/nav.php
index 143da49..3f9fa94 100755
--- a/partials/structure/nav.php
+++ b/partials/structure/nav.php
@@ -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) {
Orte & Veranstaltungen
Community
Einstellungen
+
+
Profil-Levels
+
System
@@ -92,6 +98,9 @@ if ($isLoggedIn) {
Orte & Veranstaltungen
Community
Einstellungen
+
+
Profil-Levels
+
System
diff --git a/src/App/AccountPages.php b/src/App/AccountPages.php
index b6bdf2c..e199bb8 100755
--- a/src/App/AccountPages.php
+++ b/src/App/AccountPages.php
@@ -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',
diff --git a/src/App/Community.php b/src/App/Community.php
index a20ff9c..962e072 100755
--- a/src/App/Community.php
+++ b/src/App/Community.php
@@ -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);
+ }
}
diff --git a/src/App/CommunityAccess.php b/src/App/CommunityAccess.php
index 81fd1d4..a319d25 100644
--- a/src/App/CommunityAccess.php
+++ b/src/App/CommunityAccess.php
@@ -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]);
+ }
}
diff --git a/src/App/SystemSettings.php b/src/App/SystemSettings.php
index a1d59e6..c2e585f 100644
--- a/src/App/SystemSettings.php
+++ b/src/App/SystemSettings.php
@@ -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)