// SQLite-Anbindung (better-sqlite3, synchron & schnell) — Schema wird beim Start angelegt import Database from 'better-sqlite3'; import crypto from 'node:crypto'; import { mkdirSync, statSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { config } from './config.js'; const dbFile = resolve(config.dbPath); mkdirSync(dirname(dbFile), { recursive: true }); export const db = new Database(dbFile); db.pragma('journal_mode = WAL'); db.exec(` CREATE TABLE IF NOT EXISTS commits ( sha TEXT PRIMARY KEY, repo TEXT NOT NULL, branch TEXT NOT NULL, message TEXT NOT NULL, author_name TEXT, author_user TEXT, url TEXT, committed_at TEXT NOT NULL, received_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_commits_repo_time ON commits (repo, committed_at DESC); CREATE TABLE IF NOT EXISTS devlogs ( message_id TEXT PRIMARY KEY, channel_id TEXT NOT NULL, content TEXT NOT NULL, author_name TEXT, posted_at TEXT NOT NULL, archived_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_devlogs_posted ON devlogs (posted_at DESC); `); // Migration: images-Spalte (JSON-Array lokaler Dateinamen) für bestehende DBs const devlogCols = db.prepare('PRAGMA table_info(devlogs)').all(); if (!devlogCols.some((c) => c.name === 'images')) { db.exec(`ALTER TABLE devlogs ADD COLUMN images TEXT NOT NULL DEFAULT '[]'`); } // Releases (Gitea-Release-Webhook) — für Ankündigungen + Changelog-Seite db.exec(` CREATE TABLE IF NOT EXISTS releases ( repo TEXT NOT NULL, tag TEXT NOT NULL, name TEXT, body TEXT, url TEXT, prerelease INTEGER NOT NULL DEFAULT 0, published_at TEXT NOT NULL, PRIMARY KEY (repo, tag) ); `); // Volltextsuche (FTS5) über Devlogs — Trigger halten den Index synchron db.exec(` CREATE VIRTUAL TABLE IF NOT EXISTS devlogs_fts USING fts5(content, message_id UNINDEXED); CREATE TRIGGER IF NOT EXISTS devlogs_fts_ai AFTER INSERT ON devlogs BEGIN INSERT INTO devlogs_fts (rowid, content, message_id) VALUES (new.rowid, new.content, new.message_id); END; CREATE TRIGGER IF NOT EXISTS devlogs_fts_ad AFTER DELETE ON devlogs BEGIN DELETE FROM devlogs_fts WHERE rowid = old.rowid; END; `); // Bestehende Zeilen nachindexieren (einmalig nach der Migration) { const ftsCount = db.prepare('SELECT count(*) AS n FROM devlogs_fts').get().n; const devlogCount = db.prepare('SELECT count(*) AS n FROM devlogs').get().n; if (ftsCount !== devlogCount) { db.exec(` DELETE FROM devlogs_fts; INSERT INTO devlogs_fts (rowid, content, message_id) SELECT rowid, content, message_id FROM devlogs; `); } } // Bug-Reports aus Discord (/bug) — für den Rückkanal (Issue geschlossen → DM) db.exec(` CREATE TABLE IF NOT EXISTS bug_reports ( repo TEXT NOT NULL, issue_number INTEGER NOT NULL, discord_user_id TEXT NOT NULL, title TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (repo, issue_number) ); `); const insertBugReport = db.prepare(` INSERT OR REPLACE INTO bug_reports (repo, issue_number, discord_user_id, title) VALUES (@repo, @issue_number, @discord_user_id, @title) `); const getBugReportStmt = db.prepare( 'SELECT * FROM bug_reports WHERE repo = ? AND issue_number = ?' ); const deleteBugReportStmt = db.prepare( 'DELETE FROM bug_reports WHERE repo = ? AND issue_number = ?' ); export function saveBugReport(report) { insertBugReport.run(report); } /** Report holen und direkt entfernen (einmalige Benachrichtigung) */ export function takeBugReport(repo, issueNumber) { const row = getBugReportStmt.get(repo, issueNumber); if (row) deleteBugReportStmt.run(repo, issueNumber); return row ?? null; } // Community: Playtester, Starboard, Screenshot-Galerie db.exec(` CREATE TABLE IF NOT EXISTS playtesters ( user_id TEXT PRIMARY KEY, username TEXT, applied_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS starboard ( message_id TEXT PRIMARY KEY, board_message_id TEXT, starred_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS gallery ( message_id TEXT PRIMARY KEY, author_name TEXT, images TEXT NOT NULL, posted_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_gallery_posted ON gallery (posted_at DESC); `); const insertPlaytester = db.prepare( 'INSERT OR IGNORE INTO playtesters (user_id, username) VALUES (?, ?)' ); const deletePlaytester = db.prepare('DELETE FROM playtesters WHERE user_id = ?'); const listPlaytestersStmt = db.prepare( 'SELECT user_id, username, applied_at FROM playtesters ORDER BY applied_at' ); const hasPlaytesterStmt = db.prepare('SELECT 1 FROM playtesters WHERE user_id = ?'); export const addPlaytester = (userId, username) => insertPlaytester.run(userId, username).changes > 0; export const removePlaytester = (userId) => deletePlaytester.run(userId).changes > 0; export const listPlaytesters = () => listPlaytestersStmt.all(); export const isPlaytester = (userId) => Boolean(hasPlaytesterStmt.get(userId)); const hasStarboardStmt = db.prepare('SELECT 1 FROM starboard WHERE message_id = ?'); const insertStarboard = db.prepare( 'INSERT OR IGNORE INTO starboard (message_id, board_message_id) VALUES (?, ?)' ); export const hasStarboard = (id) => Boolean(hasStarboardStmt.get(id)); export const saveStarboard = (id, boardId) => insertStarboard.run(id, boardId); const insertGallery = db.prepare(` INSERT OR IGNORE INTO gallery (message_id, author_name, images, posted_at) VALUES (@message_id, @author_name, @images, @posted_at) `); const hasGalleryStmt = db.prepare('SELECT 1 FROM gallery WHERE message_id = ?'); const getGalleryImagesStmt = db.prepare('SELECT images FROM gallery WHERE message_id = ?'); const deleteGalleryStmt = db.prepare('DELETE FROM gallery WHERE message_id = ?'); const listGalleryStmt = db.prepare(` SELECT message_id, author_name, images, posted_at FROM gallery ORDER BY posted_at DESC LIMIT ? OFFSET ? `); const countGalleryStmt = db.prepare('SELECT count(*) AS n FROM gallery'); export const saveGalleryItem = (item) => insertGallery.run(item).changes > 0; export const hasGalleryItem = (id) => Boolean(hasGalleryStmt.get(id)); export function deleteGalleryItem(id) { const row = getGalleryImagesStmt.get(id); if (!row) return { deleted: false, images: [] }; deleteGalleryStmt.run(id); return { deleted: true, images: JSON.parse(row.images || '[]') }; } export const listGallery = (limit, offset) => ({ items: listGalleryStmt.all(limit, offset), total: countGalleryStmt.get().n, }); // Feature-Voting (/wunsch) + Giveaways db.exec(` CREATE TABLE IF NOT EXISTS wishes ( message_id TEXT PRIMARY KEY, author TEXT, idea TEXT NOT NULL, score INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS giveaways ( message_id TEXT PRIMARY KEY, channel_id TEXT NOT NULL, prize TEXT NOT NULL, winners INTEGER NOT NULL DEFAULT 1, ends_at TEXT NOT NULL, ended INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS giveaway_entries ( giveaway_id TEXT NOT NULL, user_id TEXT NOT NULL, PRIMARY KEY (giveaway_id, user_id) ); `); const insertWish = db.prepare('INSERT INTO wishes (message_id, author, idea) VALUES (?, ?, ?)'); const wishScoreStmt = db.prepare('UPDATE wishes SET score = MAX(0, score + ?) WHERE message_id = ?'); const hasWishStmt = db.prepare('SELECT 1 FROM wishes WHERE message_id = ?'); const topWishesStmt = db.prepare( 'SELECT message_id, idea, author, score, created_at FROM wishes ORDER BY score DESC, created_at DESC LIMIT ?' ); export const saveWish = (messageId, author, idea) => insertWish.run(messageId, author, idea); export const isWish = (messageId) => Boolean(hasWishStmt.get(messageId)); export const bumpWish = (messageId, delta) => wishScoreStmt.run(delta, messageId); export const topWishes = (limit = 20) => topWishesStmt.all(limit); // Web-Votes auf Wünsche (Discord-👍 laufen separat über Reactions) db.exec(` CREATE TABLE IF NOT EXISTS wish_votes ( message_id TEXT NOT NULL, user_id TEXT NOT NULL, PRIMARY KEY (message_id, user_id) ); `); const hasWishVoteStmt = db.prepare('SELECT 1 FROM wish_votes WHERE message_id = ? AND user_id = ?'); const insertWishVote = db.prepare('INSERT OR IGNORE INTO wish_votes (message_id, user_id) VALUES (?, ?)'); const deleteWishVote = db.prepare('DELETE FROM wish_votes WHERE message_id = ? AND user_id = ?'); const myWishVotesStmt = db.prepare('SELECT message_id FROM wish_votes WHERE user_id = ?'); /** Web-Vote togglen; passt den Score mit an. @returns {boolean} true = jetzt gevotet */ export function toggleWishVote(messageId, userId) { if (hasWishVoteStmt.get(messageId, userId)) { deleteWishVote.run(messageId, userId); wishScoreStmt.run(-1, messageId); return false; } insertWishVote.run(messageId, userId); wishScoreStmt.run(1, messageId); return true; } export const myWishVotes = (userId) => myWishVotesStmt.all(userId).map((r) => r.message_id); const insertGiveaway = db.prepare(` INSERT INTO giveaways (message_id, channel_id, prize, winners, ends_at) VALUES (@message_id, @channel_id, @prize, @winners, @ends_at) `); const dueGiveawaysStmt = db.prepare( `SELECT * FROM giveaways WHERE ended = 0 AND ends_at <= datetime('now')` ); const endGiveawayStmt = db.prepare('UPDATE giveaways SET ended = 1 WHERE message_id = ?'); const giveawayStmt = db.prepare('SELECT * FROM giveaways WHERE message_id = ?'); const toggleEntryInsert = db.prepare( 'INSERT OR IGNORE INTO giveaway_entries (giveaway_id, user_id) VALUES (?, ?)' ); const toggleEntryDelete = db.prepare( 'DELETE FROM giveaway_entries WHERE giveaway_id = ? AND user_id = ?' ); const entriesStmt = db.prepare('SELECT user_id FROM giveaway_entries WHERE giveaway_id = ?'); export const saveGiveaway = (g) => insertGiveaway.run(g); export const getGiveaway = (id) => giveawayStmt.get(id) ?? null; export const dueGiveaways = () => dueGiveawaysStmt.all(); export const markGiveawayEnded = (id) => endGiveawayStmt.run(id); export const giveawayEntries = (id) => entriesStmt.all(id).map((r) => r.user_id); /** Teilnahme togglen — true = jetzt drin, false = ausgetragen */ export function toggleGiveawayEntry(giveawayId, userId) { if (toggleEntryInsert.run(giveawayId, userId).changes > 0) return true; toggleEntryDelete.run(giveawayId, userId); return false; } /** Commits pro Tag der letzten 365 Tage (Contribution-Heatmap) */ export function commitHeatmap() { return db .prepare(` SELECT date(committed_at) AS day, count(*) AS n FROM commits WHERE committed_at >= datetime('now', '-365 days') GROUP BY day `) .all(); } // Alpha-Keys: Pool + Zuweisung an Playtester db.exec(` CREATE TABLE IF NOT EXISTS alpha_keys ( key TEXT PRIMARY KEY, assigned_to TEXT, assigned_at TEXT ); `); const insertAlphaKey = db.prepare('INSERT OR IGNORE INTO alpha_keys (key) VALUES (?)'); const freeKeyCountStmt = db.prepare('SELECT count(*) AS n FROM alpha_keys WHERE assigned_to IS NULL'); const assignedKeysStmt = db.prepare( `SELECT key, assigned_to, assigned_at FROM alpha_keys WHERE assigned_to IS NOT NULL ORDER BY assigned_at` ); const hasKeyForUserStmt = db.prepare('SELECT key FROM alpha_keys WHERE assigned_to = ?'); const reserveKeyStmt = db.prepare(` UPDATE alpha_keys SET assigned_to = ?, assigned_at = datetime('now') WHERE key = (SELECT key FROM alpha_keys WHERE assigned_to IS NULL LIMIT 1) RETURNING key `); const unreserveKeyStmt = db.prepare('UPDATE alpha_keys SET assigned_to = NULL, assigned_at = NULL WHERE key = ?'); export const addAlphaKeys = db.transaction((keys) => { let added = 0; for (const k of keys) added += insertAlphaKey.run(k).changes; return added; }); export const freeAlphaKeyCount = () => freeKeyCountStmt.get().n; export const assignedAlphaKeys = () => assignedKeysStmt.all(); export const alphaKeyOf = (userId) => hasKeyForUserStmt.get(userId)?.key ?? null; export const reserveAlphaKey = (userId) => reserveKeyStmt.get(userId)?.key ?? null; export const unreserveAlphaKey = (key) => unreserveKeyStmt.run(key); // Bewerbungs-Formulare (Modals) + eingereichte Bewerbungen db.exec(` CREATE TABLE IF NOT EXISTS app_forms ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', review_channel_id TEXT, approve_role_id TEXT, post_channel_id TEXT, message_id TEXT, questions TEXT NOT NULL DEFAULT '[]' ); CREATE TABLE IF NOT EXISTS applications ( id INTEGER PRIMARY KEY AUTOINCREMENT, form_id INTEGER NOT NULL, user_id TEXT NOT NULL, username TEXT, answers TEXT NOT NULL DEFAULT '[]', status TEXT NOT NULL DEFAULT 'open', created_at TEXT NOT NULL DEFAULT (datetime('now')) ); `); const insertAppForm = db.prepare(` INSERT INTO app_forms (title, description, review_channel_id, approve_role_id, post_channel_id, questions) VALUES (@title, @description, @review_channel_id, @approve_role_id, @post_channel_id, @questions) `); const updateAppFormStmt = db.prepare(` UPDATE app_forms SET title = @title, description = @description, review_channel_id = @review_channel_id, approve_role_id = @approve_role_id, post_channel_id = @post_channel_id, questions = @questions WHERE id = @id `); const setAppFormMessageStmt = db.prepare('UPDATE app_forms SET message_id = ? WHERE id = ?'); const getAppFormStmt = db.prepare('SELECT * FROM app_forms WHERE id = ?'); const listAppFormsStmt = db.prepare('SELECT * FROM app_forms ORDER BY id'); const deleteAppFormStmt = db.prepare('DELETE FROM app_forms WHERE id = ?'); export const createAppForm = (f) => insertAppForm.run(f).lastInsertRowid; export const updateAppForm = (f) => updateAppFormStmt.run(f).changes > 0; export const setAppFormMessage = (id, messageId) => setAppFormMessageStmt.run(messageId, id); export const getAppForm = (id) => getAppFormStmt.get(id) ?? null; export const listAppForms = () => listAppFormsStmt.all(); export const deleteAppForm = (id) => deleteAppFormStmt.run(id).changes > 0; const insertApplication = db.prepare(` INSERT INTO applications (form_id, user_id, username, answers) VALUES (?, ?, ?, ?) `); const getApplicationStmt = db.prepare('SELECT * FROM applications WHERE id = ?'); const openApplicationStmt = db.prepare( `SELECT * FROM applications WHERE form_id = ? AND user_id = ? AND status = 'open'` ); const setApplicationStatusStmt = db.prepare('UPDATE applications SET status = ? WHERE id = ?'); export const createApplication = (formId, userId, username, answers) => insertApplication.run(formId, userId, username, JSON.stringify(answers)).lastInsertRowid; export const getApplication = (id) => getApplicationStmt.get(id) ?? null; export const openApplicationOf = (formId, userId) => openApplicationStmt.get(formId, userId) ?? null; export const setApplicationStatus = (id, status) => setApplicationStatusStmt.run(status, id); // Triggers (Auto-Antworten), Reminders, Server-Aktivität, Temp-Voice db.exec(` CREATE TABLE IF NOT EXISTS triggers ( keyword TEXT PRIMARY KEY, reply TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS reminders ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, text TEXT NOT NULL, remind_at TEXT NOT NULL, sent INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS activity_daily ( day TEXT PRIMARY KEY, messages INTEGER NOT NULL DEFAULT 0, joins INTEGER NOT NULL DEFAULT 0, leaves INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS temp_voice ( channel_id TEXT PRIMARY KEY ); CREATE TABLE IF NOT EXISTS message_templates ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, payload TEXT NOT NULL ); `); // Migration: Temp-Voice-Besitzer (fürs Control-Panel) { const cols = db.prepare('PRAGMA table_info(temp_voice)').all().map((c) => c.name); if (!cols.includes('owner_id')) db.exec(`ALTER TABLE temp_voice ADD COLUMN owner_id TEXT NOT NULL DEFAULT ''`); } const upsertTemplate = db.prepare(` INSERT INTO message_templates (name, payload) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET payload = excluded.payload `); const listTemplatesStmt = db.prepare('SELECT id, name, payload FROM message_templates ORDER BY name'); const deleteTemplateStmt = db.prepare('DELETE FROM message_templates WHERE name = ?'); export const saveTemplate = (name, payload) => upsertTemplate.run(name, JSON.stringify(payload)); export const listTemplates = () => listTemplatesStmt.all().map((t) => ({ ...t, payload: JSON.parse(t.payload) })); export const deleteTemplate = (name) => deleteTemplateStmt.run(name).changes > 0; const upsertTrigger = db.prepare(` INSERT INTO triggers (keyword, reply) VALUES (?, ?) ON CONFLICT(keyword) DO UPDATE SET reply = excluded.reply `); const listTriggersStmt = db.prepare('SELECT keyword, reply FROM triggers ORDER BY keyword'); const deleteTriggerStmt = db.prepare('DELETE FROM triggers WHERE keyword = ?'); export const saveTrigger = (keyword, reply) => upsertTrigger.run(keyword, reply); export const listTriggers = () => listTriggersStmt.all(); export const deleteTrigger = (keyword) => deleteTriggerStmt.run(keyword).changes > 0; const insertReminder = db.prepare('INSERT INTO reminders (user_id, text, remind_at) VALUES (?, ?, ?)'); const dueRemindersStmt = db.prepare( `SELECT * FROM reminders WHERE sent = 0 AND remind_at <= datetime('now')` ); const markReminderSentStmt = db.prepare('UPDATE reminders SET sent = 1 WHERE id = ?'); export const createReminder = (userId, text, remindAt) => insertReminder.run(userId, text, remindAt).lastInsertRowid; export const dueReminders = () => dueRemindersStmt.all(); export const markReminderSent = (id) => markReminderSentStmt.run(id); const bumpActivityStmt = (col) => db.prepare(` INSERT INTO activity_daily (day, ${col}) VALUES (date('now'), 1) ON CONFLICT(day) DO UPDATE SET ${col} = ${col} + 1 `); const bumpMessages = bumpActivityStmt('messages'); const bumpJoins = bumpActivityStmt('joins'); const bumpLeaves = bumpActivityStmt('leaves'); export const countActivity = (kind) => (kind === 'join' ? bumpJoins : kind === 'leave' ? bumpLeaves : bumpMessages).run(); const activityRangeStmt = db.prepare( `SELECT day, messages, joins, leaves FROM activity_daily WHERE day >= date('now', ?) ORDER BY day` ); export const activityRange = (days = 30) => activityRangeStmt.all(`-${days} days`); const insertTempVoice = db.prepare('INSERT OR IGNORE INTO temp_voice (channel_id, owner_id) VALUES (?, ?)'); const tempVoiceOwnerStmt = db.prepare('SELECT owner_id FROM temp_voice WHERE channel_id = ?'); export const tempVoiceOwner = (id) => tempVoiceOwnerStmt.get(id)?.owner_id ?? null; const deleteTempVoiceStmt = db.prepare('DELETE FROM temp_voice WHERE channel_id = ?'); const listTempVoiceStmt = db.prepare('SELECT channel_id FROM temp_voice'); export const trackTempVoice = (id, ownerId = '') => insertTempVoice.run(id, ownerId); export const untrackTempVoice = (id) => deleteTempVoiceStmt.run(id); export const listTempVoice = () => listTempVoiceStmt.all().map((r) => r.channel_id); const isTempVoiceStmt = db.prepare('SELECT 1 FROM temp_voice WHERE channel_id = ?'); export const isTempVoice = (id) => Boolean(isTempVoiceStmt.get(id)); // Team: Web-Zugriff mit Bereichs-Rechten (Owner = ADMIN_DISCORD_ID darf immer alles) db.exec(` CREATE TABLE IF NOT EXISTS web_admins ( user_id TEXT PRIMARY KEY, username TEXT, scopes TEXT NOT NULL DEFAULT '' ); `); const upsertWebAdmin = db.prepare(` INSERT INTO web_admins (user_id, username, scopes) VALUES (?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET username = excluded.username, scopes = excluded.scopes `); const webAdminStmt = db.prepare('SELECT * FROM web_admins WHERE user_id = ?'); const listWebAdminsStmt = db.prepare('SELECT * FROM web_admins ORDER BY username'); const deleteWebAdminStmt = db.prepare('DELETE FROM web_admins WHERE user_id = ?'); export const saveWebAdmin = (userId, username, scopes) => upsertWebAdmin.run(userId, username, scopes.join(',')); export const webAdminScopes = (userId) => (webAdminStmt.get(userId)?.scopes ?? '').split(',').filter(Boolean); export const listWebAdmins = () => listWebAdminsStmt.all(); export const deleteWebAdmin = (userId) => deleteWebAdminStmt.run(userId).changes > 0; // Geburtstage: /geburtstag → morgendliche Gratulation + Tages-Rolle db.exec(` CREATE TABLE IF NOT EXISTS birthdays ( user_id TEXT PRIMARY KEY, username TEXT, day INTEGER NOT NULL, month INTEGER NOT NULL ); `); const upsertBirthday = db.prepare(` INSERT INTO birthdays (user_id, username, day, month) VALUES (?, ?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET username = excluded.username, day = excluded.day, month = excluded.month `); const deleteBirthdayStmt = db.prepare('DELETE FROM birthdays WHERE user_id = ?'); const birthdaysTodayStmt = db.prepare('SELECT * FROM birthdays WHERE day = ? AND month = ?'); export const setBirthday = (userId, username, day, month) => upsertBirthday.run(userId, username, day, month); export const deleteBirthday = (userId) => deleteBirthdayStmt.run(userId).changes > 0; export const birthdaysToday = (day, month) => birthdaysTodayStmt.all(day, month); /** * DSGVO Art. 17: alle Selbstauskunfts-Daten eines Users löschen. * Bewusst NICHT gelöscht: Verwarnungen (berechtigtes Interesse an Moderation) * und Inhalte, die im Discord stehen (Devlogs, Galerie) — die gehören dort gelöscht. * @returns {Record} gelöschte Zeilen pro Bereich */ export function deleteUserData(userId) { const del = (sql) => db.prepare(sql).run(userId).changes; const result = { level: del('DELETE FROM levels WHERE user_id = ?'), geburtstag: del('DELETE FROM birthdays WHERE user_id = ?'), playtester: del('DELETE FROM playtesters WHERE user_id = ?'), votes: del('DELETE FROM wish_votes WHERE user_id = ?'), erinnerungen: del('DELETE FROM reminders WHERE user_id = ?'), rollen_backup: del('DELETE FROM sticky_roles WHERE user_id = ?'), giveaways: del('DELETE FROM giveaway_entries WHERE user_id = ?'), alpha_key: db.prepare( `UPDATE alpha_keys SET assigned_to = NULL, assigned_at = NULL WHERE assigned_to = ?` ).run(userId).changes, }; return result; } // Portal: eigene Dienste (Gitea, Kanban, Cloud …) für die Kachel-Übersicht db.exec(` CREATE TABLE IF NOT EXISTS services ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, url TEXT NOT NULL, icon TEXT NOT NULL DEFAULT '🔗', description TEXT NOT NULL DEFAULT '', sort INTEGER NOT NULL DEFAULT 0 ); `); const insertService = db.prepare( 'INSERT INTO services (name, url, icon, description, sort) VALUES (@name, @url, @icon, @description, @sort)' ); const updateServiceStmt = db.prepare( 'UPDATE services SET name = @name, url = @url, icon = @icon, description = @description, sort = @sort WHERE id = @id' ); const listServicesStmt = db.prepare('SELECT * FROM services ORDER BY sort, name'); const deleteServiceStmt = db.prepare('DELETE FROM services WHERE id = ?'); export const createService = (s) => insertService.run(s).lastInsertRowid; export const updateService = (s) => updateServiceStmt.run(s).changes > 0; export const listServices = () => listServicesStmt.all(); export const deleteService = (id) => deleteServiceStmt.run(id).changes > 0; // SSO: Fremd-Apps (Kanban, Platform …), die den Discord-Login des Bots mitnutzen db.exec(` CREATE TABLE IF NOT EXISTS sso_apps ( slug TEXT PRIMARY KEY, name TEXT NOT NULL, redirect_prefix TEXT NOT NULL, secret TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), last_used TEXT ); `); const upsertSsoApp = db.prepare(` INSERT INTO sso_apps (slug, name, redirect_prefix, secret) VALUES (@slug, @name, @redirect_prefix, @secret) ON CONFLICT(slug) DO UPDATE SET name = excluded.name, redirect_prefix = excluded.redirect_prefix `); const getSsoAppStmt = db.prepare('SELECT * FROM sso_apps WHERE slug = ?'); const listSsoAppsStmt = db.prepare('SELECT slug, name, redirect_prefix, created_at, last_used FROM sso_apps ORDER BY name'); const deleteSsoAppStmt = db.prepare('DELETE FROM sso_apps WHERE slug = ?'); const touchSsoAppStmt = db.prepare(`UPDATE sso_apps SET last_used = datetime('now') WHERE slug = ?`); export const saveSsoApp = (app) => upsertSsoApp.run(app); export const getSsoApp = (slug) => getSsoAppStmt.get(slug) ?? null; export const listSsoApps = () => listSsoAppsStmt.all(); export const deleteSsoApp = (slug) => deleteSsoAppStmt.run(slug).changes > 0; export const touchSsoApp = (slug) => touchSsoAppStmt.run(slug); // Audit-Log: wer (Owner/Team) hat wann was im Webinterface geändert db.exec(` CREATE TABLE IF NOT EXISTS audit_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT NOT NULL DEFAULT (datetime('now')), user_id TEXT NOT NULL, username TEXT, action TEXT NOT NULL, detail TEXT NOT NULL DEFAULT '' ); `); const insertAudit = db.prepare( 'INSERT INTO audit_log (user_id, username, action, detail) VALUES (?, ?, ?, ?)' ); const listAuditStmt = db.prepare('SELECT * FROM audit_log ORDER BY id DESC LIMIT ?'); const pruneAuditStmt = db.prepare(` DELETE FROM audit_log WHERE id NOT IN (SELECT id FROM audit_log ORDER BY id DESC LIMIT 500) `); export function logAudit(user, action, detail = '') { insertAudit.run(user?.id ?? '?', user?.username ?? '?', action, String(detail).slice(0, 300)); pruneAuditStmt.run(); } export const listAudit = (limit = 100) => listAuditStmt.all(limit); // Game-Server (DiscordGSM-Stil): strukturierte Server statt CSV-Setting db.exec(` CREATE TABLE IF NOT EXISTS gameservers ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, type TEXT NOT NULL DEFAULT 'fivem', query_url TEXT NOT NULL, address TEXT NOT NULL DEFAULT '', message_id TEXT ); `); // Einmalige Migration aus dem alten "gameservers"-CSV-Setting ("Name=URL, …") { // settings-Tabelle kann hier noch fehlen (wird sonst erst weiter unten angelegt) db.exec('CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)'); const hasServers = db.prepare('SELECT count(*) AS n FROM gameservers').get().n > 0; const legacy = db.prepare(`SELECT value FROM settings WHERE key = 'gameservers'`).get()?.value; if (!hasServers && legacy) { const ins = db.prepare(`INSERT INTO gameservers (name, type, query_url) VALUES (?, 'fivem', ?)`); for (const entry of legacy.split(',')) { const [name, ...rest] = entry.split('='); const url = rest.join('=').trim().replace(/\/$/, ''); if (name?.trim() && /^https?:\/\//.test(url)) ins.run(name.trim(), url); } db.prepare(`DELETE FROM settings WHERE key = 'gameservers'`).run(); } } // Migration v3: host/port (gamedig) + connect_url (Connect-Link im Embed) { const cols = db.prepare('PRAGMA table_info(gameservers)').all().map((c) => c.name); if (!cols.includes('host')) db.exec(`ALTER TABLE gameservers ADD COLUMN host TEXT NOT NULL DEFAULT ''`); if (!cols.includes('port')) db.exec('ALTER TABLE gameservers ADD COLUMN port INTEGER'); if (!cols.includes('connect_url')) db.exec(`ALTER TABLE gameservers ADD COLUMN connect_url TEXT NOT NULL DEFAULT ''`); if (!cols.includes('image_url')) db.exec(`ALTER TABLE gameservers ADD COLUMN image_url TEXT NOT NULL DEFAULT ''`); } const insertGameserver = db.prepare(` INSERT INTO gameservers (name, type, query_url, address, host, port, connect_url, image_url) VALUES (@name, @type, @query_url, @address, @host, @port, @connect_url, @image_url) `); const updateGameserverStmt = db.prepare(` UPDATE gameservers SET name = @name, type = @type, query_url = @query_url, address = @address, host = @host, port = @port, connect_url = @connect_url, image_url = @image_url WHERE id = @id `); const setGameserverMessageStmt = db.prepare('UPDATE gameservers SET message_id = ? WHERE id = ?'); const listGameserversStmt = db.prepare('SELECT * FROM gameservers ORDER BY id'); const getGameserverStmt = db.prepare('SELECT * FROM gameservers WHERE id = ?'); const deleteGameserverStmt = db.prepare('DELETE FROM gameservers WHERE id = ?'); export const createGameserver = (s) => insertGameserver.run(s).lastInsertRowid; export const updateGameserver = (s) => updateGameserverStmt.run(s).changes > 0; export const setGameserverMessage = (id, messageId) => setGameserverMessageStmt.run(messageId, id); export const listGameservers = () => listGameserversStmt.all(); export const getGameserver = (id) => getGameserverStmt.get(id) ?? null; export const deleteGameserver = (id) => deleteGameserverStmt.run(id).changes > 0; // Spielerzahl-Verlauf für die öffentliche /server-Seite (Sample alle 2 min, 7 Tage Retention) db.exec(` CREATE TABLE IF NOT EXISTS player_history ( server_id INTEGER NOT NULL, ts TEXT NOT NULL DEFAULT (datetime('now')), online INTEGER NOT NULL, players INTEGER ); CREATE INDEX IF NOT EXISTS idx_ph_server_ts ON player_history (server_id, ts); `); const insertPlayerSample = db.prepare( 'INSERT INTO player_history (server_id, online, players) VALUES (?, ?, ?)' ); const playerHistoryStmt = db.prepare(` SELECT ts, online, players FROM player_history WHERE server_id = ? AND ts >= datetime('now', ?) ORDER BY ts `); const prunePlayerHistoryStmt = db.prepare(`DELETE FROM player_history WHERE ts < datetime('now', '-7 days')`); export const recordPlayerSample = (serverId, online, players) => insertPlayerSample.run(serverId, online ? 1 : 0, players); export const playerHistory = (serverId, hours = 24) => playerHistoryStmt.all(serverId, `-${hours} hours`); export const prunePlayerHistory = () => prunePlayerHistoryStmt.run(); // Moderation: Verwarnungen + Sticky-Roles db.exec(` CREATE TABLE IF NOT EXISTS warns ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, moderator TEXT, reason TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_warns_user ON warns (user_id); CREATE TABLE IF NOT EXISTS sticky_roles ( user_id TEXT PRIMARY KEY, role_ids TEXT NOT NULL DEFAULT '[]', saved_at TEXT NOT NULL DEFAULT (datetime('now')) ); `); const insertWarn = db.prepare('INSERT INTO warns (user_id, moderator, reason) VALUES (?, ?, ?)'); const warnsOfStmt = db.prepare('SELECT * FROM warns WHERE user_id = ? ORDER BY id'); const deleteWarnStmt = db.prepare('DELETE FROM warns WHERE id = ?'); export const addWarn = (userId, moderator, reason) => insertWarn.run(userId, moderator, reason).lastInsertRowid; export const warnsOf = (userId) => warnsOfStmt.all(userId); export const deleteWarn = (id) => deleteWarnStmt.run(id).changes > 0; const upsertSticky = db.prepare(` INSERT INTO sticky_roles (user_id, role_ids, saved_at) VALUES (?, ?, datetime('now')) ON CONFLICT(user_id) DO UPDATE SET role_ids = excluded.role_ids, saved_at = excluded.saved_at `); const stickyOfStmt = db.prepare('SELECT role_ids FROM sticky_roles WHERE user_id = ?'); export const saveStickyRoles = (userId, roleIds) => upsertSticky.run(userId, JSON.stringify(roleIds)); export const stickyRolesOf = (userId) => JSON.parse(stickyOfStmt.get(userId)?.role_ids ?? '[]'); // Level-System: XP pro Nachricht (Cooldown), Bestenliste auf der Webseite db.exec(` CREATE TABLE IF NOT EXISTS levels ( user_id TEXT PRIMARY KEY, username TEXT, xp INTEGER NOT NULL DEFAULT 0, level INTEGER NOT NULL DEFAULT 0, last_xp_ms INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_levels_xp ON levels (xp DESC); `); const getLevelStmt = db.prepare('SELECT * FROM levels WHERE user_id = ?'); const upsertLevelStmt = db.prepare(` INSERT INTO levels (user_id, username, xp, level, last_xp_ms) VALUES (?, ?, ?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET username = excluded.username, xp = excluded.xp, level = excluded.level, last_xp_ms = excluded.last_xp_ms `); const topLevelsStmt = db.prepare('SELECT user_id, username, xp, level FROM levels ORDER BY xp DESC LIMIT ?'); const rankOfStmt = db.prepare('SELECT count(*) + 1 AS rank FROM levels WHERE xp > (SELECT xp FROM levels WHERE user_id = ?)'); export const getLevelRow = (userId) => getLevelStmt.get(userId) ?? null; export const setLevelRow = (r) => upsertLevelStmt.run(r.user_id, r.username, r.xp, r.level, r.last_xp_ms); export const topLevels = (limit = 50) => topLevelsStmt.all(limit); export const rankOf = (userId) => (getLevelStmt.get(userId) ? rankOfStmt.get(userId).rank : null); // Tags (eigene Text-Befehle) + geplante Posts db.exec(` CREATE TABLE IF NOT EXISTS tags ( name TEXT PRIMARY KEY, content TEXT NOT NULL, updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS scheduled_posts ( id INTEGER PRIMARY KEY AUTOINCREMENT, channel_id TEXT NOT NULL, content TEXT NOT NULL, embed_title TEXT, type TEXT NOT NULL, date TEXT, time TEXT NOT NULL, weekday INTEGER, next_run_at TEXT NOT NULL, last_run_at TEXT, enabled INTEGER NOT NULL DEFAULT 1 ); `); const upsertTag = db.prepare(` INSERT INTO tags (name, content, updated_at) VALUES (?, ?, datetime('now')) ON CONFLICT(name) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at `); const getTagStmt = db.prepare('SELECT * FROM tags WHERE name = ?'); const listTagsStmt = db.prepare('SELECT name, content, updated_at FROM tags ORDER BY name'); const deleteTagStmt = db.prepare('DELETE FROM tags WHERE name = ?'); export const saveTag = (name, content) => upsertTag.run(name, content); export const getTag = (name) => getTagStmt.get(name) ?? null; export const listTags = () => listTagsStmt.all(); export const deleteTag = (name) => deleteTagStmt.run(name).changes > 0; const insertScheduled = db.prepare(` INSERT INTO scheduled_posts (channel_id, content, embed_title, type, date, time, weekday, next_run_at) VALUES (@channel_id, @content, @embed_title, @type, @date, @time, @weekday, @next_run_at) `); const listScheduledStmt = db.prepare('SELECT * FROM scheduled_posts ORDER BY next_run_at'); const dueScheduledStmt = db.prepare( `SELECT * FROM scheduled_posts WHERE enabled = 1 AND next_run_at <= datetime('now')` ); const updateScheduledRunStmt = db.prepare( `UPDATE scheduled_posts SET next_run_at = ?, last_run_at = datetime('now'), enabled = ? WHERE id = ?` ); const deleteScheduledStmt = db.prepare('DELETE FROM scheduled_posts WHERE id = ?'); export const createScheduledPost = (p) => insertScheduled.run(p).lastInsertRowid; export const listScheduledPosts = () => listScheduledStmt.all(); export const dueScheduledPosts = () => dueScheduledStmt.all(); export const markScheduledRun = (id, nextRunAt, stillEnabled) => updateScheduledRunStmt.run(nextRunAt, stillEnabled ? 1 : 0, id); export const deleteScheduledPost = (id) => deleteScheduledStmt.run(id).changes > 0; // Rollen-Menüs (Carl-Bot-Ersatz): selbst zuweisbare Rollen per Button-Post db.exec(` CREATE TABLE IF NOT EXISTS role_menus ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', channel_id TEXT, message_id TEXT, exclusive INTEGER NOT NULL DEFAULT 0, entries TEXT NOT NULL DEFAULT '[]' ); `); const insertRoleMenu = db.prepare(` INSERT INTO role_menus (title, description, channel_id, exclusive, entries) VALUES (@title, @description, @channel_id, @exclusive, @entries) `); const updateRoleMenuStmt = db.prepare(` UPDATE role_menus SET title = @title, description = @description, channel_id = @channel_id, exclusive = @exclusive, entries = @entries WHERE id = @id `); const setRoleMenuMessageStmt = db.prepare('UPDATE role_menus SET message_id = ? WHERE id = ?'); const getRoleMenuStmt = db.prepare('SELECT * FROM role_menus WHERE id = ?'); const listRoleMenusStmt = db.prepare('SELECT * FROM role_menus ORDER BY id'); const deleteRoleMenuStmt = db.prepare('DELETE FROM role_menus WHERE id = ?'); export const createRoleMenu = (m) => insertRoleMenu.run(m).lastInsertRowid; export const updateRoleMenu = (m) => updateRoleMenuStmt.run(m).changes > 0; export const setRoleMenuMessage = (id, messageId) => setRoleMenuMessageStmt.run(messageId, id); export const getRoleMenu = (id) => getRoleMenuStmt.get(id) ?? null; export const listRoleMenus = () => listRoleMenusStmt.all(); export const deleteRoleMenu = (id) => deleteRoleMenuStmt.run(id).changes > 0; // Ticket-System (Button → privater Thread) db.exec(` CREATE TABLE IF NOT EXISTS tickets ( thread_id TEXT PRIMARY KEY, user_id TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'open', opened_at TEXT NOT NULL DEFAULT (datetime('now')) ); `); const insertTicket = db.prepare('INSERT INTO tickets (thread_id, user_id) VALUES (?, ?)'); const getTicketStmt = db.prepare('SELECT * FROM tickets WHERE thread_id = ?'); const closeTicketStmt = db.prepare(`UPDATE tickets SET status = 'closed' WHERE thread_id = ?`); const openTicketByUserStmt = db.prepare( `SELECT * FROM tickets WHERE user_id = ? AND status = 'open'` ); export const saveTicket = (threadId, userId) => insertTicket.run(threadId, userId); export const getTicket = (threadId) => getTicketStmt.get(threadId) ?? null; export const closeTicket = (threadId) => closeTicketStmt.run(threadId); export const openTicketOf = (userId) => openTicketByUserStmt.get(userId) ?? null; // Modmail: DM-Konversationen ↔ Staff-Threads db.exec(` CREATE TABLE IF NOT EXISTS modmail ( user_id TEXT PRIMARY KEY, thread_id TEXT NOT NULL UNIQUE, username TEXT, opened_at TEXT NOT NULL DEFAULT (datetime('now')) ); `); const upsertModmail = db.prepare(` INSERT INTO modmail (user_id, thread_id, username) VALUES (?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET thread_id = excluded.thread_id, username = excluded.username `); const modmailByUserStmt = db.prepare('SELECT * FROM modmail WHERE user_id = ?'); const modmailByThreadStmt = db.prepare('SELECT * FROM modmail WHERE thread_id = ?'); export const saveModmail = (userId, threadId, username) => upsertModmail.run(userId, threadId, username); export const modmailByUser = (userId) => modmailByUserStmt.get(userId) ?? null; export const modmailByThread = (threadId) => modmailByThreadStmt.get(threadId) ?? null; // API-Keys für externe Skripte (/api/v1/*) — nur der SHA-256-Hash wird gespeichert db.exec(` CREATE TABLE IF NOT EXISTS api_keys ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, key_hash TEXT NOT NULL UNIQUE, scopes TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), last_used_at TEXT ); `); const insertApiKey = db.prepare( 'INSERT INTO api_keys (name, key_hash, scopes) VALUES (?, ?, ?)' ); const findApiKeyStmt = db.prepare('SELECT * FROM api_keys WHERE key_hash = ?'); const touchApiKeyStmt = db.prepare( `UPDATE api_keys SET last_used_at = datetime('now') WHERE id = ?` ); const listApiKeysStmt = db.prepare( 'SELECT id, name, scopes, created_at, last_used_at FROM api_keys ORDER BY id' ); const deleteApiKeyStmt = db.prepare('DELETE FROM api_keys WHERE id = ?'); const hashKey = (key) => crypto.createHash('sha256').update(key).digest('hex'); /** Neuen Key erzeugen — der Klartext-Key wird nur einmal zurückgegeben! */ export function createApiKey(name, scopes) { const key = `d4rk_${crypto.randomBytes(24).toString('hex')}`; const info = insertApiKey.run(name, hashKey(key), scopes.join(',')); return { id: info.lastInsertRowid, key }; } /** Key prüfen: gibt { id, name, scopes: [] } zurück oder null; aktualisiert last_used */ export function verifyApiKey(key) { if (!key?.startsWith('d4rk_')) return null; const row = findApiKeyStmt.get(hashKey(key)); if (!row) return null; touchApiKeyStmt.run(row.id); return { id: row.id, name: row.name, scopes: row.scopes.split(',') }; } export function listApiKeys() { return listApiKeysStmt.all(); } export function deleteApiKey(id) { return deleteApiKeyStmt.run(id).changes > 0; } // Laufzeit-Einstellungen (Settings-Seite im Webinterface) — überschreiben Env-Defaults db.exec('CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)'); const getSettingStmt = db.prepare('SELECT value FROM settings WHERE key = ?'); const setSettingStmt = db.prepare(` INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value `); export function getSetting(key) { return getSettingStmt.get(key)?.value ?? null; } export function setSetting(key, value) { setSettingStmt.run(key, value); } const insertCommit = db.prepare(` INSERT OR IGNORE INTO commits (sha, repo, branch, message, author_name, author_user, url, committed_at) VALUES (@sha, @repo, @branch, @message, @author_name, @author_user, @url, @committed_at) `); /** Commits eines Pushes speichern — bereits bekannte SHAs werden ignoriert. Gibt Anzahl neuer Zeilen zurück. */ export const saveCommits = db.transaction((commits) => { let inserted = 0; for (const commit of commits) { inserted += insertCommit.run(commit).changes; } return inserted; }); const insertDevlog = db.prepare(` INSERT OR IGNORE INTO devlogs (message_id, channel_id, content, author_name, posted_at, images) VALUES (@message_id, @channel_id, @content, @author_name, @posted_at, @images) `); const hasDevlogStmt = db.prepare('SELECT 1 FROM devlogs WHERE message_id = ?'); /** Devlog speichern — bereits bekannte Message-IDs werden ignoriert. Gibt true zurück, wenn neu. */ export function saveDevlog(devlog) { return insertDevlog.run({ images: '[]', ...devlog }).changes > 0; } /** true, wenn die Message schon archiviert ist (spart z. B. erneute Bild-Downloads) */ export function hasDevlog(messageId) { return Boolean(hasDevlogStmt.get(messageId)); } const getDevlogImagesStmt = db.prepare('SELECT images FROM devlogs WHERE message_id = ?'); const deleteDevlogStmt = db.prepare('DELETE FROM devlogs WHERE message_id = ?'); /** Devlog löschen; gibt { deleted, images } zurück (Dateinamen fürs Aufräumen) */ export function deleteDevlog(messageId) { const row = getDevlogImagesStmt.get(messageId); if (!row) return { deleted: false, images: [] }; deleteDevlogStmt.run(messageId); return { deleted: true, images: JSON.parse(row.images || '[]') }; } // --- Lese-Queries für die Web-API (Feature 4) --- const selectDevlogs = db.prepare(` SELECT message_id, content, author_name, posted_at, images FROM devlogs ORDER BY posted_at DESC LIMIT ? OFFSET ? `); const countDevlogsStmt = db.prepare('SELECT COUNT(*) AS n FROM devlogs'); export function listDevlogs(limit, offset) { return { items: selectDevlogs.all(limit, offset), total: countDevlogsStmt.get().n }; } const getDevlogStmt = db.prepare( 'SELECT message_id, content, author_name, posted_at, images FROM devlogs WHERE message_id = ?' ); export const getDevlog = (messageId) => getDevlogStmt.get(messageId) ?? null; const searchDevlogsStmt = db.prepare(` SELECT d.message_id, d.content, d.author_name, d.posted_at, d.images FROM devlogs_fts f JOIN devlogs d ON d.rowid = f.rowid WHERE devlogs_fts MATCH ? ORDER BY d.posted_at DESC LIMIT ? OFFSET ? `); const searchDevlogsCountStmt = db.prepare( 'SELECT count(*) AS n FROM devlogs_fts WHERE devlogs_fts MATCH ?' ); /** Suchbegriff in eine sichere FTS5-Query übersetzen: jede Eingabe als Präfix-Phrase */ function ftsQuery(input) { const terms = input.split(/\s+/).filter(Boolean).slice(0, 8); return terms.map((t) => `"${t.replaceAll('"', '')}"*`).join(' '); } export function searchDevlogs(input, limit, offset) { const query = ftsQuery(input); if (!query) return { items: [], total: 0 }; return { items: searchDevlogsStmt.all(query, limit, offset), total: searchDevlogsCountStmt.get(query).n, }; } const selectCommits = db.prepare(` SELECT sha, repo, branch, message, author_name, url, committed_at FROM commits ORDER BY committed_at DESC LIMIT ? OFFSET ? `); const countCommitsStmt = db.prepare('SELECT COUNT(*) AS n FROM commits'); export function listCommits(limit, offset) { return { items: selectCommits.all(limit, offset), total: countCommitsStmt.get().n }; } const upsertRelease = db.prepare(` INSERT INTO releases (repo, tag, name, body, url, prerelease, published_at) VALUES (@repo, @tag, @name, @body, @url, @prerelease, @published_at) ON CONFLICT(repo, tag) DO UPDATE SET name = excluded.name, body = excluded.body, url = excluded.url, prerelease = excluded.prerelease `); /** Release speichern/aktualisieren. Gibt true zurück, wenn neu (→ ankündigen). */ export function saveRelease(release) { const existed = db .prepare('SELECT 1 FROM releases WHERE repo = ? AND tag = ?') .get(release.repo, release.tag); upsertRelease.run(release); return !existed; } const selectReleases = db.prepare(` SELECT repo, tag, name, body, url, prerelease, published_at FROM releases ORDER BY published_at DESC LIMIT ? OFFSET ? `); const countReleasesStmt = db.prepare('SELECT count(*) AS n FROM releases'); export function listReleases(limit, offset) { return { items: selectReleases.all(limit, offset), total: countReleasesStmt.get().n }; } /** Zahlen der letzten 7 Tage für den Wochen-Rückblick */ export function weeklyStats() { const perDay = db .prepare(` SELECT date(committed_at) AS day, count(*) AS n FROM commits WHERE committed_at >= datetime('now', '-7 days') GROUP BY day `) .all(); const topRepos = db .prepare(` SELECT repo, count(*) AS n FROM commits WHERE committed_at >= datetime('now', '-7 days') GROUP BY repo ORDER BY n DESC LIMIT 3 `) .all(); const devlogs = db .prepare(`SELECT count(*) AS n FROM devlogs WHERE posted_at >= datetime('now', '-7 days')`) .get().n; return { perDay, topRepos, devlogs }; } /** Statistiken fürs Status-Panel der Settings-Seite */ export function archiveStats() { return { devlogs: countDevlogsStmt.get().n, commits: countCommitsStmt.get().n, dbSizeBytes: statSync(dbFile).size, }; }