+
Themen
= number_format($stats['threads']) ?>
@@ -62,19 +103,59 @@ $threads = $community ? ($search !== '' ? $community->searchThreads($search, 50)
Beiträge
= number_format($stats['posts']) ?>
-
- Mitglieder
- = number_format($stats['members']) ?>
-
-
+
+
+
+ = htmlspecialchars((string)$category['title'], ENT_QUOTES) ?>
+
+
+
+
+
+
= htmlspecialchars((string)($board['icon'] ?? '💬'), ENT_QUOTES) ?>
+
+
+
= htmlspecialchars((string)$board['description'], ENT_QUOTES) ?>
+
+ Themen: = (int)($board['thread_count'] ?? 0) ?>
+ Beiträge: = (int)($board['post_count'] ?? 0) ?>
+
+
+
+
+
+
+
+
+
+
+
+
-
Forum
-
Hier findest du die neuesten Themen, Antworten und laufenden Gespräche.
+
= htmlspecialchars($selectedBoard['title'] ?? 'Themenübersicht', ENT_QUOTES) ?>
+
+ = htmlspecialchars($selectedBoard['description'] ?? 'Hier findest du die neuesten Themen, Antworten und laufenden Gespräche.', ENT_QUOTES) ?>
+
+
+
+
+
+
+
+
+
Offene Bewerbungen
+
Bewerbungen ab Rang Community-Vater prüfen.
+
+
+
+
+
+
+
= htmlspecialchars((string)($application['display_name'] ?: $application['email']), ENT_QUOTES) ?>
+
= nl2br(htmlspecialchars((string)$application['motivation'], ENT_QUOTES)) ?>
+
+
+
+
+
+
Keine offenen Bewerbungen.
+
+
+
+
+
+ supportsReports()): ?>
+
+
+
+
Offene Meldungen
+
Gemeldete Inhalte prüfen und moderieren.
+
+
+
+
+
+
+
= htmlspecialchars((string)$report['target_type'], ENT_QUOTES) ?> #= (int)$report['target_id'] ?>
+
= nl2br(htmlspecialchars((string)$report['reason'], ENT_QUOTES)) ?>
+
Gemeldet von = htmlspecialchars((string)($report['reporter_name'] ?: 'Mitglied'), ENT_QUOTES) ?> am = htmlspecialchars((string)$report['created_at'], ENT_QUOTES) ?>
+
+
+
+
+
+
Keine offenen Meldungen.
+
+
+
+
+
+ hasModerationTables()): ?>
+
+
+
+
Rollenübersicht
+
Vergebene Community-Rollen im Blick behalten.
+
+
+
+
+
+
+
= htmlspecialchars((string)($assignment['display_name'] ?: $assignment['email']), ENT_QUOTES) ?>
+
Rolle: = htmlspecialchars((string)$assignment['role'], ENT_QUOTES) ?> · seit = htmlspecialchars((string)$assignment['assigned_at'], ENT_QUOTES) ?>
+
+
+
+
+
+
Keine Rollen vergeben.
+
+
+
+
+
+
@@ -132,22 +313,36 @@ $threads = $community ? ($search !== '' ? $community->searchThreads($search, 50)
-
Neue Frage
+ Neues Thema
diff --git a/partials/landing/community/thread.php b/partials/landing/community/thread.php
old mode 100755
new mode 100644
index 16d73c7..07c7228
--- a/partials/landing/community/thread.php
+++ b/partials/landing/community/thread.php
@@ -5,22 +5,88 @@ $app = app();
$pdo = $app->pdo();
$userId = $_SESSION['user_id'] ?? null;
$error = '';
+$info = '';
$threadId = (int)($_GET['id'] ?? 0);
+$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;
-if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'reply') {
- if (!$userId) {
- $error = 'Bitte einloggen, um zu antworten.';
- } elseif ($community) {
- $body = trim((string)($_POST['body'] ?? ''));
- if ($body === '') {
- $error = 'Antwort darf nicht leer sein.';
- } else {
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && $community && $access) {
+ $action = (string)($_POST['action'] ?? '');
+ try {
+ if ($action === 'reply') {
+ if (!$userId) {
+ throw new \RuntimeException('Bitte einloggen, um zu antworten.');
+ }
+ if (!$access->canReply((int)$userId)) {
+ $state = $access->getRestrictionState((int)$userId);
+ throw new \RuntimeException($state['reason'] ?: 'Du darfst aktuell nicht antworten.');
+ }
+ $body = trim((string)($_POST['body'] ?? ''));
+ if ($body === '') {
+ throw new \RuntimeException('Antwort darf nicht leer sein.');
+ }
$community->createPost((int)$userId, $threadId, $body);
redirect('/community_thread?id=' . $threadId);
+ } elseif ($action === 'post_vote') {
+ if (!$userId) {
+ throw new \RuntimeException('Bitte einloggen, um Beiträge zu bewerten.');
+ }
+ $access->votePost((int)$userId, (int)($_POST['post_id'] ?? 0), (int)($_POST['value'] ?? 0));
+ redirect('/community_thread?id=' . $threadId);
+ } elseif ($action === 'report_content') {
+ if (!$userId) {
+ throw new \RuntimeException('Bitte einloggen, um Beiträge zu melden.');
+ }
+ $access->submitReport((int)$userId, (string)($_POST['target_type'] ?? ''), (int)($_POST['target_id'] ?? 0), (string)($_POST['reason'] ?? ''));
+ $info = 'Deine Meldung wurde eingereicht.';
+ } elseif ($action === 'post_update') {
+ if (!$userId || !$access->canModerateForum((int)$userId)) {
+ throw new \RuntimeException('Keine Berechtigung.');
+ }
+ $community->updatePost((int)($_POST['post_id'] ?? 0), (string)($_POST['body'] ?? ''));
+ redirect('/community_thread?id=' . $threadId);
+ } elseif ($action === 'post_delete') {
+ if (!$userId || !$access->canModerateForum((int)$userId)) {
+ throw new \RuntimeException('Keine Berechtigung.');
+ }
+ $community->deletePost((int)($_POST['post_id'] ?? 0));
+ redirect('/community_thread?id=' . $threadId);
+ } elseif ($action === 'thread_update') {
+ if (!$userId || !$access->canModerateForum((int)$userId)) {
+ throw new \RuntimeException('Keine Berechtigung.');
+ }
+ $community->updateThread($threadId, (string)($_POST['title'] ?? ''), (string)($_POST['body'] ?? ''));
+ redirect('/community_thread?id=' . $threadId);
+ } elseif ($action === 'thread_delete') {
+ if (!$userId || !$access->canModerateForum((int)$userId)) {
+ throw new \RuntimeException('Keine Berechtigung.');
+ }
+ $community->deleteThread($threadId);
+ redirect('/community');
+ } elseif ($action === 'highlight_post') {
+ if (!$userId) {
+ throw new \RuntimeException('Bitte einloggen.');
+ }
+ $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.');
+ }
+ $postId = (int)($_POST['post_id'] ?? 0);
+ $highlight = ((string)($_POST['highlight'] ?? '1')) === '1';
+ $community->toggleHelpfulHighlight($postId, $highlight ? (int)$userId : null);
+ redirect('/community_thread?id=' . $threadId);
+ } elseif ($action === 'restriction_set') {
+ $access->setRestriction((int)$userId, (int)($_POST['target_user_id'] ?? 0), (string)($_POST['restriction_type'] ?? ''), (string)($_POST['reason'] ?? ''));
+ $info = 'Community-Sperre wurde gesetzt.';
+ } elseif ($action === 'restriction_clear') {
+ $access->clearRestriction((int)$userId, (int)($_POST['target_user_id'] ?? 0), (string)($_POST['restriction_type'] ?? ''));
+ $info = 'Community-Sperre wurde aufgehoben.';
}
+ } catch (\Throwable $e) {
+ $error = $e->getMessage();
}
}
@@ -36,6 +102,12 @@ if (!$thread) {
$threadAuthor = (string)($thread['display_name'] ?: 'Mitglied');
$threadPoints = ($community && $pdo) ? $community->computePoints((int)$thread['user_id']) : 0.0;
$threadLevel = $community ? $community->membershipLevel($threadPoints) : ['label' => '', 'icon' => ''];
+$userRoles = $access && $userId ? $access->getUserRoles((int)$userId) : [];
+$isForumAdmin = $access && $userId ? $access->canModerateForum((int)$userId) : false;
+$userRestrictions = $access && $userId ? $access->getRestrictionState((int)$userId) : ['reply_blocked' => false, 'reason' => null];
+$postIds = array_map(static fn(array $post): int => (int)$post['id'], $posts);
+$feedbackSummary = $access ? $access->getPostFeedbackSummary($postIds) : [];
+$userVotes = ($access && $userId) ? $access->getUserPostVotes((int)$userId, $postIds) : [];
?>
-
= htmlspecialchars($thread['title'], ENT_QUOTES) ?>
-
Erstellt von = htmlspecialchars($threadAuthor, ENT_QUOTES) ?> am = htmlspecialchars($thread['created_at'], ENT_QUOTES) ?>
+
= htmlspecialchars((string)$thread['title'], ENT_QUOTES) ?>
+
Erstellt von = htmlspecialchars($threadAuthor, ENT_QUOTES) ?> am = htmlspecialchars((string)$thread['created_at'], ENT_QUOTES) ?>
+
+
Bereich: = htmlspecialchars((string)$thread['board_title'], ENT_QUOTES) ?>
+
= count($posts) ?>
@@ -58,21 +137,46 @@ $threadLevel = $community ? $community->membershipLevel($threadPoints) : ['label
+
+ = htmlspecialchars($error, ENT_QUOTES) ?>
+
+
+ = htmlspecialchars($info, ENT_QUOTES) ?>
+
+
- = htmlspecialchars($thread['created_at'], ENT_QUOTES) ?>
+ = htmlspecialchars((string)$thread['created_at'], ENT_QUOTES) ?>
#1
- = nl2br(htmlspecialchars($thread['body'], ENT_QUOTES)) ?>
+ = nl2br(htmlspecialchars((string)$thread['body'], ENT_QUOTES)) ?>
+ supportsReports()): ?>
+
+
@@ -82,21 +186,93 @@ $threadLevel = $community ? $community->membershipLevel($threadPoints) : ['label
-
+ $p): ?>
0, 'unhelpful' => 0];
+ $userVote = $userVotes[(int)$p['id']] ?? 0;
+ $isHighlighted = !empty($p['highlighted_at']);
?>
-
+
- = htmlspecialchars($p['created_at'], ENT_QUOTES) ?>
+ = htmlspecialchars((string)$p['created_at'], ENT_QUOTES) ?>
+ #= $index + 2 ?>
- = nl2br(htmlspecialchars($p['body'], ENT_QUOTES)) ?>
+
+
+
+ = nl2br(htmlspecialchars((string)$p['body'], ENT_QUOTES)) ?>
+
+
+
+
+ supportsFeedback()): ?>
+
+
+
+ supportsReports()): ?>
+
+
+
+ canHighlightHelpful($community->computePoints((int)$userId))): ?>
+
+
+
+
+
Bearbeiten
+
+
@@ -106,21 +282,68 @@ $threadLevel = $community ? $community->membershipLevel($threadPoints) : ['label
-
-
= htmlspecialchars($error, ENT_QUOTES) ?>
-
-
-
+
+
Du darfst aktuell keine Antworten schreiben. = !empty($userRestrictions['reason']) ? htmlspecialchars((string)$userRestrictions['reason'], ENT_QUOTES) : '' ?>
+
+
+
+
+
+
+
+
+
Thema bearbeiten
+
+
+
+
+
+
+
+
diff --git a/public/assets/css/app.css b/public/assets/css/app.css
index 8897e5b..cb0b004 100755
--- a/public/assets/css/app.css
+++ b/public/assets/css/app.css
@@ -155,9 +155,23 @@ body {
.forum-hero__copy { margin: 0; max-width: 70ch; color: var(--color-muted); font-size: 16px; }
.forum-hero__actions { display: flex; align-items: center; gap: 12px; }
.forum-stats { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 14px; margin: 18px 0 24px; }
+.forum-stats--compact { grid-template-columns: repeat(2, minmax(0,1fr)); max-width: 540px; }
.forum-stat { padding: 18px 20px; background: #fff; border: 1px solid var(--color-border); border-radius: var(--radius-md); box-shadow: var(--shadow-card); }
.forum-stat strong { display: block; font-size: 28px; line-height: 1.1; }
.forum-stat__label { display: block; margin-bottom: 8px; color: var(--color-muted); font-size: 13px; text-transform: uppercase; letter-spacing: .4px; }
+.forum-category-list { display: grid; gap: 22px; margin-top: 18px; }
+.forum-category-card { background: #fff; border: 1px solid var(--color-border); border-radius: var(--radius-lg); box-shadow: var(--shadow-card); overflow: hidden; }
+.forum-category-card__title { padding: 16px 24px; border-bottom: 1px solid var(--color-border); background: linear-gradient(90deg, #fffdf7, #f5eee2); font-size: 28px; font-weight: 700; color: var(--color-primary); }
+.forum-board-grid { display: grid; }
+.forum-board-row { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 18px; padding: 22px 24px; border-top: 1px solid var(--color-border); align-items: start; }
+.forum-board-row:first-child { border-top: 0; }
+.forum-board-row__main { display: grid; grid-template-columns: 40px minmax(0, 1fr); gap: 14px; }
+.forum-board-row__main h3 { margin: 0 0 6px; font-size: 18px; }
+.forum-board-row__main h3 a:hover { color: var(--color-primary); }
+.forum-board-row__main p { margin: 0; color: var(--color-muted); line-height: 1.5; }
+.forum-board-row__latest { display: grid; gap: 4px; align-content: start; }
+.forum-board-row__latest strong { font-size: 16px; }
+.forum-board-row__latest a:hover { color: var(--color-primary); }
.forum-board { background: #fff; border: 1px solid var(--color-border); border-radius: var(--radius-lg); box-shadow: var(--shadow-card); overflow: hidden; }
.forum-board__head { display: flex; justify-content: space-between; gap: 20px; align-items: end; padding: 22px 24px; border-bottom: 1px solid var(--color-border); background: #fffdfa; }
.forum-board__head h2 { margin: 0 0 6px; }
@@ -191,12 +205,25 @@ body {
.forum-post__body { padding: 20px 24px; }
.forum-post__head { display: flex; justify-content: space-between; gap: 12px; color: var(--color-muted); font-size: 14px; margin-bottom: 14px; }
.forum-post__content { font-size: 17px; line-height: 1.7; color: var(--color-text); }
+.forum-post--highlighted { border-color: #d6b46f; box-shadow: 0 0 0 1px rgba(214,180,111,0.22), var(--shadow-card); }
+.forum-highlight-badge { display: inline-flex; align-items: center; padding: 4px 8px; border-radius: 999px; background: var(--color-accent-soft); color: var(--color-highlight); font-size: 12px; font-weight: 700; }
+.forum-post__toolbar { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 16px; }
+.forum-post__toolbar .btn.is-active { background: var(--color-primary); color: var(--color-primary-contrast); border-color: var(--color-primary); }
+.forum-post__admin { display: grid; gap: 8px; margin-top: 10px; }
+.forum-inline-report { margin-top: 16px; }
+.forum-admin-mini-form { margin-top: 10px; }
.forum-replies-head { display: flex; justify-content: space-between; gap: 16px; align-items: center; margin: 0 0 14px; }
.forum-replies-head h2 { margin: 0; }
.forum-replies-head span { color: var(--color-muted); }
.forum-replies { display: grid; gap: 0; }
.forum-reply-form { display: grid; gap: 14px; margin-top: 18px; padding: 22px 24px; background: #fff; border: 1px solid var(--color-border); border-radius: var(--radius-lg); box-shadow: var(--shadow-card); }
.forum-login-note { margin-top: 18px; color: var(--color-muted); }
+.forum-admin-grid { display: grid; gap: 22px; margin-top: 24px; }
+.forum-admin-list { display: grid; }
+.forum-admin-item { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 18px; padding: 20px 24px; border-top: 1px solid var(--color-border); }
+.forum-admin-item:first-child { border-top: 0; }
+.forum-admin-item p { margin: 8px 0 0; }
+.forum-admin-item__actions { display: grid; gap: 10px; }
.flex { display:flex; }
.between { justify-content: space-between; }
@@ -205,6 +232,8 @@ body {
.stack { display:flex; flex-direction: column; }
@media (max-width: 980px){
+ .forum-board-row,
+ .forum-admin-item,
.forum-list__header,
.forum-row { grid-template-columns: minmax(0, 1fr); }
.forum-row__count,
@@ -220,10 +249,15 @@ body {
.forum-hero,
.forum-board__head,
.forum-thread-head { grid-template-columns: 1fr; display: grid; }
- .forum-stats { grid-template-columns: 1fr; }
+ .forum-stats,
+ .forum-stats--compact { grid-template-columns: 1fr; }
.forum-search__row { flex-direction: column; }
+ .forum-category-card__title,
.forum-row,
- .forum-list__header { padding-left: 16px; padding-right: 16px; }
+ .forum-list__header,
+ .forum-board__head,
+ .forum-board-row,
+ .forum-admin-item { padding-left: 16px; padding-right: 16px; }
.forum-post__body,
.forum-reply-form { padding: 18px 16px; }
}
diff --git a/schema.sql b/schema.sql
index a507f41..806f688 100755
--- a/schema.sql
+++ b/schema.sql
@@ -99,15 +99,37 @@ CREATE TABLE event_participants (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Community / Forum
+CREATE TABLE forum_categories (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ slug VARCHAR(120) NOT NULL UNIQUE,
+ title VARCHAR(180) NOT NULL,
+ sort_order SMALLINT UNSIGNED NOT NULL DEFAULT 0
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE forum_boards (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ category_id BIGINT UNSIGNED NOT NULL,
+ slug VARCHAR(120) NOT NULL UNIQUE,
+ title VARCHAR(180) NOT NULL,
+ description TEXT NULL,
+ icon VARCHAR(32) NULL,
+ sort_order SMALLINT UNSIGNED NOT NULL DEFAULT 0,
+ CONSTRAINT fk_fb_category FOREIGN KEY (category_id) REFERENCES forum_categories(id) ON DELETE CASCADE,
+ INDEX idx_fb_category (category_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
CREATE TABLE forum_threads (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ board_id BIGINT UNSIGNED NULL,
user_id BIGINT UNSIGNED NOT NULL,
title VARCHAR(200) NOT NULL,
body TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ CONSTRAINT fk_ft_board FOREIGN KEY (board_id) REFERENCES forum_boards(id) ON DELETE SET NULL,
CONSTRAINT fk_ft_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
- INDEX idx_ft_created (created_at)
+ INDEX idx_ft_created (created_at),
+ INDEX idx_ft_board (board_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE forum_posts (
@@ -115,13 +137,83 @@ CREATE TABLE forum_posts (
thread_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
body TEXT NOT NULL,
+ highlighted_by BIGINT UNSIGNED NULL,
+ highlighted_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_fp_thread FOREIGN KEY (thread_id) REFERENCES forum_threads(id) ON DELETE CASCADE,
CONSTRAINT fk_fp_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+ CONSTRAINT fk_fp_highlighted_by FOREIGN KEY (highlighted_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_fp_thread (thread_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+CREATE TABLE forum_post_feedback (
+ post_id BIGINT UNSIGNED NOT NULL,
+ user_id BIGINT UNSIGNED NOT NULL,
+ value TINYINT NOT NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (post_id, user_id),
+ CONSTRAINT fk_fpf_post FOREIGN KEY (post_id) REFERENCES forum_posts(id) ON DELETE CASCADE,
+ CONSTRAINT fk_fpf_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+ INDEX idx_fpf_value (value)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE forum_reports (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ reporter_user_id BIGINT UNSIGNED NOT NULL,
+ target_type ENUM('thread','post') NOT NULL,
+ target_id BIGINT UNSIGNED NOT NULL,
+ reason TEXT NOT NULL,
+ status ENUM('open','resolved','dismissed') NOT NULL DEFAULT 'open',
+ moderator_user_id BIGINT UNSIGNED NULL,
+ moderator_note TEXT NULL,
+ resolved_at DATETIME NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT fk_fr_reporter FOREIGN KEY (reporter_user_id) REFERENCES users(id) ON DELETE CASCADE,
+ CONSTRAINT fk_fr_moderator FOREIGN KEY (moderator_user_id) REFERENCES users(id) ON DELETE SET NULL,
+ INDEX idx_fr_status (status),
+ INDEX idx_fr_target (target_type, target_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE user_roles (
+ user_id BIGINT UNSIGNED NOT NULL,
+ role ENUM('forum_admin','site_admin','owner') NOT NULL,
+ assigned_by BIGINT UNSIGNED NULL,
+ assigned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (user_id, role),
+ CONSTRAINT fk_ur_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+ CONSTRAINT fk_ur_assigned_by FOREIGN KEY (assigned_by) REFERENCES users(id) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE community_admin_applications (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ user_id BIGINT UNSIGNED NOT NULL,
+ motivation TEXT NOT NULL,
+ status ENUM('open','approved','rejected') NOT NULL DEFAULT 'open',
+ decision_reason TEXT NULL,
+ decided_by BIGINT UNSIGNED NULL,
+ decided_at DATETIME NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT fk_caa_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+ CONSTRAINT fk_caa_decided_by FOREIGN KEY (decided_by) REFERENCES users(id) ON DELETE SET NULL,
+ INDEX idx_caa_status (status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE community_user_restrictions (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ user_id BIGINT UNSIGNED NOT NULL,
+ restriction_type ENUM('thread_create_blocked','reply_blocked') NOT NULL,
+ reason TEXT NULL,
+ active TINYINT(1) NOT NULL DEFAULT 1,
+ created_by BIGINT UNSIGNED NOT NULL,
+ expires_at DATETIME NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT fk_cur_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+ CONSTRAINT fk_cur_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE,
+ INDEX idx_cur_user_active (user_id, active)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
-- Punkte-Tracking (persistent, config-Änderungen wirken nur auf neue Einträge)
CREATE TABLE user_points (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
diff --git a/src/App/AccountPages.php b/src/App/AccountPages.php
index 24c173a..589c588 100755
--- a/src/App/AccountPages.php
+++ b/src/App/AccountPages.php
@@ -153,6 +153,10 @@ final class AccountPages
$info = '';
$crypto = null;
try { $crypto = new Crypto($app->config()); } catch (\Throwable) {}
+ $communityCfg = dirname(__DIR__, 2) . '/config/community.php';
+ $communityConfig = file_exists($communityCfg) ? require $communityCfg : [];
+ $community = $pdo ? new Community($pdo, $communityConfig) : null;
+ $communityAccess = $pdo ? new CommunityAccess($pdo, $communityConfig) : null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
@@ -282,6 +286,16 @@ final class AccountPages
'id' => $eventId,
]);
$info = 'Event wurde abgesagt.';
+ } elseif ($action === 'community_admin_apply') {
+ if (!$community || !$communityAccess) {
+ throw new \RuntimeException('Community-Funktionen sind aktuell nicht verfügbar.');
+ }
+ $points = $community->computePoints($userId);
+ if (!$communityAccess->canApplyForForumAdmin($userId, $points)) {
+ throw new \RuntimeException('Du kannst dich aktuell nicht als Forum-Admin bewerben.');
+ }
+ $communityAccess->submitApplication($userId, (string)($_POST['motivation'] ?? ''));
+ $info = 'Deine Bewerbung wurde eingereicht.';
}
} catch (\Throwable $e) {
$error = $e->getMessage();
@@ -353,7 +367,33 @@ final class AccountPages
$editEvent = $stmt?->fetch(\PDO::FETCH_ASSOC) ?: null;
}
- return compact('flash','info','error','profile','children','eventsUpcoming','eventsPast','editEvent');
+ $communityPoints = $community ? $community->computePoints($userId) : 0.0;
+ $communityLevel = $community ? $community->membershipLevel($communityPoints) : ['label' => '', 'icon' => ''];
+ $communityRoles = $communityAccess ? $communityAccess->getUserRoles($userId) : [];
+ $communityApplication = $communityAccess ? $communityAccess->getLatestApplication($userId) : null;
+ $communityCanApply = $communityAccess ? $communityAccess->canApplyForForumAdmin($userId, $communityPoints) : false;
+ $communityRestrictions = $communityAccess ? $communityAccess->getRestrictionState($userId) : [
+ 'thread_create_blocked' => false,
+ 'reply_blocked' => false,
+ 'reason' => null,
+ ];
+
+ return compact(
+ 'flash',
+ 'info',
+ 'error',
+ 'profile',
+ 'children',
+ 'eventsUpcoming',
+ 'eventsPast',
+ 'editEvent',
+ 'communityPoints',
+ 'communityLevel',
+ 'communityRoles',
+ 'communityApplication',
+ 'communityCanApply',
+ 'communityRestrictions'
+ );
}
private static function geocodeAddress(?string $street, ?string $zip, ?string $city, ?string $region): array
diff --git a/src/App/Community.php b/src/App/Community.php
index 1f69299..14fa2e5 100755
--- a/src/App/Community.php
+++ b/src/App/Community.php
@@ -5,6 +5,10 @@ namespace App;
final class Community
{
+ private ?array $forumStructure = null;
+ private array $tableCache = [];
+ private array $columnCache = [];
+
public function __construct(private \PDO $pdo, private array $config)
{
}
@@ -24,11 +28,41 @@ final class Community
public function createThread(int $userId, string $title, string $body): void
{
+ $this->createThreadInBoard($userId, null, $title, $body);
+ }
+
+ public function createThreadInBoard(int $userId, ?string $boardSlug, string $title, string $body): void
+ {
+ $title = trim($title);
+ $body = trim($body);
+ if ($title === '' || $body === '') {
+ throw new \RuntimeException('Titel und Text sind erforderlich.');
+ }
+
+ if ($this->hasColumn('forum_threads', 'board_id') && $this->hasTable('forum_boards')) {
+ $boardId = null;
+ if ($boardSlug !== null && $boardSlug !== '') {
+ $board = $this->getBoardBySlug($boardSlug);
+ if (!$board) {
+ throw new \RuntimeException('Forum-Bereich nicht gefunden.');
+ }
+ $boardId = (int)$board['id'];
+ }
+
+ $stmt = $this->pdo->prepare('INSERT INTO forum_threads (board_id, user_id, title, body) VALUES (:boardId, :uid, :title, :body)');
+ $stmt->bindValue(':boardId', $boardId, $boardId === null ? \PDO::PARAM_NULL : \PDO::PARAM_INT);
+ $stmt->bindValue(':uid', $userId, \PDO::PARAM_INT);
+ $stmt->bindValue(':title', $title, \PDO::PARAM_STR);
+ $stmt->bindValue(':body', $body, \PDO::PARAM_STR);
+ $stmt->execute();
+ return;
+ }
+
$stmt = $this->pdo->prepare('INSERT INTO forum_threads (user_id, title, body) VALUES (:uid, :title, :body)');
$stmt->execute([
':uid' => $userId,
- ':title' => trim($title),
- ':body' => trim($body),
+ ':title' => $title,
+ ':body' => $body,
]);
}
@@ -42,7 +76,57 @@ final class Community
]);
}
- public function searchThreads(string $query, int $limit = 50): array
+ public function updateThread(int $threadId, string $title, string $body): void
+ {
+ $stmt = $this->pdo->prepare('UPDATE forum_threads SET title = :title, body = :body, updated_at = NOW() WHERE id = :id');
+ $stmt->execute([
+ 'id' => $threadId,
+ 'title' => trim($title),
+ 'body' => trim($body),
+ ]);
+ }
+
+ public function deleteThread(int $threadId): void
+ {
+ $stmt = $this->pdo->prepare('DELETE FROM forum_threads WHERE id = :id');
+ $stmt->execute(['id' => $threadId]);
+ }
+
+ public function updatePost(int $postId, string $body): void
+ {
+ $stmt = $this->pdo->prepare('UPDATE forum_posts SET body = :body, updated_at = NOW() WHERE id = :id');
+ $stmt->execute([
+ 'id' => $postId,
+ 'body' => trim($body),
+ ]);
+ }
+
+ public function deletePost(int $postId): void
+ {
+ $stmt = $this->pdo->prepare('DELETE FROM forum_posts WHERE id = :id');
+ $stmt->execute(['id' => $postId]);
+ }
+
+ public function toggleHelpfulHighlight(int $postId, ?int $userId): void
+ {
+ if (!$this->hasColumn('forum_posts', 'highlighted_at')) {
+ return;
+ }
+
+ if ($userId === null) {
+ $stmt = $this->pdo->prepare('UPDATE forum_posts SET highlighted_by = NULL, highlighted_at = NULL WHERE id = :id');
+ $stmt->execute(['id' => $postId]);
+ return;
+ }
+
+ $stmt = $this->pdo->prepare('UPDATE forum_posts SET highlighted_by = :uid, highlighted_at = NOW() WHERE id = :id');
+ $stmt->execute([
+ 'uid' => $userId,
+ 'id' => $postId,
+ ]);
+ }
+
+ public function searchThreads(string $query, int $limit = 50, ?string $boardSlug = null): array
{
$conditions = [];
$params = [];
@@ -56,11 +140,24 @@ final class Community
$params[$ph2] = '%' . $tok . '%';
$i++;
}
+ $boardJoin = '';
+ $boardSelect = "NULL AS board_slug, NULL AS board_title, NULL AS category_slug, NULL AS category_title";
+ $boardWhere = '';
+ if ($this->hasColumn('forum_threads', 'board_id') && $this->hasTable('forum_boards') && $this->hasTable('forum_categories')) {
+ $boardJoin = ' LEFT JOIN forum_boards fb ON fb.id = ft.board_id LEFT JOIN forum_categories fc ON fc.id = fb.category_id ';
+ $boardSelect = 'fb.slug AS board_slug, fb.title AS board_title, fc.slug AS category_slug, fc.title AS category_title';
+ if ($boardSlug !== null && $boardSlug !== '') {
+ $boardWhere = ' AND fb.slug = :boardSlug';
+ $params[':boardSlug'] = $boardSlug;
+ }
+ }
+
$where = $conditions ? ('AND ' . implode(' AND ', $conditions)) : '';
$sql = "SELECT ft.id, ft.title, ft.body, ft.created_at, ft.updated_at,
u.id as uid, u.created_at as user_created,
p.display_name,
+ $boardSelect,
(SELECT COUNT(*) FROM forum_posts fp WHERE fp.thread_id = ft.id) AS answers,
COALESCE(
(SELECT MAX(fp.created_at) FROM forum_posts fp WHERE fp.thread_id = ft.id),
@@ -84,7 +181,8 @@ final class Community
FROM forum_threads ft
JOIN users u ON u.id = ft.user_id
LEFT JOIN user_profiles p ON p.user_id = u.id
- WHERE 1=1 $where
+ $boardJoin
+ WHERE 1=1 $where $boardWhere
ORDER BY ft.created_at DESC
LIMIT :lim";
$stmt = $this->pdo->prepare($sql);
@@ -96,14 +194,20 @@ final class Community
return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
}
- public function listThreads(int $limit = 50): array
+ public function listThreads(int $limit = 50, ?string $boardSlug = null): array
{
- return $this->searchThreads('', $limit);
+ return $this->searchThreads('', $limit, $boardSlug);
}
public function getThread(int $id): ?array
{
- $stmt = $this->pdo->prepare('SELECT ft.*, p.display_name FROM forum_threads ft LEFT JOIN user_profiles p ON p.user_id = ft.user_id WHERE ft.id = :id');
+ $select = 'ft.*, p.display_name';
+ $join = '';
+ if ($this->hasColumn('forum_threads', 'board_id') && $this->hasTable('forum_boards') && $this->hasTable('forum_categories')) {
+ $select .= ', fb.slug AS board_slug, fb.title AS board_title, fc.slug AS category_slug, fc.title AS category_title';
+ $join = ' LEFT JOIN forum_boards fb ON fb.id = ft.board_id LEFT JOIN forum_categories fc ON fc.id = fb.category_id ';
+ }
+ $stmt = $this->pdo->prepare("SELECT $select FROM forum_threads ft LEFT JOIN user_profiles p ON p.user_id = ft.user_id $join WHERE ft.id = :id");
$stmt->execute([':id' => $id]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
return $row ?: null;
@@ -111,11 +215,97 @@ final class Community
public function listPosts(int $threadId): array
{
- $stmt = $this->pdo->prepare('SELECT fp.*, p.display_name FROM forum_posts fp LEFT JOIN user_profiles p ON p.user_id = fp.user_id WHERE fp.thread_id = :id ORDER BY fp.created_at ASC');
+ $select = 'fp.*, p.display_name';
+ if ($this->hasColumn('forum_posts', 'highlighted_at')) {
+ $select .= ', hp.display_name AS highlighted_by_name';
+ } else {
+ $select .= ', NULL AS highlighted_by_name';
+ }
+ $join = ' LEFT JOIN user_profiles p ON p.user_id = fp.user_id ';
+ if ($this->hasColumn('forum_posts', 'highlighted_at')) {
+ $join .= ' LEFT JOIN user_profiles hp ON hp.user_id = fp.highlighted_by ';
+ }
+ $stmt = $this->pdo->prepare("SELECT $select FROM forum_posts fp $join WHERE fp.thread_id = :id ORDER BY fp.created_at ASC");
$stmt->execute([':id' => $threadId]);
return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
}
+ public function getPost(int $postId): ?array
+ {
+ $stmt = $this->pdo->prepare('SELECT * FROM forum_posts WHERE id = :id LIMIT 1');
+ $stmt->execute(['id' => $postId]);
+ $row = $stmt->fetch(\PDO::FETCH_ASSOC);
+ return $row ?: null;
+ }
+
+ public function listForumCategories(): array
+ {
+ $structure = $this->forumStructure();
+ $boards = [];
+ foreach ($structure as $category) {
+ foreach ($category['boards'] as $board) {
+ $boards[$board['slug']] = $board;
+ }
+ }
+
+ $threadsByBoard = [];
+ foreach ($this->listThreads(200) as $thread) {
+ $slug = (string)($thread['board_slug'] ?? '');
+ if ($slug === '' || !isset($boards[$slug])) {
+ continue;
+ }
+ $threadsByBoard[$slug][] = $thread;
+ }
+
+ foreach ($structure as &$category) {
+ foreach ($category['boards'] as &$board) {
+ $items = $threadsByBoard[$board['slug']] ?? [];
+ $board['thread_count'] = count($items);
+ $replyCount = 0;
+ foreach ($items as $item) {
+ $replyCount += (int)($item['answers'] ?? 0);
+ }
+ $board['post_count'] = $replyCount;
+ $board['latest_thread'] = $items[0] ?? null;
+ }
+ unset($board);
+ }
+ unset($category);
+
+ return $structure;
+ }
+
+ public function getBoardBySlug(string $slug): ?array
+ {
+ foreach ($this->forumStructure() as $category) {
+ foreach ($category['boards'] as $board) {
+ if ($board['slug'] === $slug) {
+ if ($this->hasTable('forum_boards')) {
+ $stmt = $this->pdo->prepare('
+ SELECT fb.id, fb.slug, fb.title, fb.description, fb.icon, fc.slug AS category_slug, fc.title AS category_title
+ FROM forum_boards fb
+ JOIN forum_categories fc ON fc.id = fb.category_id
+ WHERE fb.slug = :slug
+ LIMIT 1
+ ');
+ $stmt->execute(['slug' => $slug]);
+ $dbBoard = $stmt->fetch(\PDO::FETCH_ASSOC);
+ if ($dbBoard) {
+ return array_merge($board, $dbBoard);
+ }
+ }
+
+ return array_merge($board, [
+ 'category_slug' => $category['slug'],
+ 'category_title' => $category['title'],
+ ]);
+ }
+ }
+ }
+
+ return null;
+ }
+
public function computePoints(int $userId): float
{
// Primär: aggregierte Werte aus user_points_totals, Fallback: Summe aus user_points
@@ -223,4 +413,92 @@ final class Community
$fallback = $levels ? $levels[count($levels)-1] : ['label' => 'New Daddy','icon' => ''];
return ['label' => $fallback['label'], 'icon' => $fallback['icon'] ?? ''];
}
+
+ private function forumStructure(): array
+ {
+ if ($this->forumStructure !== null) {
+ return $this->forumStructure;
+ }
+
+ $file = dirname(__DIR__, 2) . '/config/community_forums.php';
+ $this->forumStructure = file_exists($file) ? (array) require $file : [];
+ $this->syncForumStructure();
+
+ return $this->forumStructure;
+ }
+
+ private function syncForumStructure(): void
+ {
+ if (!$this->hasTable('forum_categories') || !$this->hasTable('forum_boards')) {
+ return;
+ }
+
+ foreach ($this->forumStructure as $categoryIndex => $category) {
+ $stmt = $this->pdo->prepare('INSERT INTO forum_categories (slug, title, sort_order) VALUES (:slug, :title, :sortOrder) ON DUPLICATE KEY UPDATE title = VALUES(title), sort_order = VALUES(sort_order)');
+ $stmt->execute([
+ 'slug' => $category['slug'],
+ 'title' => $category['title'],
+ 'sortOrder' => $categoryIndex,
+ ]);
+
+ $catIdStmt = $this->pdo->prepare('SELECT id FROM forum_categories WHERE slug = :slug LIMIT 1');
+ $catIdStmt->execute(['slug' => $category['slug']]);
+ $categoryId = (int)$catIdStmt->fetchColumn();
+
+ foreach ($category['boards'] as $boardIndex => $board) {
+ $boardStmt = $this->pdo->prepare('
+ INSERT INTO forum_boards (category_id, slug, title, description, icon, sort_order)
+ VALUES (:categoryId, :slug, :title, :description, :icon, :sortOrder)
+ ON DUPLICATE KEY UPDATE
+ category_id = VALUES(category_id),
+ title = VALUES(title),
+ description = VALUES(description),
+ icon = VALUES(icon),
+ sort_order = VALUES(sort_order)
+ ');
+ $boardStmt->execute([
+ 'categoryId' => $categoryId,
+ 'slug' => $board['slug'],
+ 'title' => $board['title'],
+ 'description' => $board['description'],
+ 'icon' => $board['icon'] ?? null,
+ 'sortOrder' => $boardIndex,
+ ]);
+ }
+ }
+ }
+
+ 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 hasColumn(string $table, string $column): bool
+ {
+ $key = $table . '.' . $column;
+ if (array_key_exists($key, $this->columnCache)) {
+ return $this->columnCache[$key];
+ }
+
+ try {
+ $stmt = $this->pdo->prepare('SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = :table AND column_name = :column LIMIT 1');
+ $stmt->execute([
+ 'table' => $table,
+ 'column' => $column,
+ ]);
+ return $this->columnCache[$key] = (bool)$stmt->fetchColumn();
+ } catch (\Throwable) {
+ return $this->columnCache[$key] = false;
+ }
+ }
}
diff --git a/src/App/CommunityAccess.php b/src/App/CommunityAccess.php
new file mode 100644
index 0000000..700c8bd
--- /dev/null
+++ b/src/App/CommunityAccess.php
@@ -0,0 +1,484 @@
+hasTable('user_roles')) {
+ $stmt = $this->pdo->prepare('SELECT role FROM user_roles WHERE user_id = :uid');
+ $stmt->execute(['uid' => $userId]);
+ $roles = array_map('strval', $stmt->fetchAll(\PDO::FETCH_COLUMN) ?: []);
+ }
+
+ if ($userId === 1 && !in_array('owner', $roles, true)) {
+ $roles[] = 'owner';
+ }
+
+ if (in_array('owner', $roles, true)) {
+ foreach (['site_admin', 'forum_admin'] as $derived) {
+ if (!in_array($derived, $roles, true)) {
+ $roles[] = $derived;
+ }
+ }
+ }
+
+ if (in_array('site_admin', $roles, true) && !in_array('forum_admin', $roles, true)) {
+ $roles[] = 'forum_admin';
+ }
+
+ return array_values(array_unique($roles));
+ }
+
+ public function hasRole(int $userId, string $role): bool
+ {
+ return in_array($role, $this->getUserRoles($userId), true);
+ }
+
+ public function canModerateForum(int $userId): bool
+ {
+ return $this->hasRole($userId, 'forum_admin');
+ }
+
+ public function canManageApplications(int $userId): bool
+ {
+ return $this->hasRole($userId, 'site_admin');
+ }
+
+ public function canManageRoles(int $userId): bool
+ {
+ return $this->hasRole($userId, 'owner');
+ }
+
+ public function getRestrictionState(int $userId): array
+ {
+ $state = [
+ 'thread_create_blocked' => false,
+ 'reply_blocked' => false,
+ 'reason' => null,
+ ];
+
+ if ($userId <= 0 || !$this->hasTable('community_user_restrictions')) {
+ return $state;
+ }
+
+ $stmt = $this->pdo->prepare('
+ SELECT restriction_type, reason
+ FROM community_user_restrictions
+ WHERE user_id = :uid
+ AND active = 1
+ AND (expires_at IS NULL OR expires_at > NOW())
+ ');
+ $stmt->execute(['uid' => $userId]);
+ foreach ($stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [] as $row) {
+ if (isset($state[$row['restriction_type']])) {
+ $state[$row['restriction_type']] = true;
+ if ($state['reason'] === null && !empty($row['reason'])) {
+ $state['reason'] = (string)$row['reason'];
+ }
+ }
+ }
+
+ return $state;
+ }
+
+ public function canCreateThread(int $userId): bool
+ {
+ return !$this->getRestrictionState($userId)['thread_create_blocked'];
+ }
+
+ public function canReply(int $userId): bool
+ {
+ return !$this->getRestrictionState($userId)['reply_blocked'];
+ }
+
+ public function canApplyForForumAdmin(int $userId, float $points): bool
+ {
+ if ($userId <= 0 || $points < 750.0 || !$this->hasTable('community_admin_applications')) {
+ return false;
+ }
+
+ foreach (['forum_admin', 'site_admin', 'owner'] as $role) {
+ if ($this->hasRole($userId, $role)) {
+ return false;
+ }
+ }
+
+ $latest = $this->getLatestApplication($userId);
+ return !$latest || $latest['status'] !== 'open';
+ }
+
+ public function getLatestApplication(int $userId): ?array
+ {
+ if ($userId <= 0 || !$this->hasTable('community_admin_applications')) {
+ return null;
+ }
+
+ $stmt = $this->pdo->prepare('
+ SELECT caa.*, p.display_name AS decided_by_name
+ FROM community_admin_applications caa
+ LEFT JOIN user_profiles p ON p.user_id = caa.decided_by
+ WHERE caa.user_id = :uid
+ ORDER BY caa.created_at DESC
+ LIMIT 1
+ ');
+ $stmt->execute(['uid' => $userId]);
+ $row = $stmt->fetch(\PDO::FETCH_ASSOC);
+ return $row ?: null;
+ }
+
+ public function submitApplication(int $userId, string $motivation): void
+ {
+ if (!$this->hasTable('community_admin_applications')) {
+ throw new \RuntimeException('Admin-Bewerbungen stehen erst nach dem Datenbank-Update zur Verfügung.');
+ }
+
+ $motivation = trim($motivation);
+ if ($motivation === '') {
+ throw new \RuntimeException('Bitte begründe deine Bewerbung.');
+ }
+
+ $stmt = $this->pdo->prepare('INSERT INTO community_admin_applications (user_id, motivation, status) VALUES (:uid, :motivation, :status)');
+ $stmt->execute([
+ 'uid' => $userId,
+ 'motivation' => $motivation,
+ 'status' => 'open',
+ ]);
+ }
+
+ public function listApplications(string $status = 'open'): array
+ {
+ if (!$this->hasTable('community_admin_applications')) {
+ return [];
+ }
+
+ $sql = '
+ SELECT caa.*, up.display_name, u.email, dp.display_name AS decided_by_name
+ FROM community_admin_applications caa
+ JOIN users u ON u.id = caa.user_id
+ LEFT JOIN user_profiles up ON up.user_id = caa.user_id
+ LEFT JOIN user_profiles dp ON dp.user_id = caa.decided_by
+ ';
+ $params = [];
+ if ($status !== '') {
+ $sql .= ' WHERE caa.status = :status';
+ $params['status'] = $status;
+ }
+ $sql .= ' ORDER BY caa.created_at DESC';
+
+ $stmt = $this->pdo->prepare($sql);
+ $stmt->execute($params);
+ return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
+ }
+
+ public function decideApplication(int $adminUserId, int $applicationId, string $decision, ?string $reason = null): void
+ {
+ if (!$this->canManageApplications($adminUserId)) {
+ throw new \RuntimeException('Keine Berechtigung.');
+ }
+ if (!$this->hasTable('community_admin_applications')) {
+ throw new \RuntimeException('Admin-Bewerbungen stehen erst nach dem Datenbank-Update zur Verfügung.');
+ }
+
+ $decision = $decision === 'approved' ? 'approved' : 'rejected';
+ $stmt = $this->pdo->prepare('SELECT * FROM community_admin_applications WHERE id = :id LIMIT 1');
+ $stmt->execute(['id' => $applicationId]);
+ $application = $stmt->fetch(\PDO::FETCH_ASSOC);
+ if (!$application) {
+ throw new \RuntimeException('Bewerbung nicht gefunden.');
+ }
+
+ $update = $this->pdo->prepare('
+ UPDATE community_admin_applications
+ SET status = :status, decision_reason = :reason, decided_by = :decidedBy, decided_at = NOW()
+ WHERE id = :id
+ ');
+ $update->execute([
+ 'status' => $decision,
+ 'reason' => trim((string)$reason) ?: null,
+ 'decidedBy' => $adminUserId,
+ 'id' => $applicationId,
+ ]);
+
+ if ($decision === 'approved') {
+ $this->assignRole($adminUserId, (int)$application['user_id'], 'forum_admin');
+ }
+ }
+
+ public function assignRole(int $actingUserId, int $targetUserId, string $role): void
+ {
+ if (!$this->canManageRoles($actingUserId) && !$this->canManageApplications($actingUserId)) {
+ throw new \RuntimeException('Keine Berechtigung.');
+ }
+ if (!$this->hasTable('user_roles')) {
+ throw new \RuntimeException('Rollen stehen erst nach dem Datenbank-Update zur Verfügung.');
+ }
+
+ if (!in_array($role, ['forum_admin', 'site_admin', 'owner'], true)) {
+ throw new \RuntimeException('Ungültige Rolle.');
+ }
+
+ if ($role !== 'forum_admin' && !$this->canManageRoles($actingUserId)) {
+ throw new \RuntimeException('Nur der Inhaber darf diese Rolle vergeben.');
+ }
+
+ $stmt = $this->pdo->prepare('INSERT IGNORE INTO user_roles (user_id, role, assigned_by) VALUES (:uid, :role, :by)');
+ $stmt->execute([
+ 'uid' => $targetUserId,
+ 'role' => $role,
+ 'by' => $actingUserId,
+ ]);
+ }
+
+ public function revokeRole(int $actingUserId, int $targetUserId, string $role): void
+ {
+ if (!$this->canManageRoles($actingUserId)) {
+ throw new \RuntimeException('Keine Berechtigung.');
+ }
+ if (!$this->hasTable('user_roles')) {
+ throw new \RuntimeException('Rollen stehen erst nach dem Datenbank-Update zur Verfügung.');
+ }
+
+ $stmt = $this->pdo->prepare('DELETE FROM user_roles WHERE user_id = :uid AND role = :role');
+ $stmt->execute([
+ 'uid' => $targetUserId,
+ 'role' => $role,
+ ]);
+ }
+
+ public function listRoleAssignments(): array
+ {
+ if (!$this->hasTable('user_roles')) {
+ return [];
+ }
+
+ $stmt = $this->pdo->query('
+ SELECT ur.user_id, ur.role, ur.assigned_at, up.display_name, u.email
+ FROM user_roles ur
+ JOIN users u ON u.id = ur.user_id
+ LEFT JOIN user_profiles up ON up.user_id = ur.user_id
+ ORDER BY FIELD(ur.role, "owner", "site_admin", "forum_admin"), ur.assigned_at ASC
+ ');
+ return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
+ }
+
+ public function setRestriction(int $actingUserId, int $targetUserId, string $type, string $reason): void
+ {
+ if (!$this->canModerateForum($actingUserId)) {
+ throw new \RuntimeException('Keine Berechtigung.');
+ }
+ if (!$this->hasTable('community_user_restrictions')) {
+ throw new \RuntimeException('Community-Sperren stehen erst nach dem Datenbank-Update zur Verfügung.');
+ }
+ if (!in_array($type, ['thread_create_blocked', 'reply_blocked'], true)) {
+ throw new \RuntimeException('Ungültige Sperre.');
+ }
+
+ $stmt = $this->pdo->prepare('
+ INSERT INTO community_user_restrictions (user_id, restriction_type, reason, active, created_by)
+ VALUES (:uid, :type, :reason, 1, :by)
+ ');
+ $stmt->execute([
+ 'uid' => $targetUserId,
+ 'type' => $type,
+ 'reason' => trim($reason) ?: null,
+ 'by' => $actingUserId,
+ ]);
+ }
+
+ public function clearRestriction(int $actingUserId, int $targetUserId, string $type): void
+ {
+ if (!$this->canModerateForum($actingUserId)) {
+ throw new \RuntimeException('Keine Berechtigung.');
+ }
+ if (!$this->hasTable('community_user_restrictions')) {
+ throw new \RuntimeException('Community-Sperren stehen erst nach dem Datenbank-Update zur Verfügung.');
+ }
+
+ $stmt = $this->pdo->prepare('UPDATE community_user_restrictions SET active = 0 WHERE user_id = :uid AND restriction_type = :type');
+ $stmt->execute([
+ 'uid' => $targetUserId,
+ 'type' => $type,
+ ]);
+ }
+
+ public function submitReport(int $userId, string $targetType, int $targetId, string $reason): void
+ {
+ if (!$this->hasTable('forum_reports')) {
+ throw new \RuntimeException('Meldungen stehen erst nach dem Datenbank-Update zur Verfügung.');
+ }
+
+ $stmt = $this->pdo->prepare('INSERT INTO forum_reports (reporter_user_id, target_type, target_id, reason, status) VALUES (:uid, :type, :targetId, :reason, :status)');
+ $stmt->execute([
+ 'uid' => $userId,
+ 'type' => $targetType,
+ 'targetId' => $targetId,
+ 'reason' => trim($reason),
+ 'status' => 'open',
+ ]);
+ }
+
+ public function listOpenReports(): array
+ {
+ if (!$this->hasTable('forum_reports')) {
+ return [];
+ }
+
+ $stmt = $this->pdo->query('
+ SELECT fr.*, up.display_name AS reporter_name
+ FROM forum_reports fr
+ LEFT JOIN user_profiles up ON up.user_id = fr.reporter_user_id
+ WHERE fr.status = "open"
+ ORDER BY fr.created_at DESC
+ ');
+ return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
+ }
+
+ public function resolveReport(int $actingUserId, int $reportId, string $note = ''): void
+ {
+ if (!$this->canModerateForum($actingUserId)) {
+ throw new \RuntimeException('Keine Berechtigung.');
+ }
+ if (!$this->hasTable('forum_reports')) {
+ throw new \RuntimeException('Meldungen stehen erst nach dem Datenbank-Update zur Verfügung.');
+ }
+
+ $stmt = $this->pdo->prepare('
+ UPDATE forum_reports
+ SET status = "resolved", moderator_user_id = :uid, moderator_note = :note, resolved_at = NOW()
+ WHERE id = :id
+ ');
+ $stmt->execute([
+ 'uid' => $actingUserId,
+ 'note' => trim($note) ?: null,
+ 'id' => $reportId,
+ ]);
+ }
+
+ public function votePost(int $userId, int $postId, int $value): void
+ {
+ if (!$this->hasTable('forum_post_feedback')) {
+ throw new \RuntimeException('Bewertungen stehen erst nach dem Datenbank-Update zur Verfügung.');
+ }
+
+ $value = $value >= 1 ? 1 : -1;
+ $stmt = $this->pdo->prepare('
+ INSERT INTO forum_post_feedback (post_id, user_id, value)
+ VALUES (:postId, :uid, :value)
+ ON DUPLICATE KEY UPDATE value = VALUES(value), updated_at = CURRENT_TIMESTAMP
+ ');
+ $stmt->execute([
+ 'postId' => $postId,
+ 'uid' => $userId,
+ 'value' => $value,
+ ]);
+ }
+
+ public function getPostFeedbackSummary(array $postIds): array
+ {
+ if (!$postIds || !$this->hasTable('forum_post_feedback')) {
+ return [];
+ }
+
+ $placeholders = implode(',', array_fill(0, count($postIds), '?'));
+ $stmt = $this->pdo->prepare("
+ SELECT post_id,
+ SUM(CASE WHEN value = 1 THEN 1 ELSE 0 END) AS helpful_count,
+ SUM(CASE WHEN value = -1 THEN 1 ELSE 0 END) AS unhelpful_count
+ FROM forum_post_feedback
+ WHERE post_id IN ($placeholders)
+ GROUP BY post_id
+ ");
+ $stmt->execute(array_values($postIds));
+ $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
+ $out = [];
+ foreach ($rows as $row) {
+ $out[(int)$row['post_id']] = [
+ 'helpful' => (int)$row['helpful_count'],
+ 'unhelpful' => (int)$row['unhelpful_count'],
+ ];
+ }
+ return $out;
+ }
+
+ public function getUserPostVotes(int $userId, array $postIds): array
+ {
+ if ($userId <= 0 || !$postIds || !$this->hasTable('forum_post_feedback')) {
+ return [];
+ }
+
+ $placeholders = implode(',', array_fill(0, count($postIds), '?'));
+ $params = array_merge([$userId], array_values($postIds));
+ $stmt = $this->pdo->prepare("
+ SELECT post_id, value
+ FROM forum_post_feedback
+ WHERE user_id = ?
+ AND post_id IN ($placeholders)
+ ");
+ $stmt->execute($params);
+ $out = [];
+ foreach ($stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [] as $row) {
+ $out[(int)$row['post_id']] = (int)$row['value'];
+ }
+ return $out;
+ }
+
+ public function canHighlightHelpful(float $points): bool
+ {
+ return $points >= 500.0;
+ }
+
+ public function supportsApplications(): bool
+ {
+ return $this->hasTable('community_admin_applications');
+ }
+
+ public function supportsReports(): bool
+ {
+ return $this->hasTable('forum_reports');
+ }
+
+ public function supportsFeedback(): bool
+ {
+ return $this->hasTable('forum_post_feedback');
+ }
+
+ public function supportsRestrictions(): bool
+ {
+ return $this->hasTable('community_user_restrictions');
+ }
+
+ public function hasModerationTables(): bool
+ {
+ return $this->hasTable('user_roles');
+ }
+
+ 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;
+ }
+ }
+}