diff --git a/config/blacklist-db.js b/config/blacklist-db.js
new file mode 100644
index 0000000..294c2c3
--- /dev/null
+++ b/config/blacklist-db.js
@@ -0,0 +1,314 @@
+/**
+ * Datenbankbasierte Blacklist für unerwünschte Namen
+ * Lädt und verwaltet Blacklist-Einträge aus der Datenbank
+ */
+
+const { checkWithCategoryThreshold, checkWithTrigramIndex, TrigramIndex, THRESHOLDS } = require('./levenshtein');
+
+// Datenbankverbindung direkt hier implementieren
+const { Pool } = require('pg');
+
+const pool = new Pool({
+ user: process.env.DB_USER || 'postgres',
+ host: process.env.DB_HOST || 'localhost',
+ database: process.env.DB_NAME || 'ninjaserver',
+ password: process.env.DB_PASSWORD || 'password',
+ port: process.env.DB_PORT || 5432,
+});
+
+// Trigram-Index für Performance-Optimierung
+let trigramIndex = null;
+let blacklistCache = null;
+let lastCacheUpdate = 0;
+const CACHE_TTL = 5 * 60 * 1000; // 5 Minuten
+
+/**
+ * Lädt alle Blacklist-Einträge aus der Datenbank mit Caching
+ * @returns {Object} - Blacklist gruppiert nach Kategorien
+ */
+async function loadBlacklistFromDB() {
+ const now = Date.now();
+
+ // Verwende Cache falls verfügbar und nicht abgelaufen
+ if (blacklistCache && (now - lastCacheUpdate) < CACHE_TTL) {
+ return blacklistCache;
+ }
+
+ try {
+ const result = await pool.query(
+ 'SELECT term, category FROM blacklist_terms ORDER BY category, term'
+ );
+
+ const blacklist = {
+ historical: [],
+ offensive: [],
+ titles: [],
+ brands: [],
+ inappropriate: []
+ };
+
+ // Erstelle neuen Trigram-Index
+ trigramIndex = new TrigramIndex();
+
+ result.rows.forEach(row => {
+ if (blacklist[row.category]) {
+ blacklist[row.category].push(row.term);
+ // Füge zum Trigram-Index hinzu
+ trigramIndex.addTerm(row.term, row.category);
+ }
+ });
+
+ // Cache aktualisieren
+ blacklistCache = blacklist;
+ lastCacheUpdate = now;
+
+ return blacklist;
+ } catch (error) {
+ console.error('Error loading blacklist from database:', error);
+ // Fallback zur statischen Blacklist
+ return getStaticBlacklist();
+ }
+}
+
+/**
+ * Statische Blacklist als Fallback
+ */
+function getStaticBlacklist() {
+ return {
+ historical: [
+ 'adolf', 'hitler', 'adolf hitler', 'adolfhittler',
+ 'mussolini', 'benito', 'benito mussolini',
+ 'stalin', 'joseph stalin', 'mao', 'mao zedong'
+ ],
+ offensive: [
+ 'satan', 'luzifer', 'teufel', 'devil',
+ 'hurensohn', 'wichser', 'fotze', 'arschloch',
+ 'idiot', 'dummkopf', 'trottel', 'schwachsinnig',
+ 'nazi', 'faschist', 'rassist'
+ ],
+ titles: [
+ 'lord', 'lady', 'sir', 'dame',
+ 'prinz', 'prinzessin', 'prince', 'princess',
+ 'könig', 'königin', 'king', 'queen',
+ 'doktor', 'professor', 'dr', 'prof'
+ ],
+ brands: [
+ 'mcdonald', 'coca cola', 'cocacola', 'pepsi',
+ 'nike', 'adidas', 'puma', 'reebok',
+ 'bmw', 'mercedes', 'audi', 'volkswagen'
+ ],
+ inappropriate: [
+ 'sex', 'porn', 'porno', 'fuck', 'shit',
+ 'bitch', 'whore', 'prostitute',
+ 'drug', 'cocaine', 'heroin', 'marijuana'
+ ]
+ };
+}
+
+/**
+ * Prüft ob ein Name in der Blacklist steht (exakte Übereinstimmung)
+ * @param {string} firstname - Vorname
+ * @param {string} lastname - Nachname
+ * @returns {Object} - {isBlocked: boolean, reason: string, category: string}
+ */
+async function checkNameAgainstBlacklist(firstname, lastname) {
+ if (!firstname || !lastname) {
+ return { isBlocked: false, reason: '', category: '' };
+ }
+
+ try {
+ const blacklist = await loadBlacklistFromDB();
+ const fullName = `${firstname.toLowerCase()} ${lastname.toLowerCase()}`;
+ const firstNameOnly = firstname.toLowerCase();
+ const lastNameOnly = lastname.toLowerCase();
+
+ // Alle Blacklist-Einträge in einem Array sammeln
+ const allBlacklistEntries = [];
+
+ Object.entries(blacklist).forEach(([category, entries]) => {
+ entries.forEach(entry => {
+ allBlacklistEntries.push({
+ term: entry,
+ category: category,
+ reason: getCategoryReason(category)
+ });
+ });
+ });
+
+ // 1. Exakte Übereinstimmung prüfen
+ for (const entry of allBlacklistEntries) {
+ const term = entry.term.toLowerCase();
+
+ // Vollständiger Name
+ if (fullName.includes(term) || term.includes(fullName)) {
+ return {
+ isBlocked: true,
+ reason: entry.reason,
+ category: entry.category,
+ matchedTerm: entry.term,
+ matchType: 'exact'
+ };
+ }
+
+ // Vorname allein
+ if (firstNameOnly.includes(term) || term.includes(firstNameOnly)) {
+ return {
+ isBlocked: true,
+ reason: entry.reason,
+ category: entry.category,
+ matchedTerm: entry.term,
+ matchType: 'exact'
+ };
+ }
+
+ // Nachname allein
+ if (lastNameOnly.includes(term) || term.includes(lastNameOnly)) {
+ return {
+ isBlocked: true,
+ reason: entry.reason,
+ category: entry.category,
+ matchedTerm: entry.term,
+ matchType: 'exact'
+ };
+ }
+ }
+
+ // 2. Levenshtein-Distanz prüfen (Fuzzy-Matching)
+ // Verwende Trigram-Index für bessere Performance bei großen Blacklists
+ let levenshteinResult;
+ if (trigramIndex && Object.values(blacklist).flat().length > 100) {
+ // Performance-optimierte Version für große Blacklists
+ levenshteinResult = checkWithTrigramIndex(firstname, lastname, blacklist, trigramIndex);
+ } else {
+ // Standard-Version für kleine Blacklists
+ for (const [category, entries] of Object.entries(blacklist)) {
+ const categoryResult = checkWithCategoryThreshold(firstname, lastname, entries, category);
+ if (categoryResult.hasSimilarTerms) {
+ levenshteinResult = categoryResult;
+ break; // Frühe Beendigung bei erstem Match
+ }
+ }
+ }
+
+ if (levenshteinResult && levenshteinResult.hasSimilarTerms) {
+ const bestMatch = levenshteinResult.bestMatch;
+ return {
+ isBlocked: true,
+ reason: `${getCategoryReason(bestMatch.category || 'unknown')} (ähnlich)`,
+ category: bestMatch.category || 'unknown',
+ matchedTerm: bestMatch.term,
+ matchType: 'similar',
+ similarity: bestMatch.distance,
+ levenshteinDistance: bestMatch.levenshteinDistance
+ };
+ }
+
+ return { isBlocked: false, reason: '', category: '' };
+ } catch (error) {
+ console.error('Error checking name against blacklist:', error);
+ return { isBlocked: false, reason: '', category: '' };
+ }
+}
+
+/**
+ * Gibt eine benutzerfreundliche Begründung für die Kategorie zurück
+ */
+function getCategoryReason(category) {
+ const reasons = {
+ historical: 'Historisch belasteter Name',
+ offensive: 'Beleidigender oder anstößiger Begriff',
+ titles: 'Titel oder Berufsbezeichnung',
+ brands: 'Markenname',
+ inappropriate: 'Unpassender Begriff'
+ };
+
+ return reasons[category] || 'Unzulässiger Begriff';
+}
+
+/**
+ * Fügt einen neuen Begriff zur Blacklist hinzu
+ * @param {string} term - Der hinzuzufügende Begriff
+ * @param {string} category - Die Kategorie
+ * @param {string} createdBy - Wer hat den Begriff hinzugefügt
+ */
+async function addToBlacklist(term, category, createdBy = 'admin') {
+ try {
+ await pool.query(
+ 'INSERT INTO blacklist_terms (term, category, created_by) VALUES ($1, $2, $3) ON CONFLICT (term, category) DO NOTHING',
+ [term.toLowerCase(), category, createdBy]
+ );
+
+ // Cache invalidieren
+ invalidateCache();
+
+ return true;
+ } catch (error) {
+ console.error('Error adding to blacklist:', error);
+ throw error;
+ }
+}
+
+/**
+ * Entfernt einen Begriff aus der Blacklist
+ * @param {string} term - Der zu entfernende Begriff
+ * @param {string} category - Die Kategorie
+ */
+async function removeFromBlacklist(term, category) {
+ try {
+ const result = await pool.query(
+ 'DELETE FROM blacklist_terms WHERE term = $1 AND category = $2',
+ [term.toLowerCase(), category]
+ );
+
+ // Cache invalidieren
+ invalidateCache();
+
+ return result.rowCount > 0;
+ } catch (error) {
+ console.error('Error removing from blacklist:', error);
+ throw error;
+ }
+}
+
+/**
+ * Invalidiert den Blacklist-Cache
+ */
+function invalidateCache() {
+ blacklistCache = null;
+ trigramIndex = null;
+ lastCacheUpdate = 0;
+}
+
+/**
+ * Gibt die komplette Blacklist zurück (für Admin-Zwecke)
+ */
+async function getBlacklist() {
+ return await loadBlacklistFromDB();
+}
+
+/**
+ * Synchronisiert die statische Blacklist mit der Datenbank
+ * (Nur für Initial-Setup oder Migration)
+ */
+async function syncStaticBlacklist() {
+ const staticBlacklist = getStaticBlacklist();
+
+ for (const [category, terms] of Object.entries(staticBlacklist)) {
+ for (const term of terms) {
+ try {
+ await addToBlacklist(term, category, 'system');
+ } catch (error) {
+ console.error(`Error syncing term ${term} in category ${category}:`, error);
+ }
+ }
+ }
+}
+
+module.exports = {
+ checkNameAgainstBlacklist,
+ addToBlacklist,
+ removeFromBlacklist,
+ getBlacklist,
+ loadBlacklistFromDB,
+ syncStaticBlacklist
+};
diff --git a/config/blacklist.js b/config/blacklist.js
new file mode 100644
index 0000000..3e3de56
--- /dev/null
+++ b/config/blacklist.js
@@ -0,0 +1,176 @@
+/**
+ * Blacklist für unerwünschte Namen
+ * Basierend auf deutschen Namensregeln und allgemeinen Richtlinien
+ */
+
+const BLACKLIST = {
+ // Historisch belastete Namen
+ historical: [
+ 'adolf', 'hitler', 'adolf hitler', 'adolfhittler', 'adolfhittler',
+ 'mussolini', 'benito', 'benito mussolini',
+ 'stalin', 'joseph stalin',
+ 'mao', 'mao zedong',
+ 'pol pot', 'polpot',
+ 'saddam', 'saddam hussein',
+ 'osama', 'osama bin laden',
+ 'kim jong', 'kim jong il', 'kim jong un'
+ ],
+
+ // Beleidigende/anstößige Begriffe
+ offensive: [
+ 'satan', 'luzifer', 'teufel', 'devil',
+ 'hurensohn', 'wichser', 'fotze', 'arschloch',
+ 'idiot', 'dummkopf', 'trottel', 'schwachsinnig',
+ 'nazi', 'faschist', 'rassist',
+ 'terrorist', 'mörder', 'killer'
+ ],
+
+ // Titel und Berufsbezeichnungen
+ titles: [
+ 'lord', 'lady', 'sir', 'dame',
+ 'prinz', 'prinzessin', 'prince', 'princess',
+ 'könig', 'königin', 'king', 'queen',
+ 'kaiser', 'kaiserin', 'emperor', 'empress',
+ 'doktor', 'professor', 'dr', 'prof',
+ 'pastor', 'pfarrer', 'bischof', 'priester',
+ 'richter', 'anwalt', 'notar'
+ ],
+
+ // Markennamen (Beispiele)
+ brands: [
+ 'mcdonald', 'coca cola', 'cocacola', 'pepsi',
+ 'nike', 'adidas', 'puma', 'reebok',
+ 'bmw', 'mercedes', 'audi', 'volkswagen',
+ 'apple', 'microsoft', 'google', 'facebook',
+ 'samsung', 'sony', 'panasonic'
+ ],
+
+ // Unpassende Begriffe
+ inappropriate: [
+ 'sex', 'porn', 'porno', 'fuck', 'shit',
+ 'bitch', 'whore', 'prostitute',
+ 'drug', 'cocaine', 'heroin', 'marijuana',
+ 'bomb', 'explosive', 'weapon', 'gun'
+ ]
+};
+
+/**
+ * Prüft ob ein Name in der Blacklist steht
+ * @param {string} firstname - Vorname
+ * @param {string} lastname - Nachname
+ * @returns {Object} - {isBlocked: boolean, reason: string, category: string}
+ */
+function checkNameAgainstBlacklist(firstname, lastname) {
+ if (!firstname || !lastname) {
+ return { isBlocked: false, reason: '', category: '' };
+ }
+
+ const fullName = `${firstname.toLowerCase()} ${lastname.toLowerCase()}`;
+ const firstNameOnly = firstname.toLowerCase();
+ const lastNameOnly = lastname.toLowerCase();
+
+ // Alle Blacklist-Einträge in einem Array sammeln
+ const allBlacklistEntries = [];
+
+ Object.entries(BLACKLIST).forEach(([category, entries]) => {
+ entries.forEach(entry => {
+ allBlacklistEntries.push({
+ term: entry,
+ category: category,
+ reason: getCategoryReason(category)
+ });
+ });
+ });
+
+ // Prüfung durchführen
+ for (const entry of allBlacklistEntries) {
+ const term = entry.term.toLowerCase();
+
+ // Vollständiger Name
+ if (fullName.includes(term) || term.includes(fullName)) {
+ return {
+ isBlocked: true,
+ reason: entry.reason,
+ category: entry.category,
+ matchedTerm: entry.term
+ };
+ }
+
+ // Vorname allein
+ if (firstNameOnly.includes(term) || term.includes(firstNameOnly)) {
+ return {
+ isBlocked: true,
+ reason: entry.reason,
+ category: entry.category,
+ matchedTerm: entry.term
+ };
+ }
+
+ // Nachname allein
+ if (lastNameOnly.includes(term) || term.includes(lastNameOnly)) {
+ return {
+ isBlocked: true,
+ reason: entry.reason,
+ category: entry.category,
+ matchedTerm: entry.term
+ };
+ }
+ }
+
+ return { isBlocked: false, reason: '', category: '' };
+}
+
+/**
+ * Gibt eine benutzerfreundliche Begründung für die Kategorie zurück
+ */
+function getCategoryReason(category) {
+ const reasons = {
+ historical: 'Historisch belasteter Name',
+ offensive: 'Beleidigender oder anstößiger Begriff',
+ titles: 'Titel oder Berufsbezeichnung',
+ brands: 'Markenname',
+ inappropriate: 'Unpassender Begriff'
+ };
+
+ return reasons[category] || 'Unzulässiger Begriff';
+}
+
+/**
+ * Fügt einen neuen Begriff zur Blacklist hinzu
+ * @param {string} term - Der hinzuzufügende Begriff
+ * @param {string} category - Die Kategorie
+ */
+function addToBlacklist(term, category) {
+ if (BLACKLIST[category] && !BLACKLIST[category].includes(term.toLowerCase())) {
+ BLACKLIST[category].push(term.toLowerCase());
+ }
+}
+
+/**
+ * Entfernt einen Begriff aus der Blacklist
+ * @param {string} term - Der zu entfernende Begriff
+ * @param {string} category - Die Kategorie
+ */
+function removeFromBlacklist(term, category) {
+ if (BLACKLIST[category]) {
+ const index = BLACKLIST[category].indexOf(term.toLowerCase());
+ if (index > -1) {
+ BLACKLIST[category].splice(index, 1);
+ }
+ }
+}
+
+/**
+ * Gibt die komplette Blacklist zurück (für Admin-Zwecke)
+ */
+function getBlacklist() {
+ return BLACKLIST;
+}
+
+module.exports = {
+ checkNameAgainstBlacklist,
+ addToBlacklist,
+ removeFromBlacklist,
+ getBlacklist,
+ BLACKLIST
+};
diff --git a/config/levenshtein.js b/config/levenshtein.js
new file mode 100644
index 0000000..ab0e318
--- /dev/null
+++ b/config/levenshtein.js
@@ -0,0 +1,336 @@
+/**
+ * Levenshtein-Distanz Algorithmus für Fuzzy-Matching
+ * Erkennt Abwandlungen und Tippfehler von Blacklist-Begriffen
+ */
+
+/**
+ * Berechnet die Levenshtein-Distanz zwischen zwei Strings
+ * @param {string} str1 - Erster String
+ * @param {string} str2 - Zweiter String
+ * @returns {number} - Distanz (0 = identisch, höher = unterschiedlicher)
+ */
+function levenshteinDistance(str1, str2) {
+ const len1 = str1.length;
+ const len2 = str2.length;
+
+ // Erstelle Matrix
+ const matrix = Array(len2 + 1).fill(null).map(() => Array(len1 + 1).fill(null));
+
+ // Initialisiere erste Zeile und Spalte
+ for (let i = 0; i <= len1; i++) {
+ matrix[0][i] = i;
+ }
+ for (let j = 0; j <= len2; j++) {
+ matrix[j][0] = j;
+ }
+
+ // Fülle Matrix
+ for (let j = 1; j <= len2; j++) {
+ for (let i = 1; i <= len1; i++) {
+ const cost = str1[i - 1] === str2[j - 1] ? 0 : 1;
+ matrix[j][i] = Math.min(
+ matrix[j][i - 1] + 1, // Deletion
+ matrix[j - 1][i] + 1, // Insertion
+ matrix[j - 1][i - 1] + cost // Substitution
+ );
+ }
+ }
+
+ return matrix[len2][len1];
+}
+
+/**
+ * Berechnet die normalisierte Levenshtein-Distanz (0-1)
+ * @param {string} str1 - Erster String
+ * @param {string} str2 - Zweiter String
+ * @returns {number} - Normalisierte Distanz (0 = identisch, 1 = komplett unterschiedlich)
+ */
+function normalizedLevenshteinDistance(str1, str2) {
+ const distance = levenshteinDistance(str1, str2);
+ const maxLength = Math.max(str1.length, str2.length);
+ return maxLength === 0 ? 0 : distance / maxLength;
+}
+
+/**
+ * Prüft ob ein String ähnlich zu einem Blacklist-Begriff ist
+ * @param {string} input - Eingabe-String
+ * @param {string} blacklistTerm - Blacklist-Begriff
+ * @param {number} threshold - Schwellenwert (0-1, niedriger = strenger)
+ * @returns {boolean} - True wenn ähnlich genug
+ */
+function isSimilarToBlacklistTerm(input, blacklistTerm, threshold = 0.3) {
+ const normalizedDistance = normalizedLevenshteinDistance(input, blacklistTerm);
+ return normalizedDistance <= threshold;
+}
+
+/**
+ * Findet ähnliche Begriffe in einer Blacklist
+ * @param {string} input - Eingabe-String
+ * @param {Array} blacklistTerms - Array von Blacklist-Begriffen
+ * @param {number} threshold - Schwellenwert (0-1)
+ * @returns {Array} - Array von ähnlichen Begriffen mit Distanz
+ */
+function findSimilarTerms(input, blacklistTerms, threshold = 0.3) {
+ const similarTerms = [];
+ const normalizedInput = input.toLowerCase().trim();
+
+ // Performance-Optimierung: Frühe Beendigung bei sehr kurzen Strings
+ if (normalizedInput.length < 2) {
+ return similarTerms;
+ }
+
+ for (const term of blacklistTerms) {
+ const normalizedTerm = term.toLowerCase().trim();
+
+ // Performance-Optimierung: Skip bei zu großer Längendifferenz
+ const lengthDiff = Math.abs(normalizedInput.length - normalizedTerm.length);
+ const maxLengthDiff = Math.ceil(normalizedInput.length * threshold);
+ if (lengthDiff > maxLengthDiff) {
+ continue;
+ }
+
+ const distance = normalizedLevenshteinDistance(normalizedInput, normalizedTerm);
+ if (distance <= threshold) {
+ similarTerms.push({
+ term: term,
+ distance: distance,
+ levenshteinDistance: levenshteinDistance(normalizedInput, normalizedTerm)
+ });
+ }
+ }
+
+ // Sortiere nach Distanz (niedrigste zuerst)
+ return similarTerms.sort((a, b) => a.distance - b.distance);
+}
+
+/**
+ * Erweiterte Blacklist-Prüfung mit Levenshtein-Distanz und Teilstring-Matching
+ * @param {string} firstname - Vorname
+ * @param {string} lastname - Nachname
+ * @param {Array} blacklistTerms - Array von Blacklist-Begriffen
+ * @param {number} threshold - Schwellenwert für Ähnlichkeit (0-1)
+ * @returns {Object} - Prüfungsergebnis mit ähnlichen Begriffen
+ */
+function checkWithLevenshtein(firstname, lastname, blacklistTerms, threshold = 0.3) {
+ const fullName = `${firstname.toLowerCase().trim()} ${lastname.toLowerCase().trim()}`;
+ const firstNameOnly = firstname.toLowerCase().trim();
+ const lastNameOnly = lastname.toLowerCase().trim();
+
+ // Prüfe alle Varianten
+ const variants = [fullName, firstNameOnly, lastNameOnly];
+ const allSimilarTerms = [];
+
+ for (const variant of variants) {
+ // 1. Direkte Levenshtein-Prüfung
+ const similarTerms = findSimilarTerms(variant, blacklistTerms, threshold);
+ allSimilarTerms.push(...similarTerms);
+
+ // 2. Teilstring-Matching: Prüfe alle Wörter im Variant gegen Blacklist
+ const words = variant.split(/\s+/);
+ for (const word of words) {
+ if (word.length >= 2) { // Nur Wörter mit mindestens 2 Zeichen
+ const wordSimilarTerms = findSimilarTerms(word, blacklistTerms, threshold);
+ allSimilarTerms.push(...wordSimilarTerms);
+ }
+ }
+
+ // 3. Teilstring-Matching: Prüfe Blacklist-Begriffe gegen Variant
+ for (const blacklistTerm of blacklistTerms) {
+ const normalizedTerm = blacklistTerm.toLowerCase().trim();
+ if (normalizedTerm.length >= 2) {
+ // Prüfe ob Blacklist-Begriff als Teilstring im Variant vorkommt
+ if (variant.includes(normalizedTerm)) {
+ allSimilarTerms.push({
+ term: blacklistTerm,
+ distance: 0, // Exakte Teilstring-Übereinstimmung
+ levenshteinDistance: 0,
+ matchType: 'substring'
+ });
+ } else {
+ // Prüfe Levenshtein für Teilstrings
+ const words = variant.split(/\s+/);
+ for (const word of words) {
+ if (word.length >= 2) {
+ const distance = normalizedLevenshteinDistance(word, normalizedTerm);
+ if (distance <= threshold) {
+ allSimilarTerms.push({
+ term: blacklistTerm,
+ distance: distance,
+ levenshteinDistance: levenshteinDistance(word, normalizedTerm),
+ matchType: 'substring-similar'
+ });
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Entferne Duplikate und sortiere nach Distanz
+ const uniqueSimilarTerms = allSimilarTerms.reduce((acc, current) => {
+ const existing = acc.find(item => item.term === current.term);
+ if (!existing || current.distance < existing.distance) {
+ return acc.filter(item => item.term !== current.term).concat(current);
+ }
+ return acc;
+ }, []);
+
+ return {
+ hasSimilarTerms: uniqueSimilarTerms.length > 0,
+ similarTerms: uniqueSimilarTerms.sort((a, b) => a.distance - b.distance),
+ bestMatch: uniqueSimilarTerms.length > 0 ? uniqueSimilarTerms[0] : null
+ };
+}
+
+/**
+ * Konfigurierbare Schwellenwerte für verschiedene Kategorien
+ */
+const THRESHOLDS = {
+ historical: 0.2, // Sehr streng für historische Begriffe
+ offensive: 0.25, // Streng für beleidigende Begriffe
+ titles: 0.3, // Normal für Titel
+ brands: 0.35, // Etwas lockerer für Marken
+ inappropriate: 0.3 // Normal für unpassende Begriffe
+};
+
+/**
+ * Performance-optimierte Version für große Blacklists
+ * Verwendet Trigram-Index für bessere Performance
+ */
+class TrigramIndex {
+ constructor() {
+ this.index = new Map();
+ }
+
+ /**
+ * Erstellt Trigramme aus einem String
+ * @param {string} str - Eingabe-String
+ * @returns {Array} - Array von Trigrammen
+ */
+ createTrigrams(str) {
+ const normalized = str.toLowerCase().trim();
+ const trigrams = [];
+
+ for (let i = 0; i < normalized.length - 2; i++) {
+ trigrams.push(normalized.substring(i, i + 3));
+ }
+
+ return trigrams;
+ }
+
+ /**
+ * Fügt einen Begriff zum Index hinzu
+ * @param {string} term - Begriff
+ * @param {string} category - Kategorie
+ */
+ addTerm(term, category) {
+ const trigrams = this.createTrigrams(term);
+ for (const trigram of trigrams) {
+ if (!this.index.has(trigram)) {
+ this.index.set(trigram, []);
+ }
+ this.index.get(trigram).push({ term, category });
+ }
+ }
+
+ /**
+ * Findet Kandidaten basierend auf Trigram-Übereinstimmung
+ * @param {string} input - Eingabe-String
+ * @param {number} minTrigrams - Mindestanzahl übereinstimmender Trigramme
+ * @returns {Array} - Array von Kandidaten
+ */
+ findCandidates(input, minTrigrams = 1) {
+ const inputTrigrams = this.createTrigrams(input);
+ const candidateCount = new Map();
+
+ for (const trigram of inputTrigrams) {
+ if (this.index.has(trigram)) {
+ for (const candidate of this.index.get(trigram)) {
+ const key = `${candidate.term}|${candidate.category}`;
+ candidateCount.set(key, (candidateCount.get(key) || 0) + 1);
+ }
+ }
+ }
+
+ // Filtere Kandidaten mit mindestens minTrigrams Übereinstimmungen
+ const candidates = [];
+ for (const [key, count] of candidateCount) {
+ if (count >= minTrigrams) {
+ const [term, category] = key.split('|');
+ candidates.push({ term, category });
+ }
+ }
+
+ return candidates;
+ }
+}
+
+/**
+ * Performance-optimierte Blacklist-Prüfung mit Trigram-Index
+ * @param {string} firstname - Vorname
+ * @param {string} lastname - Nachname
+ * @param {Object} blacklist - Blacklist gruppiert nach Kategorien
+ * @param {TrigramIndex} trigramIndex - Trigram-Index
+ * @returns {Object} - Prüfungsergebnis
+ */
+function checkWithTrigramIndex(firstname, lastname, blacklist, trigramIndex) {
+ const fullName = `${firstname.toLowerCase().trim()} ${lastname.toLowerCase().trim()}`;
+ const firstNameOnly = firstname.toLowerCase().trim();
+ const lastNameOnly = lastname.toLowerCase().trim();
+
+ const variants = [fullName, firstNameOnly, lastNameOnly];
+ const allSimilarTerms = [];
+
+ for (const variant of variants) {
+ // Finde Kandidaten mit Trigram-Index
+ const candidates = trigramIndex.findCandidates(variant, 1);
+
+ // Prüfe nur Kandidaten mit Levenshtein
+ for (const candidate of candidates) {
+ const categoryTerms = blacklist[candidate.category] || [];
+ const similarTerms = findSimilarTerms(variant, categoryTerms, THRESHOLDS[candidate.category] || 0.3);
+ allSimilarTerms.push(...similarTerms);
+ }
+ }
+
+ // Entferne Duplikate und sortiere
+ const uniqueSimilarTerms = allSimilarTerms.reduce((acc, current) => {
+ const existing = acc.find(item => item.term === current.term);
+ if (!existing || current.distance < existing.distance) {
+ return acc.filter(item => item.term !== current.term).concat(current);
+ }
+ return acc;
+ }, []);
+
+ return {
+ hasSimilarTerms: uniqueSimilarTerms.length > 0,
+ similarTerms: uniqueSimilarTerms.sort((a, b) => a.distance - b.distance),
+ bestMatch: uniqueSimilarTerms.length > 0 ? uniqueSimilarTerms[0] : null
+ };
+}
+
+/**
+ * Kategorie-spezifische Levenshtein-Prüfung
+ * @param {string} firstname - Vorname
+ * @param {string} lastname - Nachname
+ * @param {Array} blacklistTerms - Array von Blacklist-Begriffen
+ * @param {string} category - Kategorie der Begriffe
+ * @returns {Object} - Prüfungsergebnis
+ */
+function checkWithCategoryThreshold(firstname, lastname, blacklistTerms, category) {
+ const threshold = THRESHOLDS[category] || 0.3;
+ return checkWithLevenshtein(firstname, lastname, blacklistTerms, threshold);
+}
+
+module.exports = {
+ levenshteinDistance,
+ normalizedLevenshteinDistance,
+ isSimilarToBlacklistTerm,
+ findSimilarTerms,
+ checkWithLevenshtein,
+ checkWithCategoryThreshold,
+ checkWithTrigramIndex,
+ TrigramIndex,
+ THRESHOLDS
+};
diff --git a/config/llm-blacklist.js b/config/llm-blacklist.js
new file mode 100644
index 0000000..80e6d87
--- /dev/null
+++ b/config/llm-blacklist.js
@@ -0,0 +1,253 @@
+/**
+ * LLM-basierte Blacklist-Prüfung mit Ollama
+ * Verwendet ein lokales LLM zur intelligenten Bewertung von Namen
+ */
+
+const axios = require('axios');
+
+// Ollama-Konfiguration
+const OLLAMA_BASE_URL = process.env.OLLAMA_BASE_URL || 'http://localhost:11434';
+const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'llama3.2:3b'; // Schnelles, kleines Modell
+
+/**
+ * Prüft einen Namen mit dem LLM
+ * @param {string} firstname - Vorname
+ * @param {string} lastname - Nachname
+ * @returns {Object} - {isBlocked: boolean, reason: string, confidence: number}
+ */
+async function checkNameWithLLM(firstname, lastname) {
+ if (!firstname || !lastname) {
+ return { isBlocked: false, reason: '', confidence: 0 };
+ }
+
+ try {
+ const fullName = `${firstname} ${lastname}`;
+
+ // Prompt für das LLM
+ const prompt = `Du bist ein strenger Moderator für ein Spielsystem. Prüfe ob der Name "${fullName}" für die Verwendung geeignet ist.
+
+WICHTIG: Blockiere ALLE Namen die:
+- Historisch belastet sind (Adolf Hitler, Stalin, Mussolini, etc.)
+- Beleidigend oder anstößig sind (Satan, Idiot, etc.)
+- Unpassende Titel sind (Dr., Professor, etc.)
+- Markennamen sind (Coca-Cola, Nike, etc.)
+- Andere unangemessene Inhalte haben
+
+Antworte NUR mit "TRUE" (blockiert) oder "FALSE" (erlaubt) - keine Erklärungen.
+
+Name: "${fullName}"
+Antwort:`;
+
+ // Ollama API-Aufruf
+ const response = await axios.post(`${OLLAMA_BASE_URL}/api/generate`, {
+ model: OLLAMA_MODEL,
+ prompt: prompt,
+ stream: false,
+ options: {
+ temperature: 0.1, // Niedrige Temperatur für konsistente Antworten
+ top_p: 0.9,
+ max_tokens: 10 // Nur TRUE/FALSE erwartet
+ }
+ }, {
+ timeout: 10000 // 10 Sekunden Timeout
+ });
+
+ const llmResponse = response.data.response.trim().toUpperCase();
+
+ // Parse LLM-Antwort
+ let isBlocked = false;
+ let reason = '';
+ let confidence = 0.8; // Standard-Konfidenz für LLM
+
+ if (llmResponse === 'TRUE') {
+ isBlocked = true;
+ reason = 'Name wurde vom KI-Moderator als ungeeignet eingestuft';
+ } else if (llmResponse === 'FALSE') {
+ isBlocked = false;
+ reason = 'Name wurde vom KI-Moderator als geeignet eingestuft';
+ } else {
+ // Fallback bei unerwarteter Antwort
+ console.warn(`Unerwartete LLM-Antwort: "${llmResponse}" für Name: "${fullName}"`);
+ isBlocked = false;
+ reason = 'KI-Moderator konnte Name nicht eindeutig bewerten';
+ confidence = 0.3;
+ }
+
+ return {
+ isBlocked,
+ reason,
+ confidence,
+ llmResponse: llmResponse,
+ matchType: 'llm'
+ };
+
+ } catch (error) {
+ console.error('Error checking name with LLM:', error);
+
+ // Fallback bei LLM-Fehlern
+ return {
+ isBlocked: false,
+ reason: 'KI-Moderator nicht verfügbar - Name wurde erlaubt',
+ confidence: 0.1,
+ error: error.message,
+ matchType: 'llm-error'
+ };
+ }
+}
+
+/**
+ * Testet die LLM-Verbindung
+ * @returns {Object} - {connected: boolean, model: string, error?: string}
+ */
+async function testLLMConnection() {
+ try {
+ const response = await axios.post(`${OLLAMA_BASE_URL}/api/generate`, {
+ model: OLLAMA_MODEL,
+ prompt: 'Test',
+ stream: false,
+ options: {
+ max_tokens: 1
+ }
+ }, {
+ timeout: 5000
+ });
+
+ return {
+ connected: true,
+ model: OLLAMA_MODEL,
+ baseUrl: OLLAMA_BASE_URL
+ };
+ } catch (error) {
+ return {
+ connected: false,
+ model: OLLAMA_MODEL,
+ baseUrl: OLLAMA_BASE_URL,
+ error: error.message
+ };
+ }
+}
+
+/**
+ * Erweiterte LLM-Prüfung mit Kontext
+ * @param {string} firstname - Vorname
+ * @param {string} lastname - Nachname
+ * @param {string} context - Zusätzlicher Kontext (optional)
+ * @returns {Object} - Prüfungsergebnis
+ */
+async function checkNameWithContext(firstname, lastname, context = '') {
+ if (!firstname || !lastname) {
+ return { isBlocked: false, reason: '', confidence: 0 };
+ }
+
+ try {
+ const fullName = `${firstname} ${lastname}`;
+
+ // Erweiterter Prompt mit Kontext
+ const prompt = `Du bist ein Moderator für ein Spielsystem. Prüfe ob der Name "${fullName}" für die Verwendung geeignet ist.
+
+Kontext: ${context || 'Standard-Spielname'}
+
+Beurteile den Namen basierend auf:
+- Historisch belastete Namen (z.B. Adolf Hitler, Stalin, etc.)
+- Beleidigende oder anstößige Begriffe
+- Unpassende Titel oder Berufsbezeichnungen
+- Markennamen die nicht verwendet werden sollten
+- Andere unangemessene Inhalte
+
+Antworte NUR mit "TRUE" oder "FALSE" - keine Erklärungen.
+
+Name: "${fullName}"
+Antwort:`;
+
+ const response = await axios.post(`${OLLAMA_BASE_URL}/api/generate`, {
+ model: OLLAMA_MODEL,
+ prompt: prompt,
+ stream: false,
+ options: {
+ temperature: 0.1,
+ top_p: 0.9,
+ max_tokens: 10
+ }
+ }, {
+ timeout: 10000
+ });
+
+ const llmResponse = response.data.response.trim().toUpperCase();
+
+ let isBlocked = false;
+ let reason = '';
+ let confidence = 0.8;
+
+ if (llmResponse === 'TRUE') {
+ isBlocked = true;
+ reason = 'Name wurde vom KI-Moderator als ungeeignet eingestuft';
+ } else if (llmResponse === 'FALSE') {
+ isBlocked = false;
+ reason = 'Name wurde vom KI-Moderator als geeignet eingestuft';
+ } else {
+ console.warn(`Unerwartete LLM-Antwort: "${llmResponse}" für Name: "${fullName}"`);
+ isBlocked = false;
+ reason = 'KI-Moderator konnte Name nicht eindeutig bewerten';
+ confidence = 0.3;
+ }
+
+ return {
+ isBlocked,
+ reason,
+ confidence,
+ llmResponse: llmResponse,
+ matchType: 'llm-context',
+ context: context
+ };
+
+ } catch (error) {
+ console.error('Error checking name with LLM context:', error);
+
+ return {
+ isBlocked: false,
+ reason: 'KI-Moderator nicht verfügbar - Name wurde erlaubt',
+ confidence: 0.1,
+ error: error.message,
+ matchType: 'llm-error'
+ };
+ }
+}
+
+/**
+ * Batch-Prüfung mehrerer Namen
+ * @param {Array} names - Array von {firstname, lastname} Objekten
+ * @returns {Array} - Array von Prüfungsergebnissen
+ */
+async function checkNamesBatch(names) {
+ const results = [];
+
+ for (const name of names) {
+ try {
+ const result = await checkNameWithLLM(name.firstname, name.lastname);
+ results.push({
+ ...name,
+ ...result
+ });
+ } catch (error) {
+ results.push({
+ ...name,
+ isBlocked: false,
+ reason: 'Fehler bei der Prüfung',
+ confidence: 0,
+ error: error.message,
+ matchType: 'error'
+ });
+ }
+ }
+
+ return results;
+}
+
+module.exports = {
+ checkNameWithLLM,
+ checkNameWithContext,
+ checkNamesBatch,
+ testLLMConnection,
+ OLLAMA_BASE_URL,
+ OLLAMA_MODEL
+};
diff --git a/package-lock.json b/package-lock.json
index c4f9bd3..d4474a3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,6 +10,7 @@
"license": "propriatary",
"dependencies": {
"@hisma/server-puppeteer": "^0.6.5",
+ "axios": "^1.11.0",
"bcrypt": "^5.1.1",
"discord-oauth2": "^2.12.1",
"dotenv": "^16.3.1",
@@ -745,6 +746,23 @@
"node": ">=4"
}
},
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "license": "MIT"
+ },
+ "node_modules/axios": {
+ "version": "1.11.0",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
+ "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.15.6",
+ "form-data": "^4.0.4",
+ "proxy-from-env": "^1.1.0"
+ }
+ },
"node_modules/b4a": {
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz",
@@ -1083,6 +1101,18 @@
"color-support": "bin.js"
}
},
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/commander": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz",
@@ -1219,6 +1249,15 @@
"node": ">= 14"
}
},
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
"node_modules/delegates": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
@@ -1479,6 +1518,21 @@
"node": ">= 0.4"
}
},
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -1767,6 +1821,42 @@
"node": ">= 0.8"
}
},
+ "node_modules/follow-redirects": {
+ "version": "1.15.11",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
+ "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
+ "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -2016,6 +2106,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/has-unicode": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
@@ -4964,6 +5069,21 @@
"tslib": "^2.0.1"
}
},
+ "asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
+ },
+ "axios": {
+ "version": "1.11.0",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
+ "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
+ "requires": {
+ "follow-redirects": "^1.15.6",
+ "form-data": "^4.0.4",
+ "proxy-from-env": "^1.1.0"
+ }
+ },
"b4a": {
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz",
@@ -5188,6 +5308,14 @@
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
"integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg=="
},
+ "combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "requires": {
+ "delayed-stream": "~1.0.0"
+ }
+ },
"commander": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz",
@@ -5279,6 +5407,11 @@
"esprima": "^4.0.1"
}
},
+ "delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="
+ },
"delegates": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
@@ -5450,6 +5583,17 @@
"es-errors": "^1.3.0"
}
},
+ "es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "requires": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ }
+ },
"escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -5647,6 +5791,23 @@
"unpipe": "~1.0.0"
}
},
+ "follow-redirects": {
+ "version": "1.15.11",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
+ "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="
+ },
+ "form-data": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
+ "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
+ "requires": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.12"
+ }
+ },
"forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -5810,6 +5971,14 @@
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="
},
+ "has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "requires": {
+ "has-symbols": "^1.0.3"
+ }
+ },
"has-unicode": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
diff --git a/package.json b/package.json
index 55dbc80..ec0fcc4 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,7 @@
},
"dependencies": {
"@hisma/server-puppeteer": "^0.6.5",
+ "axios": "^1.11.0",
"bcrypt": "^5.1.1",
"discord-oauth2": "^2.12.1",
"dotenv": "^16.3.1",
diff --git a/public/admin-dashboard.html b/public/admin-dashboard.html
index 7f53f34..d17bf0b 100644
--- a/public/admin-dashboard.html
+++ b/public/admin-dashboard.html
@@ -99,6 +99,20 @@
Spieler anzeigen
+
+
+
🚫 Blacklist-Verwaltung
+
Verwalte verbotene Namen und Begriffe
+
Blacklist verwalten
+
+
+
+
+
🧠 KI-Moderator
+
Intelligente Namensprüfung mit Ollama LLM
+
KI-Moderator testen
+
+
⏱️ Läufe-Verwaltung
@@ -168,6 +182,47 @@
+
+
+
+
×
+
🚫 Blacklist-Verwaltung
+
+
+
+
+
Name testen
+
+
+
+ Testen
+
+
+
+
+
+
+
Neuen Eintrag hinzufügen
+
+
+
+ Historisch belastet
+ Beleidigend/anstößig
+ Titel/Berufsbezeichnung
+ Markenname
+ Unpassend
+
+ Hinzufügen
+
+
+
+
+
+
+
+
+
+
+
+
×
+
🧠 KI-Moderator
+
+
+
+
+
+
+
Name testen
+
+ Vorname:
+
+
+
+ Nachname:
+
+
+
+ Kontext (optional):
+
+
+
Mit KI prüfen
+
+
+
+
+
+
+
+
Vergleich mit Blacklist
+
+
+
+
+
diff --git a/public/css/admin-dashboard.css b/public/css/admin-dashboard.css
index 5931dfb..788a37f 100644
--- a/public/css/admin-dashboard.css
+++ b/public/css/admin-dashboard.css
@@ -204,11 +204,13 @@ body {
.modal-content {
background-color: rgba(26, 26, 46, 0.95);
- margin: 10% auto;
+ margin: 5% auto;
padding: 20px;
border-radius: 10px;
width: 90%;
max-width: 500px;
+ max-height: 85vh;
+ overflow-y: auto;
position: relative;
border: 1px solid rgba(255, 255, 255, 0.1);
}
@@ -228,6 +230,37 @@ body {
color: #ffffff;
}
+/* Blacklist Modal specific styles */
+#blacklistModal .modal-content {
+ max-width: 800px;
+ max-height: 90vh;
+ overflow-y: auto;
+}
+
+/* Smooth scrolling for modal content */
+.modal-content {
+ scroll-behavior: smooth;
+}
+
+/* Custom scrollbar for modal content */
+.modal-content::-webkit-scrollbar {
+ width: 8px;
+}
+
+.modal-content::-webkit-scrollbar-track {
+ background: rgba(30, 41, 59, 0.3);
+ border-radius: 4px;
+}
+
+.modal-content::-webkit-scrollbar-thumb {
+ background: rgba(100, 116, 139, 0.6);
+ border-radius: 4px;
+}
+
+.modal-content::-webkit-scrollbar-thumb:hover {
+ background: rgba(100, 116, 139, 0.8);
+}
+
.form-group {
margin-bottom: 15px;
}
diff --git a/public/css/dashboard.css b/public/css/dashboard.css
index b16a516..15d8adf 100644
--- a/public/css/dashboard.css
+++ b/public/css/dashboard.css
@@ -239,6 +239,8 @@ body {
border-radius: 1rem;
width: 90%;
max-width: 500px;
+ max-height: 80vh;
+ overflow-y: auto;
backdrop-filter: blur(20px);
}
@@ -260,7 +262,36 @@ body {
font-size: 2rem;
font-weight: bold;
cursor: pointer;
- transition: color 0.2s ease;
+}
+
+/* RFID Modal specific styles */
+#rfidModal .modal-content {
+ max-height: 85vh;
+ overflow-y: auto;
+}
+
+/* Smooth scrolling for modal content */
+.modal-content {
+ scroll-behavior: smooth;
+}
+
+/* Custom scrollbar for modal content */
+.modal-content::-webkit-scrollbar {
+ width: 8px;
+}
+
+.modal-content::-webkit-scrollbar-track {
+ background: rgba(30, 41, 59, 0.3);
+ border-radius: 4px;
+}
+
+.modal-content::-webkit-scrollbar-thumb {
+ background: rgba(100, 116, 139, 0.6);
+ border-radius: 4px;
+}
+
+.modal-content::-webkit-scrollbar-thumb:hover {
+ background: rgba(100, 116, 139, 0.8);
}
.close:hover {
diff --git a/public/css/leaderboard.css b/public/css/leaderboard.css
index 76f8ea4..0f38426 100644
--- a/public/css/leaderboard.css
+++ b/public/css/leaderboard.css
@@ -85,7 +85,7 @@ body {
.dashboard-grid {
display: grid;
- grid-template-columns: 1fr 2fr;
+ grid-template-columns: 1fr 2fr 1fr;
gap: 2rem;
margin-bottom: 2rem;
}
@@ -204,7 +204,6 @@ body {
border-radius: 0.75rem;
padding: 0.25rem;
gap: 0.25rem;
- overflow-x: auto;
}
.time-tab {
@@ -255,7 +254,7 @@ body {
.refresh-btn {
width: 100%;
- padding: 1rem;
+ padding: 2rem;
background: linear-gradient(135deg, #00d4ff, #0891b2);
border: none;
border-radius: 0.75rem;
@@ -266,6 +265,7 @@ body {
transition: all 0.2s ease;
text-transform: uppercase;
letter-spacing: 0.05em;
+ margin-top: 1.5rem;
}
.refresh-btn:hover {
@@ -281,6 +281,65 @@ body {
backdrop-filter: blur(20px);
}
+.admin-panel {
+ background: rgba(15, 23, 42, 0.8);
+ border: 1px solid #1e293b;
+ border-radius: 1rem;
+ padding: 2rem;
+ backdrop-filter: blur(20px);
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+}
+
+.admin-buttons {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.admin-btn {
+ padding: 1rem 1.5rem;
+ border: none;
+ border-radius: 0.75rem;
+ color: white;
+ font-weight: 600;
+ font-size: 0.9rem;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ text-decoration: none;
+ display: inline-block;
+ text-align: center;
+ width: 100%;
+}
+
+.admin-login-btn {
+ background: linear-gradient(135deg, #ff6b35, #f7931e);
+}
+
+.admin-login-btn:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 10px 25px rgba(255, 107, 53, 0.3);
+}
+
+.dashboard-btn {
+ background: linear-gradient(135deg, #00d4ff, #0891b2);
+}
+
+.dashboard-btn:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 10px 25px rgba(0, 212, 255, 0.3);
+}
+
+.logout-btn {
+ background: linear-gradient(135deg, #dc3545, #c82333);
+}
+
+.logout-btn:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 10px 25px rgba(220, 53, 69, 0.3);
+}
+
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
@@ -459,75 +518,6 @@ body {
line-height: 1.6;
}
-/* Admin Login Button */
-.admin-login-btn {
- position: fixed;
- top: 2rem;
- right: 2rem;
- padding: 0.75rem 1.5rem;
- background: linear-gradient(135deg, #ff6b35, #f7931e);
- border: none;
- border-radius: 0.75rem;
- color: white;
- font-weight: 600;
- font-size: 0.9rem;
- cursor: pointer;
- transition: all 0.2s ease;
- text-decoration: none;
- display: inline-block;
- z-index: 1000;
-}
-
-.admin-login-btn:hover {
- transform: translateY(-2px);
- box-shadow: 0 10px 25px rgba(255, 107, 53, 0.3);
-}
-
-.dashboard-btn {
- position: fixed;
- top: 2rem;
- right: 2rem;
- padding: 0.75rem 1.5rem;
- background: linear-gradient(135deg, #00d4ff, #0891b2);
- border: none;
- border-radius: 0.75rem;
- color: white;
- font-weight: 600;
- font-size: 0.9rem;
- cursor: pointer;
- transition: all 0.2s ease;
- text-decoration: none;
- display: inline-block;
- z-index: 1000;
-}
-
-.dashboard-btn:hover {
- transform: translateY(-2px);
- box-shadow: 0 10px 25px rgba(0, 212, 255, 0.3);
-}
-
-.logout-btn {
- position: fixed;
- top: 2rem;
- right: 12rem;
- padding: 0.75rem 1.5rem;
- background: linear-gradient(135deg, #dc3545, #c82333);
- border: none;
- border-radius: 0.75rem;
- color: white;
- font-weight: 600;
- font-size: 0.9rem;
- cursor: pointer;
- transition: all 0.2s ease;
- text-decoration: none;
- display: inline-block;
- z-index: 1000;
-}
-
-.logout-btn:hover {
- transform: translateY(-2px);
- box-shadow: 0 10px 25px rgba(220, 53, 69, 0.3);
-}
.pulse-animation {
animation: pulse 2s infinite;
@@ -650,13 +640,17 @@ body {
/* Mobile First Responsive Design */
@media (max-width: 1024px) {
.dashboard-grid {
- grid-template-columns: 1fr;
+ grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
.stats-grid {
grid-template-columns: repeat(3, 1fr);
}
+
+ .admin-panel {
+ grid-column: 1 / -1;
+ }
}
@media (max-width: 768px) {
@@ -676,8 +670,7 @@ body {
.header-section {
margin-bottom: 2rem;
- margin-top: 5rem;
- /* Account for fixed buttons */
+ margin-top: 2rem;
}
.dashboard-grid {
@@ -685,6 +678,22 @@ body {
gap: 1.5rem;
margin-bottom: 1.5rem;
}
+
+ .admin-panel {
+ grid-column: 1;
+ order: -1; /* Admin panel kommt zuerst */
+ }
+
+ .admin-buttons {
+ flex-direction: row;
+ gap: 0.75rem;
+ }
+
+ .admin-btn {
+ flex: 1;
+ padding: 0.75rem 1rem;
+ font-size: 0.8rem;
+ }
.control-panel {
padding: 1.5rem;
@@ -817,44 +826,6 @@ body {
display: none;
}
- .admin-login-btn,
- .dashboard-btn,
- .logout-btn {
- position: fixed;
- top: 1rem;
- right: 1rem;
- padding: 0.75rem 1rem;
- font-size: 0.8rem;
- min-height: 44px;
- touch-action: manipulation;
- z-index: 1000;
- }
-
- .logout-btn {
- right: 8rem;
- }
-
- /* Mobile button container for better positioning */
- .mobile-nav-buttons {
- position: fixed;
- top: 1rem;
- right: 1rem;
- display: flex;
- flex-direction: column;
- gap: 0.5rem;
- z-index: 1000;
- }
-
- .mobile-nav-buttons .admin-login-btn,
- .mobile-nav-buttons .dashboard-btn,
- .mobile-nav-buttons .logout-btn {
- position: static;
- right: auto;
- width: 120px;
- padding: 0.5rem 0.75rem;
- font-size: 0.75rem;
- text-align: center;
- }
.notification-bubble {
top: 1rem;
@@ -895,6 +866,20 @@ body {
.stats-panel {
padding: 1rem;
}
+
+ .admin-panel {
+ padding: 1rem;
+ }
+
+ .admin-buttons {
+ flex-direction: column;
+ gap: 0.5rem;
+ }
+
+ .admin-btn {
+ padding: 0.75rem;
+ font-size: 0.75rem;
+ }
.leaderboard-header {
padding: 1rem;
@@ -947,18 +932,6 @@ body {
font-size: 0.75rem;
}
- .mobile-nav-buttons {
- top: 0.5rem;
- right: 0.5rem;
- }
-
- .mobile-nav-buttons .admin-login-btn,
- .mobile-nav-buttons .dashboard-btn,
- .mobile-nav-buttons .logout-btn {
- width: 100px;
- padding: 0.4rem 0.5rem;
- font-size: 0.7rem;
- }
}
/* Landscape orientation on mobile */
@@ -972,19 +945,6 @@ body {
font-size: 2rem;
}
- .mobile-nav-buttons {
- flex-direction: row;
- gap: 0.5rem;
- }
-
- .mobile-nav-buttons .admin-login-btn,
- .mobile-nav-buttons .dashboard-btn,
- .mobile-nav-buttons .logout-btn {
- width: auto;
- min-width: 80px;
- padding: 0.5rem 0.75rem;
- font-size: 0.7rem;
- }
.time-tabs {
flex-direction: row;
diff --git a/public/dashboard.html b/public/dashboard.html
index 5c0f7f1..54b9f6f 100644
--- a/public/dashboard.html
+++ b/public/dashboard.html
@@ -296,6 +296,32 @@
+
+
+
+ Neuen Spieler mit RFID erstellen:
+
+
+
+ Vorname:
+
+
+
+
+ Nachname:
+
+
+
+
+ Geburtsdatum:
+
+
+
+
+ Spieler erstellen
+
+
+
diff --git a/public/js/admin-dashboard.js b/public/js/admin-dashboard.js
index ddc68dc..0357a12 100644
--- a/public/js/admin-dashboard.js
+++ b/public/js/admin-dashboard.js
@@ -3,14 +3,14 @@ let currentDataType = null;
let currentData = [];
// Beim Laden der Seite
-document.addEventListener('DOMContentLoaded', function() {
+document.addEventListener('DOMContentLoaded', function () {
checkAuth();
loadStatistics();
-
+
// Add cookie settings button functionality
const cookieSettingsBtn = document.getElementById('cookie-settings-footer');
if (cookieSettingsBtn) {
- cookieSettingsBtn.addEventListener('click', function() {
+ cookieSettingsBtn.addEventListener('click', function () {
if (window.cookieConsent) {
window.cookieConsent.resetConsent();
}
@@ -23,9 +23,9 @@ document.addEventListener('DOMContentLoaded', function() {
function setupEventListeners() {
// Logout Button
document.getElementById('logoutBtn').addEventListener('click', logout);
-
+
// Generator Button
- document.getElementById('generatorBtn').addEventListener('click', function() {
+ document.getElementById('generatorBtn').addEventListener('click', function () {
window.location.href = '/generator';
});
@@ -48,7 +48,7 @@ async function checkAuth() {
try {
const response = await fetch('/api/v1/web/check-session');
const result = await response.json();
-
+
if (!result.success) {
window.location.href = '/adminlogin.html';
return;
@@ -56,7 +56,7 @@ async function checkAuth() {
currentUser = result.user;
document.getElementById('username').textContent = currentUser.username;
-
+
const accessBadge = document.getElementById('accessBadge');
accessBadge.textContent = `Level ${currentUser.access_level}`;
accessBadge.className = `access-badge level-${currentUser.access_level}`;
@@ -86,7 +86,7 @@ async function loadStatistics() {
try {
const response = await fetch('/api/v1/admin/stats');
const stats = await response.json();
-
+
if (stats.success) {
document.getElementById('totalPlayers').textContent = stats.data.players || 0;
document.getElementById('totalRuns').textContent = stats.data.runs || 0;
@@ -102,16 +102,16 @@ async function loadPageStatistics() {
try {
const response = await fetch('/api/v1/admin/page-stats');
const result = await response.json();
-
+
if (result.success) {
const data = result.data;
-
+
// Display page view statistics
displayPageStats('todayStats', data.today);
displayPageStats('weekStats', data.week);
displayPageStats('monthStats', data.month);
displayPageStats('totalStats', data.total);
-
+
// Display link statistics
if (data.linkStats) {
document.getElementById('totalPlayersCount').textContent = data.linkStats.total_players || 0;
@@ -130,16 +130,16 @@ async function loadPageStatistics() {
function displayPageStats(containerId, stats) {
const container = document.getElementById(containerId);
-
+
if (!stats || stats.length === 0) {
container.innerHTML = '
0
';
return;
}
-
+
// Find main page visits
const mainPageStat = stats.find(stat => stat.page === 'main_page_visit');
const count = mainPageStat ? mainPageStat.count : 0;
-
+
container.innerHTML = `
${count}
`;
}
@@ -154,7 +154,7 @@ function getPageDisplayName(page) {
'license_generator': '🔧 Lizenzgenerator',
'reset_password': '🔑 Passwort Reset'
};
-
+
return pageNames[page] || page;
}
@@ -204,7 +204,7 @@ async function loadPlayers() {
try {
const response = await fetch('/api/v1/admin/players');
const result = await response.json();
-
+
if (result.success) {
currentData = result.data;
displayPlayersTable(result.data);
@@ -221,7 +221,7 @@ async function loadRuns() {
try {
const response = await fetch('/api/v1/admin/runs');
const result = await response.json();
-
+
if (result.success) {
currentData = result.data;
displayRunsTable(result.data);
@@ -238,7 +238,7 @@ async function loadLocations() {
try {
const response = await fetch('/api/v1/admin/locations');
const result = await response.json();
-
+
if (result.success) {
currentData = result.data;
displayLocationsTable(result.data);
@@ -255,7 +255,7 @@ async function loadAdminUsers() {
try {
const response = await fetch('/api/v1/admin/adminusers');
const result = await response.json();
-
+
if (result.success) {
currentData = result.data;
displayAdminUsersTable(result.data);
@@ -271,7 +271,7 @@ async function loadAdminUsers() {
function displayPlayersTable(players) {
let html = '
';
html += 'ID Name RFID UID Supabase User Registriert Aktionen ';
-
+
players.forEach(player => {
html += `
${player.id}
@@ -285,7 +285,7 @@ function displayPlayersTable(players) {
`;
});
-
+
html += '
';
document.getElementById('dataContent').innerHTML = html;
}
@@ -293,7 +293,7 @@ function displayPlayersTable(players) {
function displayRunsTable(runs) {
let html = '
';
html += 'ID Spieler Standort Zeit Datum Aktionen ';
-
+
runs.forEach(run => {
// Use the time_seconds value from the backend
const timeInSeconds = parseFloat(run.time_seconds) || 0;
@@ -310,7 +310,7 @@ function displayRunsTable(runs) {
`;
});
-
+
html += '
';
document.getElementById('dataContent').innerHTML = html;
}
@@ -318,7 +318,7 @@ function displayRunsTable(runs) {
function displayLocationsTable(locations) {
let html = '
';
html += 'ID Name Latitude Longitude Erstellt Aktionen ';
-
+
locations.forEach(location => {
html += `
${location.id}
@@ -332,7 +332,7 @@ function displayLocationsTable(locations) {
`;
});
-
+
html += '
';
document.getElementById('dataContent').innerHTML = html;
}
@@ -340,7 +340,7 @@ function displayLocationsTable(locations) {
function displayAdminUsersTable(users) {
let html = '
';
html += 'ID Benutzername Access Level Aktiv Letzter Login Aktionen ';
-
+
users.forEach(user => {
const isCurrentUser = user.id === currentUser.id;
html += `
@@ -354,7 +354,7 @@ function displayAdminUsersTable(users) {
`;
});
-
+
html += '
';
document.getElementById('dataContent').innerHTML = html;
}
@@ -375,12 +375,12 @@ function filterData() {
if (!currentData) return;
let filteredData = currentData.filter(item => {
- return Object.values(item).some(value =>
+ return Object.values(item).some(value =>
value && value.toString().toLowerCase().includes(searchTerm)
);
});
- switch(currentDataType) {
+ switch (currentDataType) {
case 'players':
displayPlayersTable(filteredData);
break;
@@ -397,7 +397,7 @@ function filterData() {
}
function refreshData() {
- switch(currentDataType) {
+ switch (currentDataType) {
case 'players':
loadPlayers();
break;
@@ -420,9 +420,9 @@ function showAddModal() {
const modal = document.getElementById('addModal');
const modalTitle = document.getElementById('modalTitle');
const formFields = document.getElementById('formFields');
-
+
// Modal-Titel basierend auf aktuellem Datentyp setzen
- switch(currentDataType) {
+ switch (currentDataType) {
case 'players':
modalTitle.textContent = 'Neuen Spieler hinzufügen';
formFields.innerHTML = `
@@ -440,7 +440,7 @@ function showAddModal() {
`;
break;
-
+
case 'locations':
modalTitle.textContent = 'Neuen Standort hinzufügen';
formFields.innerHTML = `
@@ -462,7 +462,7 @@ function showAddModal() {
`;
break;
-
+
case 'adminusers':
modalTitle.textContent = 'Neuen Admin-Benutzer hinzufügen';
formFields.innerHTML = `
@@ -483,7 +483,7 @@ function showAddModal() {
`;
break;
-
+
case 'runs':
modalTitle.textContent = 'Neuen Lauf hinzufügen';
formFields.innerHTML = `
@@ -501,12 +501,12 @@ function showAddModal() {
`;
break;
-
+
default:
modalTitle.textContent = 'Element hinzufügen';
formFields.innerHTML = 'Keine Felder für diesen Datentyp verfügbar.
';
}
-
+
modal.style.display = 'block';
}
@@ -521,21 +521,21 @@ function closeModal() {
async function handleAddSubmit(e) {
e.preventDefault();
-
+
const formData = new FormData(e.target);
const data = Object.fromEntries(formData.entries());
-
+
// Prüfen ob es sich um eine Edit-Operation handelt
const isEdit = data.edit_id;
const editId = data.edit_id;
delete data.edit_id; // edit_id aus den Daten entfernen
-
+
try {
let endpoint = '';
let successMessage = '';
let method = 'POST';
-
- switch(currentDataType) {
+
+ switch (currentDataType) {
case 'players':
endpoint = isEdit ? `/api/v1/admin/players/${editId}` : '/api/v1/admin/players';
successMessage = isEdit ? 'Spieler erfolgreich aktualisiert' : 'Spieler erfolgreich hinzugefügt';
@@ -560,7 +560,7 @@ async function handleAddSubmit(e) {
showError('Unbekannter Datentyp');
return;
}
-
+
const response = await fetch(endpoint, {
method: method,
headers: {
@@ -568,9 +568,9 @@ async function handleAddSubmit(e) {
},
body: JSON.stringify(data)
});
-
+
const result = await response.json();
-
+
if (result.success) {
showSuccess(successMessage);
closeModal();
@@ -578,7 +578,7 @@ async function handleAddSubmit(e) {
} else {
showError(result.message || `Fehler beim ${isEdit ? 'Aktualisieren' : 'Hinzufügen'}`);
}
-
+
} catch (error) {
console.error('Submit failed:', error);
showError(`Fehler beim ${isEdit ? 'Aktualisieren' : 'Hinzufügen'}`);
@@ -589,11 +589,11 @@ async function handleAddSubmit(e) {
async function editPlayer(id) {
const player = currentData.find(p => p.id == id);
if (!player) return;
-
+
const modal = document.getElementById('addModal');
const modalTitle = document.getElementById('modalTitle');
const formFields = document.getElementById('formFields');
-
+
modalTitle.textContent = 'Spieler bearbeiten';
formFields.innerHTML = `
@@ -610,18 +610,18 @@ async function editPlayer(id) {
`;
-
+
modal.style.display = 'block';
}
async function editLocation(id) {
const location = currentData.find(l => l.id == id);
if (!location) return;
-
+
const modal = document.getElementById('addModal');
const modalTitle = document.getElementById('modalTitle');
const formFields = document.getElementById('formFields');
-
+
modalTitle.textContent = 'Standort bearbeiten';
formFields.innerHTML = `
@@ -642,7 +642,7 @@ async function editLocation(id) {
`;
-
+
modal.style.display = 'block';
}
@@ -651,24 +651,24 @@ async function editRun(id) {
// Lade die spezifischen Lauf-Daten direkt von der API
const response = await fetch(`/api/admin-runs/${id}`);
const result = await response.json();
-
+
if (!result.success) {
showError('Fehler beim Laden der Lauf-Daten: ' + (result.message || 'Unbekannter Fehler'));
return;
}
-
+
const run = result.data;
if (!run) {
showError('Lauf nicht gefunden');
return;
}
-
+
console.log('Editing run:', run); // Debug log
-
+
const modal = document.getElementById('addModal');
const modalTitle = document.getElementById('modalTitle');
const formFields = document.getElementById('formFields');
-
+
modalTitle.textContent = 'Lauf bearbeiten';
formFields.innerHTML = `
@@ -685,9 +685,9 @@ async function editRun(id) {
`;
-
+
modal.style.display = 'block';
-
+
} catch (error) {
console.error('Error loading run data:', error);
showError('Fehler beim Laden der Lauf-Daten');
@@ -699,7 +699,7 @@ async function deletePlayer(id) {
try {
const response = await fetch(`/api/v1/admin/players/${id}`, { method: 'DELETE' });
const result = await response.json();
-
+
if (result.success) {
showSuccess('Spieler erfolgreich gelöscht');
loadPlayers();
@@ -718,7 +718,7 @@ async function deleteRun(id) {
try {
const response = await fetch(`/api/v1/admin/runs/${id}`, { method: 'DELETE' });
const result = await response.json();
-
+
if (result.success) {
showSuccess('Lauf erfolgreich gelöscht');
loadRuns();
@@ -737,7 +737,7 @@ async function deleteLocation(id) {
try {
const response = await fetch(`/api/v1/admin/locations/${id}`, { method: 'DELETE' });
const result = await response.json();
-
+
if (result.success) {
showSuccess('Standort erfolgreich gelöscht');
loadLocations();
@@ -756,7 +756,7 @@ async function deleteAdminUser(id) {
try {
const response = await fetch(`/api/v1/admin/adminusers/${id}`, { method: 'DELETE' });
const result = await response.json();
-
+
if (result.success) {
showSuccess('Admin-Benutzer erfolgreich gelöscht');
loadAdminUsers();
@@ -774,12 +774,12 @@ function confirmDelete(message) {
return new Promise((resolve) => {
document.getElementById('confirmMessage').textContent = message;
document.getElementById('confirmModal').style.display = 'block';
-
+
document.getElementById('confirmYes').onclick = () => {
closeModal();
resolve(true);
};
-
+
document.getElementById('confirmNo').onclick = () => {
closeModal();
resolve(false);
@@ -806,3 +806,493 @@ function showError(message) {
messageDiv.style.display = 'none';
}, 3000);
}
+
+// ============================================================================
+// BLACKLIST MANAGEMENT FUNCTIONS
+// ============================================================================
+
+async function showBlacklistManagement() {
+ currentDataType = 'blacklist';
+ document.getElementById('blacklistModal').style.display = 'block';
+ await loadBlacklist();
+ await loadBlacklistStats();
+}
+
+async function loadBlacklist() {
+ try {
+ const response = await fetch('/api/v1/admin/blacklist');
+ const result = await response.json();
+
+ if (result.success) {
+ displayBlacklist(result.data);
+ } else {
+ showBlacklistMessage('Fehler beim Laden der Blacklist: ' + result.message, 'error');
+ }
+ } catch (error) {
+ console.error('Error loading blacklist:', error);
+ showBlacklistMessage('Fehler beim Laden der Blacklist', 'error');
+ }
+}
+
+function displayBlacklist(blacklist) {
+ const content = document.getElementById('blacklistContent');
+
+ let html = '📋 Blacklist-Inhalte ';
+
+ Object.entries(blacklist).forEach(([category, terms]) => {
+ const categoryName = getCategoryDisplayName(category);
+ const categoryIcon = getCategoryIcon(category);
+
+ html += `
+
+
+ ${categoryIcon} ${categoryName}
+
+ ${terms.length} Einträge
+
+
+
+ `;
+
+ if (terms.length === 0) {
+ html += `
+
+ Keine Einträge in dieser Kategorie
+
+ `;
+ } else {
+ terms.forEach(term => {
+ html += `
+
+ ${term}
+
+ ×
+
+
+ `;
+ });
+ }
+
+ html += `
+
+
+ `;
+ });
+
+ content.innerHTML = html;
+}
+
+// Kategorie-Icons hinzufügen
+function getCategoryIcon(category) {
+ const icons = {
+ historical: '🏛️',
+ offensive: '⚠️',
+ titles: '👑',
+ brands: '🏷️',
+ inappropriate: '🚫'
+ };
+ return icons[category] || '📝';
+}
+
+function getCategoryDisplayName(category) {
+ const names = {
+ historical: 'Historisch belastet',
+ offensive: 'Beleidigend/anstößig',
+ titles: 'Titel/Berufsbezeichnung',
+ brands: 'Markenname',
+ inappropriate: 'Unpassend'
+ };
+ return names[category] || category;
+}
+
+async function testNameAgainstBlacklist() {
+ const firstname = document.getElementById('testFirstname').value.trim();
+ const lastname = document.getElementById('testLastname').value.trim();
+ const resultDiv = document.getElementById('testResult');
+
+ if (!firstname || !lastname) {
+ showBlacklistMessage('Bitte gib Vorname und Nachname ein', 'error');
+ return;
+ }
+
+ try {
+ const response = await fetch('/api/v1/admin/blacklist/test', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({ firstname, lastname })
+ });
+
+ const result = await response.json();
+
+ if (result.success) {
+ const testResult = result.data;
+
+ if (testResult.isBlocked) {
+ let matchTypeText = '';
+ let similarityText = '';
+
+ if (testResult.matchType === 'exact') {
+ matchTypeText = 'Exakte Übereinstimmung';
+ } else if (testResult.matchType === 'similar') {
+ matchTypeText = 'Ähnliche Übereinstimmung (Levenshtein)';
+ const similarityPercent = Math.round((1 - testResult.similarity) * 100);
+ similarityText = ` Ähnlichkeit: ${similarityPercent}% (Distanz: ${testResult.levenshteinDistance})`;
+ }
+
+ resultDiv.innerHTML = `
+
+ ❌ Name ist blockiert
+ Grund: ${testResult.reason}
+ Kategorie: ${getCategoryDisplayName(testResult.category)}
+ Gefundener Begriff: "${testResult.matchedTerm}"
+ Typ: ${matchTypeText}${similarityText}
+
+ `;
+ } else {
+ resultDiv.innerHTML = `
+
+ ✅ Name ist erlaubt
+ Der Name "${firstname} ${lastname}" ist nicht in der Blacklist.
+
+ `;
+ }
+
+ resultDiv.style.display = 'block';
+ } else {
+ showBlacklistMessage('Fehler beim Testen: ' + result.message, 'error');
+ }
+ } catch (error) {
+ console.error('Error testing name:', error);
+ showBlacklistMessage('Fehler beim Testen des Namens', 'error');
+ }
+}
+
+async function addToBlacklist() {
+ const term = document.getElementById('newTerm').value.trim();
+ const category = document.getElementById('newCategory').value;
+
+ if (!term) {
+ showBlacklistMessage('Bitte gib einen Begriff ein', 'error');
+ return;
+ }
+
+ try {
+ const response = await fetch('/api/v1/admin/blacklist', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({ term, category })
+ });
+
+ const result = await response.json();
+
+ if (result.success) {
+ showBlacklistMessage('Begriff erfolgreich hinzugefügt', 'success');
+ document.getElementById('newTerm').value = '';
+ await loadBlacklist();
+ await loadBlacklistStats();
+ } else {
+ showBlacklistMessage('Fehler beim Hinzufügen: ' + result.message, 'error');
+ }
+ } catch (error) {
+ console.error('Error adding to blacklist:', error);
+ showBlacklistMessage('Fehler beim Hinzufügen zur Blacklist', 'error');
+ }
+}
+
+async function removeFromBlacklist(term, category) {
+ if (!confirm(`Möchtest du "${term}" aus der Kategorie "${getCategoryDisplayName(category)}" entfernen?`)) {
+ return;
+ }
+
+ try {
+ const response = await fetch('/api/v1/admin/blacklist', {
+ method: 'DELETE',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({ term, category })
+ });
+
+ const result = await response.json();
+
+ if (result.success) {
+ showBlacklistMessage('Begriff erfolgreich entfernt', 'success');
+ await loadBlacklist();
+ await loadBlacklistStats();
+ } else {
+ showBlacklistMessage('Fehler beim Entfernen: ' + result.message, 'error');
+ }
+ } catch (error) {
+ console.error('Error removing from blacklist:', error);
+ showBlacklistMessage('Fehler beim Entfernen aus der Blacklist', 'error');
+ }
+}
+
+function showBlacklistMessage(message, type) {
+ const messageDiv = document.getElementById('blacklistMessage');
+ messageDiv.textContent = message;
+ messageDiv.className = `message ${type}`;
+ messageDiv.style.display = 'block';
+ setTimeout(() => {
+ messageDiv.style.display = 'none';
+ }, 3000);
+}
+
+// Blacklist-Statistiken laden
+async function loadBlacklistStats() {
+ try {
+ const response = await fetch('/api/v1/admin/blacklist/stats');
+ const result = await response.json();
+
+ if (result.success) {
+ displayBlacklistStats(result.data);
+ } else {
+ console.error('Error loading blacklist stats:', result.message);
+ }
+ } catch (error) {
+ console.error('Error loading blacklist stats:', error);
+ }
+}
+
+// Blacklist-Statistiken anzeigen
+function displayBlacklistStats(stats) {
+ // Erstelle oder aktualisiere Statistiken-Bereich
+ let statsDiv = document.getElementById('blacklistStats');
+ if (!statsDiv) {
+ // Erstelle Statistiken-Bereich am Anfang des Modals
+ const modalContent = document.querySelector('#blacklistModal .modal-content');
+ const firstChild = modalContent.firstChild;
+ statsDiv = document.createElement('div');
+ statsDiv.id = 'blacklistStats';
+ statsDiv.style.cssText = 'border: 1px solid #4ade80; padding: 1rem; margin-bottom: 1rem; border-radius: 5px; background: rgba(34, 197, 94, 0.1);';
+ modalContent.insertBefore(statsDiv, firstChild);
+ }
+
+ let html = `
+ 📊 Blacklist-Statistiken
+
+ `;
+
+ stats.categories.forEach(category => {
+ const categoryName = getCategoryDisplayName(category.category);
+ const lastAdded = new Date(category.last_added).toLocaleDateString('de-DE');
+ html += `
+
+
${category.count}
+
${categoryName}
+
Letzte: ${lastAdded}
+
+ `;
+ });
+
+ html += `
+
+
+ Gesamt: ${stats.total} Begriffe
+
+ `;
+
+ statsDiv.innerHTML = html;
+}
+
+// LLM-Management
+function showLLMManagement() {
+ document.getElementById('llmModal').style.display = 'block';
+ loadLLMStatus();
+}
+
+function closeLLMModal() {
+ document.getElementById('llmModal').style.display = 'none';
+}
+
+// LLM-Status laden
+async function loadLLMStatus() {
+ try {
+ const response = await fetch('/api/v1/admin/llm/status', {
+ headers: {
+ 'Authorization': `Bearer ${localStorage.getItem('adminToken')}`
+ }
+ });
+
+ const result = await response.json();
+ const statusContent = document.getElementById('llmStatusContent');
+
+ if (result.success) {
+ const status = result.data;
+ if (status.connected) {
+ statusContent.innerHTML = `
+
+ ✅ KI-Moderator verbunden
+ Modell: ${status.model}
+ URL: ${status.baseUrl}
+
+ `;
+ } else {
+ statusContent.innerHTML = `
+
+ ❌ KI-Moderator nicht verfügbar
+ Modell: ${status.model}
+ URL: ${status.baseUrl}
+ Fehler: ${status.error}
+
+ `;
+ }
+ } else {
+ statusContent.innerHTML = `
+
+ ❌ Fehler beim Laden des Status
+ ${result.message}
+
+ `;
+ }
+ } catch (error) {
+ console.error('Error loading LLM status:', error);
+ document.getElementById('llmStatusContent').innerHTML = `
+
+ ❌ Fehler beim Laden des Status
+ ${error.message}
+
+ `;
+ }
+}
+
+// Name mit LLM testen
+async function testNameWithLLM() {
+ const firstname = document.getElementById('llmFirstname').value.trim();
+ const lastname = document.getElementById('llmLastname').value.trim();
+ const context = document.getElementById('llmContext').value.trim();
+
+ if (!firstname || !lastname) {
+ showLLMMessage('Bitte gib Vorname und Nachname ein', 'error');
+ return;
+ }
+
+ try {
+ // LLM-Test
+ const llmResponse = await fetch('/api/v1/admin/llm/test', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${localStorage.getItem('adminToken')}`
+ },
+ body: JSON.stringify({ firstname, lastname, context })
+ });
+
+ const llmResult = await llmResponse.json();
+
+ // Blacklist-Test zum Vergleich
+ const blacklistResponse = await fetch('/api/v1/admin/blacklist/test', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${localStorage.getItem('adminToken')}`
+ },
+ body: JSON.stringify({ firstname, lastname })
+ });
+
+ const blacklistResult = await blacklistResponse.json();
+
+ // Ergebnisse anzeigen
+ displayLLMResults(llmResult, blacklistResult);
+
+ } catch (error) {
+ console.error('Error testing name with LLM:', error);
+ showLLMMessage('Fehler beim Testen: ' + error.message, 'error');
+ }
+}
+
+// LLM-Ergebnisse anzeigen
+function displayLLMResults(llmResult, blacklistResult) {
+ const resultDiv = document.getElementById('llmResult');
+ const resultContent = document.getElementById('llmResultContent');
+ const comparisonDiv = document.getElementById('llmComparison');
+ const comparisonContent = document.getElementById('llmComparisonContent');
+
+ if (llmResult.success) {
+ const llm = llmResult.data;
+
+ let llmStatus = '';
+ if (llm.isBlocked) {
+ llmStatus = `
+
+ ❌ Name blockiert
+ Grund: ${llm.reason}
+ Konfidenz: ${Math.round(llm.confidence * 100)}%
+ LLM-Antwort: "${llm.llmResponse}"
+ Typ: ${llm.matchType}
+
+ `;
+ } else {
+ llmStatus = `
+
+ ✅ Name erlaubt
+ Grund: ${llm.reason}
+ Konfidenz: ${Math.round(llm.confidence * 100)}%
+ LLM-Antwort: "${llm.llmResponse}"
+ Typ: ${llm.matchType}
+
+ `;
+ }
+
+ resultContent.innerHTML = llmStatus;
+ resultDiv.style.display = 'block';
+
+ // Vergleich mit Blacklist
+ if (blacklistResult.success) {
+ const blacklist = blacklistResult.data;
+ let comparisonStatus = '';
+
+ if (blacklist.combined.isBlocked) {
+ comparisonStatus = `
+
+ ⚠️ Name blockiert (${blacklist.combined.source})
+ Grund: ${blacklist.combined.reason}
+
+ `;
+ } else {
+ comparisonStatus = `
+
+ ✅ Name erlaubt
+ Sowohl KI als auch Blacklist erlauben den Namen
+
+ `;
+ }
+
+ comparisonContent.innerHTML = comparisonStatus;
+ comparisonDiv.style.display = 'block';
+ }
+
+ } else {
+ resultContent.innerHTML = `
+
+ ❌ Fehler beim Testen
+ ${llmResult.message}
+
+ `;
+ resultDiv.style.display = 'block';
+ }
+}
+
+// LLM-Nachricht anzeigen
+function showLLMMessage(message, type) {
+ const resultDiv = document.getElementById('llmResult');
+ const resultContent = document.getElementById('llmResultContent');
+
+ const color = type === 'error' ? '#c62828' : '#2e7d32';
+ const bgColor = type === 'error' ? '#ffebee' : '#e8f5e8';
+
+ resultContent.innerHTML = `
+
+ ${type === 'error' ? '❌' : '✅'} ${message}
+
+ `;
+ resultDiv.style.display = 'block';
+}
diff --git a/public/js/dashboard.js b/public/js/dashboard.js
index 686ca73..446e290 100644
--- a/public/js/dashboard.js
+++ b/public/js/dashboard.js
@@ -242,6 +242,53 @@ async function showRFIDSettings() {
openModal('rfidModal');
// Reset scanner state
stopQRScanner();
+
+ // Check if user is already linked and hide/show create player section
+ await updateCreatePlayerSectionVisibility();
+}
+
+// Update visibility of create player section based on link status
+async function updateCreatePlayerSectionVisibility() {
+ const createPlayerSection = document.getElementById('createPlayerSection');
+
+ if (!currentUser) {
+ // No user logged in - hide create player section
+ if (createPlayerSection) {
+ createPlayerSection.style.display = 'none';
+ }
+ return;
+ }
+
+ try {
+ // Check if user has a linked player
+ const response = await fetch(`/api/v1/public/user-player/${currentUser.id}?t=${Date.now()}`);
+
+ if (response.ok) {
+ const result = await response.json();
+ if (result.success && result.data && result.data.id) {
+ // User is already linked - hide create player section
+ if (createPlayerSection) {
+ createPlayerSection.style.display = 'none';
+ }
+ } else {
+ // User is not linked - show create player section
+ if (createPlayerSection) {
+ createPlayerSection.style.display = 'block';
+ }
+ }
+ } else {
+ // Error checking link status - show create player section as fallback
+ if (createPlayerSection) {
+ createPlayerSection.style.display = 'block';
+ }
+ }
+ } catch (error) {
+ console.error('Error checking link status:', error);
+ // Error occurred - show create player section as fallback
+ if (createPlayerSection) {
+ createPlayerSection.style.display = 'block';
+ }
+ }
}
// Check link status and load times
@@ -448,6 +495,117 @@ async function linkManualRfid() {
}
}
+// Create new RFID player record
+async function createRfidPlayerRecord() {
+ const rawUid = document.getElementById('manualRfidInput').value.trim();
+ const firstname = document.getElementById('playerFirstname').value.trim();
+ const lastname = document.getElementById('playerLastname').value.trim();
+ const birthdate = document.getElementById('playerBirthdate').value;
+
+ // Validation
+ if (!rawUid) {
+ const inputErrorMsg = currentLanguage === 'de' ?
+ 'Bitte gib eine RFID UID ein' :
+ 'Please enter a RFID UID';
+ showMessage('rfidMessage', inputErrorMsg, 'error');
+ return;
+ }
+
+ if (!firstname) {
+ const inputErrorMsg = currentLanguage === 'de' ?
+ 'Bitte gib einen Vornamen ein' :
+ 'Please enter a first name';
+ showMessage('rfidMessage', inputErrorMsg, 'error');
+ return;
+ }
+
+ if (!lastname) {
+ const inputErrorMsg = currentLanguage === 'de' ?
+ 'Bitte gib einen Nachnamen ein' :
+ 'Please enter a last name';
+ showMessage('rfidMessage', inputErrorMsg, 'error');
+ return;
+ }
+
+ if (!birthdate) {
+ const inputErrorMsg = currentLanguage === 'de' ?
+ 'Bitte gib ein Geburtsdatum ein' :
+ 'Please enter a birth date';
+ showMessage('rfidMessage', inputErrorMsg, 'error');
+ return;
+ }
+
+ try {
+ // Format the UID to match database format
+ const formattedUid = formatRfidUid(rawUid);
+
+ const formattedMsg = currentLanguage === 'de' ?
+ `Erstelle Spieler: ${firstname} ${lastname} (${formattedUid})` :
+ `Creating player: ${firstname} ${lastname} (${formattedUid})`;
+ showMessage('rfidMessage', formattedMsg, 'info');
+
+ // Create player record
+ const response = await fetch('/api/v1/public/players/create-with-rfid', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ rfiduid: formattedUid,
+ firstname: firstname,
+ lastname: lastname,
+ birthdate: birthdate,
+ supabase_user_id: currentUser?.id || null
+ })
+ });
+
+ const result = await response.json();
+
+ if (result.success) {
+ const successMsg = currentLanguage === 'de' ?
+ `Spieler erfolgreich erstellt: ${result.data.firstname} ${result.data.lastname}` :
+ `Player successfully created: ${result.data.firstname} ${result.data.lastname}`;
+ showMessage('rfidMessage', successMsg, 'success');
+
+ // Clear form
+ document.getElementById('manualRfidInput').value = '';
+ document.getElementById('playerFirstname').value = '';
+ document.getElementById('playerLastname').value = '';
+ document.getElementById('playerBirthdate').value = '';
+
+ // Hide create player section since user is now linked
+ const createPlayerSection = document.getElementById('createPlayerSection');
+ if (createPlayerSection) {
+ createPlayerSection.style.display = 'none';
+ }
+
+ // Refresh times if user is linked
+ if (currentUser) {
+ await checkLinkStatusAndLoadTimes();
+ }
+ } else {
+ let errorMsg = result.message;
+
+ // Erweitere Fehlermeldung um Levenshtein-Details falls vorhanden
+ if (result.details) {
+ if (result.details.matchType === 'similar') {
+ const similarityPercent = Math.round((1 - result.details.similarity) * 100);
+ errorMsg += `\n\nÄhnlichkeit: ${similarityPercent}% (Distanz: ${result.details.levenshteinDistance})`;
+ }
+ }
+
+ showMessage('rfidMessage', errorMsg, 'error');
+ }
+
+ } catch (error) {
+ console.error('Error creating RFID player record:', error);
+ const errorMsg = currentLanguage === 'de' ?
+ `Fehler beim Erstellen: ${error.message}` :
+ `Error creating: ${error.message}`;
+ showMessage('rfidMessage', errorMsg, 'error');
+ }
+}
+
// Link user by RFID UID (core function)
async function linkUserByRfidUid(rfidUid) {
if (!currentUser) {
@@ -549,6 +707,51 @@ function showTimesNotLinked() {
}
}
+// Show RFID linked info with help button
+function showRFIDLinkedInfo(playerData) {
+ // Find the RFID card and update it
+ const rfidCard = document.querySelector('.card[onclick="showRFIDSettings()"]');
+ if (rfidCard) {
+ const isGerman = currentLanguage === 'de';
+
+ rfidCard.innerHTML = `
+ ${isGerman ? '🏷️ RFID Verknüpft' : '🏷️ RFID Linked'}
+
+
+ ✅
+
+ ${isGerman ? 'Erfolgreich verknüpft' : 'Successfully linked'}
+
+
+
+
${isGerman ? 'Spieler:' : 'Player:'} ${playerData.firstname} ${playerData.lastname}
+
RFID: ${playerData.rfiduid}
+
+
+
+ ${isGerman ? '❓ Hilfe anfordern' : '❓ Request Help'}
+
+ `;
+
+ // Remove the onclick from the card since we don't want it to open the modal
+ rfidCard.removeAttribute('onclick');
+ rfidCard.style.cursor = 'default';
+ }
+}
+
+// Request RFID help
+function requestRFIDHelp() {
+ const isGerman = currentLanguage === 'de';
+ const message = isGerman ?
+ 'Hilfe-Anfrage gesendet! Ein Administrator wird sich bei dir melden, um bei der RFID-Änderung zu helfen.' :
+ 'Help request sent! An administrator will contact you to help with the RFID change.';
+
+ alert(message);
+
+ // Here you could send a notification to admins or log the help request
+ console.log('RFID help requested by user:', currentUser?.email);
+}
+
// Show loading state
function showTimesLoading() {
document.getElementById('timesLoading').style.display = 'block';
@@ -584,6 +787,9 @@ async function loadUserTimesSection(playerData) {
// Display times
displayUserTimes(times);
+ // Show RFID info and help button
+ showRFIDLinkedInfo(playerData);
+
// Show the times display
document.getElementById('timesLoading').style.display = 'none';
document.getElementById('timesNotLinked').style.display = 'none';
diff --git a/routes/api.js b/routes/api.js
index 22c1bd2..0a3a7c6 100644
--- a/routes/api.js
+++ b/routes/api.js
@@ -820,21 +820,6 @@ router.post('/v1/private/create-time', requireApiKey, async (req, res) => {
}
try {
- // Spieler anhand der RFID UID finden
- const playerResult = await pool.query(
- 'SELECT id, firstname, lastname FROM players WHERE rfiduid = $1',
- [rfiduid]
- );
-
- if (playerResult.rows.length === 0) {
- return res.status(404).json({
- success: false,
- message: 'Spieler mit dieser RFID UID nicht gefunden'
- });
- }
-
- const player = playerResult.rows[0];
- const player_id = player.id;
// Location anhand des Namens finden (inklusive time_threshold)
const locationResult = await pool.query(
@@ -881,25 +866,14 @@ router.post('/v1/private/create-time', requireApiKey, async (req, res) => {
}
- // Zeit in Datenbank einfügen
+ // Zeit mit RFIDUID in die Tabelle einfügen (player_id wird automatisch durch Trigger gesetzt)
const result = await pool.query(
- `INSERT INTO times (player_id, location_id, recorded_time, created_at)
+ `INSERT INTO times (rfiduid, location_id, recorded_time, created_at)
VALUES ($1, $2, $3, $4)
- RETURNING id, created_at`,
- [player_id, location_id, recorded_time, new Date()]
+ RETURNING id, player_id, created_at`,
+ [rfiduid, location_id, recorded_time, new Date()]
);
- // Achievement-Überprüfung nach Zeit-Eingabe
- try {
- await pool.query('SELECT check_immediate_achievements($1)', [player_id]);
- console.log(`✅ Achievement-Check für Spieler ${player_id} ausgeführt`);
- } catch (achievementError) {
- console.error('Fehler bei Achievement-Check:', achievementError);
- // Achievement-Fehler sollen die Zeit-Eingabe nicht blockieren
- }
-
-
-
// WebSocket-Event senden für Live-Updates
const io = req.app.get('io');
if (io) {
@@ -917,14 +891,13 @@ router.post('/v1/private/create-time', requireApiKey, async (req, res) => {
// Sende WebSocket-Event an alle verbundenen Clients
io.emit('newTime', {
id: result.rows[0].id,
- player_name: `${player.firstname} ${player.lastname}`,
+ player_id: result.rows[0].player_id,
+ rfiduid: rfiduid,
location_name: location.name,
recorded_time: recorded_time,
rank: rank,
created_at: result.rows[0].created_at
});
-
-
}
res.json({
@@ -932,8 +905,7 @@ router.post('/v1/private/create-time', requireApiKey, async (req, res) => {
message: 'Zeit erfolgreich gespeichert',
data: {
id: result.rows[0].id,
- player_id: player_id,
- player_name: `${player.firstname} ${player.lastname}`,
+ player_id: result.rows[0].player_id,
rfiduid: rfiduid,
location_id: location_id,
location_name: location.name,
@@ -1028,6 +1000,130 @@ router.post('/v1/private/users/find', requireApiKey, async (req, res) => {
// RFID LINKING & USER MANAGEMENT ENDPOINTS (No API Key required for dashboard)
// ============================================================================
+// Import blacklist module
+const { checkNameAgainstBlacklist, addToBlacklist, removeFromBlacklist, getBlacklist } = require('../config/blacklist-db');
+const { checkNameWithLLM, checkNameWithContext, testLLMConnection } = require('../config/llm-blacklist');
+
+// Create new player with RFID and blacklist validation (no auth required for dashboard)
+router.post('/v1/public/players/create-with-rfid', async (req, res) => {
+ const { rfiduid, firstname, lastname, birthdate, supabase_user_id } = req.body;
+
+ // Validierung
+ if (!rfiduid || !firstname || !lastname || !birthdate) {
+ return res.status(400).json({
+ success: false,
+ message: 'RFID UID, Vorname, Nachname und Geburtsdatum sind erforderlich'
+ });
+ }
+
+ try {
+ // LLM-basierte Blacklist-Prüfung
+ const llmCheck = await checkNameWithLLM(firstname, lastname);
+ if (llmCheck.isBlocked) {
+ return res.status(400).json({
+ success: false,
+ message: `Name nicht zulässig: ${llmCheck.reason}`,
+ details: llmCheck
+ });
+ }
+
+ // Fallback: Traditionelle Blacklist-Prüfung (optional)
+ const blacklistCheck = await checkNameAgainstBlacklist(firstname, lastname);
+ if (blacklistCheck.isBlocked) {
+ return res.status(400).json({
+ success: false,
+ message: `Name nicht zulässig: ${blacklistCheck.reason}`,
+ details: {
+ reason: blacklistCheck.reason,
+ category: blacklistCheck.category,
+ matchedTerm: blacklistCheck.matchedTerm
+ }
+ });
+ }
+
+ // Prüfen ob RFID UID bereits existiert
+ const existingRfid = await pool.query(
+ 'SELECT id, firstname, lastname FROM players WHERE rfiduid = $1',
+ [rfiduid]
+ );
+
+ if (existingRfid.rows.length > 0) {
+ return res.status(409).json({
+ success: false,
+ message: 'RFID UID existiert bereits',
+ details: {
+ existingPlayer: {
+ firstname: existingRfid.rows[0].firstname,
+ lastname: existingRfid.rows[0].lastname
+ }
+ }
+ });
+ }
+
+ // Prüfen ob Supabase User bereits verknüpft ist (falls angegeben)
+ if (supabase_user_id) {
+ const existingUser = await pool.query(
+ 'SELECT id FROM players WHERE supabase_user_id = $1',
+ [supabase_user_id]
+ );
+
+ if (existingUser.rows.length > 0) {
+ return res.status(409).json({
+ success: false,
+ message: 'Dieser Benutzer ist bereits mit einem Spieler verknüpft'
+ });
+ }
+ }
+
+ // Geburtsdatum validieren
+ const birthDate = new Date(birthdate);
+ if (isNaN(birthDate.getTime())) {
+ return res.status(400).json({
+ success: false,
+ message: 'Ungültiges Geburtsdatum'
+ });
+ }
+
+ // Alter berechnen
+ const today = new Date();
+ let age = today.getFullYear() - birthDate.getFullYear();
+ const monthDiff = today.getMonth() - birthDate.getMonth();
+ if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
+ age--;
+ }
+
+ // Spieler in Datenbank einfügen
+ const result = await pool.query(
+ `INSERT INTO players (rfiduid, firstname, lastname, birthdate, supabase_user_id, created_at, show_in_leaderboard)
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
+ RETURNING id, rfiduid, firstname, lastname, birthdate, created_at`,
+ [rfiduid, firstname, lastname, birthdate, supabase_user_id || null, new Date(), true]
+ );
+
+ const newPlayer = result.rows[0];
+
+ res.status(201).json({
+ success: true,
+ message: 'Spieler erfolgreich mit RFID erstellt',
+ data: {
+ id: newPlayer.id,
+ rfiduid: newPlayer.rfiduid,
+ firstname: newPlayer.firstname,
+ lastname: newPlayer.lastname,
+ birthdate: newPlayer.birthdate,
+ age: age,
+ created_at: newPlayer.created_at
+ }
+ });
+
+ } catch (error) {
+ console.error('Fehler beim Erstellen des Spielers mit RFID:', error);
+ res.status(500).json({
+ success: false,
+ message: 'Interner Serverfehler beim Erstellen des Spielers'
+ });
+ }
+});
// Create new player with optional Supabase user linking (no auth required for dashboard)
router.post('/v1/public/players', async (req, res) => {
@@ -1148,10 +1244,25 @@ router.post('/v1/public/link-player', async (req, res) => {
[supabase_user_id, player_id]
);
+ const player = result.rows[0];
+
+ // Alle anonymen Zeiten (mit rfiduid aber ohne player_id) mit diesem Spieler verknüpfen
+ if (player.rfiduid) {
+ const updateTimesResult = await pool.query(
+ 'UPDATE times SET player_id = $1 WHERE rfiduid = $2 AND player_id IS NULL',
+ [player_id, player.rfiduid]
+ );
+
+ console.log(`✅ ${updateTimesResult.rowCount} anonyme Zeiten mit Spieler ${player_id} verknüpft`);
+ }
+
res.json({
success: true,
message: 'Spieler erfolgreich verknüpft',
- data: result.rows[0]
+ data: {
+ ...player,
+ linked_times_count: player.rfiduid ? (await pool.query('SELECT COUNT(*) FROM times WHERE rfiduid = $1 AND player_id = $2', [player.rfiduid, player_id])).rows[0].count : 0
+ }
});
} catch (error) {
@@ -1407,6 +1518,236 @@ router.get('/v1/web/check-session', (req, res) => {
// ADMIN DASHBOARD ROUTES (/api/v1/admin/)
// ============================================================================
+// Blacklist-Verwaltung
+router.get('/v1/admin/blacklist', requireAdminAuth, async (req, res) => {
+ try {
+ const blacklist = await getBlacklist();
+ res.json({
+ success: true,
+ data: blacklist
+ });
+ } catch (error) {
+ console.error('Error loading blacklist:', error);
+ res.status(500).json({
+ success: false,
+ message: 'Fehler beim Laden der Blacklist'
+ });
+ }
+});
+
+// Blacklist-Eintrag hinzufügen
+router.post('/v1/admin/blacklist', requireAdminAuth, async (req, res) => {
+ const { term, category } = req.body;
+
+ if (!term || !category) {
+ return res.status(400).json({
+ success: false,
+ message: 'Begriff und Kategorie sind erforderlich'
+ });
+ }
+
+ try {
+ // Get admin user info for created_by field
+ const adminUser = req.session.username || 'admin';
+ await addToBlacklist(term, category, adminUser);
+ res.json({
+ success: true,
+ message: 'Begriff erfolgreich zur Blacklist hinzugefügt'
+ });
+ } catch (error) {
+ console.error('Error adding to blacklist:', error);
+ res.status(500).json({
+ success: false,
+ message: 'Fehler beim Hinzufügen zur Blacklist'
+ });
+ }
+});
+
+// Blacklist-Eintrag entfernen
+router.delete('/v1/admin/blacklist', requireAdminAuth, async (req, res) => {
+ const { term, category } = req.body;
+
+ if (!term || !category) {
+ return res.status(400).json({
+ success: false,
+ message: 'Begriff und Kategorie sind erforderlich'
+ });
+ }
+
+ try {
+ await removeFromBlacklist(term, category);
+ res.json({
+ success: true,
+ message: 'Begriff erfolgreich aus der Blacklist entfernt'
+ });
+ } catch (error) {
+ console.error('Error removing from blacklist:', error);
+ res.status(500).json({
+ success: false,
+ message: 'Fehler beim Entfernen aus der Blacklist'
+ });
+ }
+});
+
+// Name gegen Blacklist testen
+router.post('/v1/admin/blacklist/test', requireAdminAuth, async (req, res) => {
+ const { firstname, lastname } = req.body;
+
+ if (!firstname || !lastname) {
+ return res.status(400).json({
+ success: false,
+ message: 'Vorname und Nachname sind erforderlich'
+ });
+ }
+
+ try {
+ // LLM-Prüfung
+ const llmResult = await checkNameWithLLM(firstname, lastname);
+
+ // Traditionelle Blacklist-Prüfung
+ const blacklistResult = await checkNameAgainstBlacklist(firstname, lastname);
+
+ res.json({
+ success: true,
+ data: {
+ llm: llmResult,
+ blacklist: blacklistResult,
+ combined: {
+ isBlocked: llmResult.isBlocked || blacklistResult.isBlocked,
+ reason: llmResult.isBlocked ? llmResult.reason : blacklistResult.reason,
+ source: llmResult.isBlocked ? 'llm' : (blacklistResult.isBlocked ? 'blacklist' : 'none')
+ }
+ }
+ });
+ } catch (error) {
+ console.error('Error testing name against blacklist:', error);
+ res.status(500).json({
+ success: false,
+ message: 'Fehler beim Testen des Namens'
+ });
+ }
+});
+
+// Blacklist-Statistiken
+router.get('/v1/admin/blacklist/stats', requireAdminAuth, async (req, res) => {
+ try {
+ const result = await pool.query(`
+ SELECT
+ category,
+ COUNT(*) as count,
+ MAX(created_at) as last_added
+ FROM blacklist_terms
+ GROUP BY category
+ ORDER BY category
+ `);
+
+ const totalResult = await pool.query('SELECT COUNT(*) as total FROM blacklist_terms');
+
+ res.json({
+ success: true,
+ data: {
+ categories: result.rows,
+ total: parseInt(totalResult.rows[0].total)
+ }
+ });
+ } catch (error) {
+ console.error('Error loading blacklist stats:', error);
+ res.status(500).json({
+ success: false,
+ message: 'Fehler beim Laden der Blacklist-Statistiken'
+ });
+ }
+});
+
+// LLM-Status prüfen
+router.get('/v1/admin/llm/status', requireAdminAuth, async (req, res) => {
+ try {
+ const status = await testLLMConnection();
+ res.json({
+ success: true,
+ data: status
+ });
+ } catch (error) {
+ console.error('Error checking LLM status:', error);
+ res.status(500).json({
+ success: false,
+ message: 'Fehler beim Prüfen des LLM-Status'
+ });
+ }
+});
+
+// LLM-Test mit Kontext
+router.post('/v1/admin/llm/test', requireAdminAuth, async (req, res) => {
+ const { firstname, lastname, context } = req.body;
+
+ if (!firstname || !lastname) {
+ return res.status(400).json({
+ success: false,
+ message: 'Vorname und Nachname sind erforderlich'
+ });
+ }
+
+ try {
+ const result = await checkNameWithContext(firstname, lastname, context);
+ res.json({
+ success: true,
+ data: result
+ });
+ } catch (error) {
+ console.error('Error testing name with LLM:', error);
+ res.status(500).json({
+ success: false,
+ message: 'Fehler beim Testen mit LLM'
+ });
+ }
+});
+
+// Levenshtein-Test (für Entwicklung/Debugging)
+router.post('/v1/admin/blacklist/levenshtein-test', requireAdminAuth, async (req, res) => {
+ const { firstname, lastname, threshold } = req.body;
+
+ if (!firstname || !lastname) {
+ return res.status(400).json({
+ success: false,
+ message: 'Vorname und Nachname sind erforderlich'
+ });
+ }
+
+ try {
+ const { checkWithLevenshtein, THRESHOLDS } = require('../config/levenshtein');
+ const blacklist = await getBlacklist();
+
+ const testThreshold = threshold || 0.3;
+ const results = {};
+
+ // Teste jede Kategorie
+ for (const [category, terms] of Object.entries(blacklist)) {
+ const levenshteinResult = checkWithLevenshtein(firstname, lastname, terms, testThreshold);
+ results[category] = {
+ hasSimilarTerms: levenshteinResult.hasSimilarTerms,
+ similarTerms: levenshteinResult.similarTerms,
+ threshold: testThreshold,
+ categoryThreshold: THRESHOLDS[category] || 0.3
+ };
+ }
+
+ res.json({
+ success: true,
+ data: {
+ input: { firstname, lastname },
+ threshold: testThreshold,
+ results: results
+ }
+ });
+ } catch (error) {
+ console.error('Error testing Levenshtein:', error);
+ res.status(500).json({
+ success: false,
+ message: 'Fehler beim Testen der Levenshtein-Distanz'
+ });
+ }
+});
+
// Admin Statistiken
router.get('/v1/admin/stats', requireAdminAuth, async (req, res) => {
try {