diff --git a/config/community_forums.php b/config/community_forums.php new file mode 100644 index 0000000..3b34e5d --- /dev/null +++ b/config/community_forums.php @@ -0,0 +1,119 @@ + 'allgemein', + 'title' => 'Allgemein', + 'boards' => [ + [ + 'slug' => 'neu-hier', + 'title' => 'Neu hier?', + 'description' => 'Vorstellen, ankommen und die ersten Fragen rund um die Community stellen.', + 'icon' => '👋', + ], + [ + 'slug' => 'regeln-und-hinweise', + 'title' => 'Regeln und Hinweise', + 'description' => 'Wichtige Informationen zum Miteinander, zur Netiquette und zu Abläufen in der Community.', + 'icon' => '📌', + ], + [ + 'slug' => 'fragen-zur-plattform', + 'title' => 'Fragen zur Plattform', + 'description' => 'Fragen, Probleme oder Ideen zur Nutzung von Papa-Kind-Treff direkt hier sammeln.', + 'icon' => '❓', + ], + ], + ], + [ + 'slug' => 'alltag-mit-kindern', + 'title' => 'Alltag mit Kindern', + 'boards' => [ + [ + 'slug' => 'kleinkindalter', + 'title' => 'Kleinkindalter', + 'description' => 'Erfahrungen, Fragen und Gedanken rund um die ersten Jahre mit Kindern.', + 'icon' => '🧸', + ], + [ + 'slug' => 'kita-schule-routinen', + 'title' => 'Kita, Schule, Routinen', + 'description' => 'Austausch zu Alltag, Betreuung, Übergängen und wiederkehrenden Herausforderungen.', + 'icon' => '🎒', + ], + [ + 'slug' => 'freizeitideen', + 'title' => 'Freizeitideen', + 'description' => 'Ideen für Ausflüge, Beschäftigung, schlechtes Wetter und spontane freie Nachmittage.', + 'icon' => '🌤️', + ], + ], + ], + [ + 'slug' => 'treffen-und-unternehmungen', + 'title' => 'Treffen & Unternehmungen', + 'boards' => [ + [ + 'slug' => 'spielplaetze-und-ausfluege', + 'title' => 'Spielplätze und Ausflüge', + 'description' => 'Empfehlungen, Erfahrungen und konkrete Tipps für gemeinsame Unternehmungen.', + 'icon' => '🛝', + ], + [ + 'slug' => 'wochenendideen', + 'title' => 'Wochenendideen', + 'description' => 'Was sich für freie Tage, spontane Treffen und kleine Abenteuer anbietet.', + 'icon' => '🗓️', + ], + [ + 'slug' => 'urlaub-mit-kindern', + 'title' => 'Urlaub mit Kindern', + 'description' => 'Aktivitäten, Kontakte und Ideen für entspannte Tage unterwegs oder im Urlaub.', + 'icon' => '🏖️', + ], + ], + ], + [ + 'slug' => 'austausch-unter-vaetern', + 'title' => 'Austausch unter Vätern', + 'boards' => [ + [ + 'slug' => 'vaterrolle', + 'title' => 'Vaterrolle', + 'description' => 'Wie sich Vatersein anfühlt, verändert und im Alltag erlebt wird.', + 'icon' => '🧑‍🧒', + ], + [ + 'slug' => 'beziehungen-und-familienleben', + 'title' => 'Beziehungen und Familienleben', + 'description' => 'Partnerschaft, Familie, Belastungen und schöne Momente im gemeinsamen Leben.', + 'icon' => '🏡', + ], + [ + 'slug' => 'unsicherheiten-fragen-erfahrungen', + 'title' => 'Unsicherheiten, Fragen, Erfahrungen', + 'description' => 'Raum für ehrliche Fragen, Zweifel und Erfahrungen ohne große Hürden.', + 'icon' => '💬', + ], + ], + ], + [ + 'slug' => 'feedback-zur-plattform', + 'title' => 'Feedback zur Plattform', + 'boards' => [ + [ + 'slug' => 'ideen-und-vorschlaege', + 'title' => 'Ideen & Vorschläge', + 'description' => 'Neue Funktionen, Verbesserungen und Wünsche rund um Papa-Kind-Treff.', + 'icon' => '💡', + ], + [ + 'slug' => 'fehler-und-probleme', + 'title' => 'Fehler und Probleme', + 'description' => 'Wenn etwas nicht funktioniert oder technisch hakt, gehört es hier hin.', + 'icon' => '🛠️', + ], + ], + ], +]; diff --git a/partials/landing/account/dashboard.php b/partials/landing/account/dashboard.php index ae96451..50bb54e 100755 --- a/partials/landing/account/dashboard.php +++ b/partials/landing/account/dashboard.php @@ -29,7 +29,37 @@ $allowNoKidsChecked = $editEvent ? ((int)$editEvent['allow_kids'] === 0) : false
-
+
+
+
Community
+

Dein Community-Status

+
    +
  • Rang:
  • +
  • Punkte:
  • +
  • Rollen:
  • +
  • Themen erstellen:
  • +
  • Antworten schreiben:
  • +
+ +

Hinweis zur Community-Sperre:

+ + +
+ + + + +
+ +
+

Letzte Bewerbung:

+ +

+ +
+ +
+
Profil

Deine Angaben

diff --git a/partials/landing/community/index.php b/partials/landing/community/index.php old mode 100755 new mode 100644 index a1af204..9747466 --- a/partials/landing/community/index.php +++ b/partials/landing/community/index.php @@ -5,28 +5,62 @@ $app = app(); $pdo = $app->pdo(); $userId = $_SESSION['user_id'] ?? null; $error = ''; +$info = ''; $search = trim((string)($_GET['q'] ?? '')); +$boardSlug = trim((string)($_GET['board'] ?? '')); $communityCfg = require __DIR__ . '/../../../config/community.php'; $community = $pdo ? new \App\Community($pdo, $communityCfg) : null; -$stats = $community ? $community->getStats() : ['threads' => 0, 'posts' => 0, 'members' => 0]; +$access = $pdo ? new \App\CommunityAccess($pdo, $communityCfg) : null; -if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'thread_create') { - if (!$userId) { - $error = 'Bitte einloggen, um Fragen zu stellen.'; - } elseif ($community) { - $title = trim((string)($_POST['title'] ?? '')); - $body = trim((string)($_POST['body'] ?? '')); - if ($title === '' || $body === '') { - $error = 'Titel und Text sind erforderlich.'; - } else { - $community->createThread((int)$userId, $title, $body); - redirect('/community'); +$stats = $community ? $community->getStats() : ['threads' => 0, 'posts' => 0, 'members' => 0]; +$forumCategories = $community ? $community->listForumCategories() : []; +$selectedBoard = ($community && $boardSlug !== '') ? $community->getBoardBySlug($boardSlug) : null; +$userRoles = $access && $userId ? $access->getUserRoles((int)$userId) : []; +$isForumAdmin = $access && $userId ? $access->canModerateForum((int)$userId) : false; +$canManageApplications = $access && $userId ? ($access->canManageApplications((int)$userId) && $access->supportsApplications()) : false; +$canManageRoles = $access && $userId ? $access->canManageRoles((int)$userId) : false; + +if ($_SERVER['REQUEST_METHOD'] === 'POST' && $community && $access) { + $action = (string)($_POST['action'] ?? ''); + try { + if ($action === 'thread_create') { + if (!$userId) { + throw new \RuntimeException('Bitte einloggen, um Themen zu erstellen.'); + } + if (!$access->canCreateThread((int)$userId)) { + $state = $access->getRestrictionState((int)$userId); + throw new \RuntimeException($state['reason'] ?: 'Du darfst aktuell keine Themen erstellen.'); + } + $community->createThreadInBoard((int)$userId, (string)($_POST['board_slug'] ?? ''), (string)($_POST['title'] ?? ''), (string)($_POST['body'] ?? '')); + redirect('/community' . ($boardSlug !== '' ? '?board=' . rawurlencode($boardSlug) : '')); + } elseif ($action === 'application_decide') { + $access->decideApplication((int)$userId, (int)($_POST['application_id'] ?? 0), (string)($_POST['decision'] ?? ''), (string)($_POST['decision_reason'] ?? '')); + $info = 'Bewerbung wurde bearbeitet.'; + } elseif ($action === 'report_resolve') { + $access->resolveReport((int)$userId, (int)($_POST['report_id'] ?? 0), (string)($_POST['moderator_note'] ?? '')); + $info = 'Meldung wurde abgeschlossen.'; + } elseif ($action === 'role_revoke') { + $access->revokeRole((int)$userId, (int)($_POST['target_user_id'] ?? 0), (string)($_POST['role'] ?? '')); + $info = 'Rolle wurde entzogen.'; + } 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(); } } -$threads = $community ? ($search !== '' ? $community->searchThreads($search, 50) : $community->listThreads(50)) : []; +$threads = $community + ? ($search !== '' ? $community->searchThreads($search, 50, $boardSlug !== '' ? $boardSlug : null) : $community->listThreads(50, $boardSlug !== '' ? $boardSlug : null)) + : []; +$applications = $canManageApplications && $access ? $access->listApplications('open') : []; +$reports = $isForumAdmin && $access ? $access->listOpenReports() : []; +$roleAssignments = $canManageRoles && $access ? $access->listRoleAssignments() : []; ?>
@@ -36,6 +70,10 @@ $threads = $community ? ($search !== '' ? $community->searchThreads($search, 50) Home / Community + + / + +

Community

Fragen stellen, Erfahrungen teilen, Antworten finden und gemeinsam weiterdenken.

@@ -50,10 +88,13 @@ $threads = $community ? ($search !== '' ? $community->searchThreads($search, 50)
-
+
+ + +
-
+
Themen @@ -62,19 +103,59 @@ $threads = $community ? ($search !== '' ? $community->searchThreads($search, 50) Beiträge
-
- Mitglieder - -
-
+
+ +
+
+
+ + +
+
+ +
+

+ + + +

+

+
+ Themen: + Beiträge: +
+
+
+
+ + + + + + Noch keine Themen + +
+
+ +
+
+ +
+ +
-

Forum

-

Hier findest du die neuesten Themen, Antworten und laufenden Gespräche.

+

+

+ +

+ + +
+ +
+
+
+

Offene Bewerbungen

+

Bewerbungen ab Rang Community-Vater prüfen.

+
+
+
+ +
+
+ +

+
+ + + + +
+ + +
+ +
+ + +
Keine offenen Bewerbungen.
+ +
+
+ + + supportsReports()): ?> +
+
+
+

Offene Meldungen

+

Gemeldete Inhalte prüfen und moderieren.

+
+
+
+ +
+
+ # +

+

Gemeldet von am

+
+
+ + + + +
+
+ + +
Keine offenen Meldungen.
+ +
+
+ + + hasModerationTables()): ?> +
+
+
+

Rollenübersicht

+

Vergebene Community-Rollen im Blick behalten.

+
+
+
+ +
+
+ +

Rolle: · seit

+
+
+ + + + +
+
+ + +
Keine Rollen vergeben.
+ +
+
+ +
+
@@ -132,22 +313,36 @@ $threads = $community ? ($search !== '' ? $community->searchThreads($search, 50)
- + $p): ?> 0, 'unhelpful' => 0]; + $userVote = $userVotes[(int)$p['id']] ?? 0; + $isHighlighted = !empty($p['highlighted_at']); ?> -
+
- + + #
- + +
+ + + +
+ + Abbrechen +
+
+ + + +
+ +
+ supportsFeedback()): ?> +
+ + + + +
+
+ + + + +
+ + supportsReports()): ?> +
+ + + + + +
+ + + canHighlightHelpful($community->computePoints((int)$userId))): ?> +
+ + + + +
+ + + + Bearbeiten +
+ + + +
+
@@ -106,21 +282,68 @@ $threadLevel = $community ? $community->membershipLevel($threadPoints) : ['label
- -
- - -
- -
- - -
- -
+ + + +
+ +
+ + +
+ +
+
+ + + + + + 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; + } + } +}