Files
d4rkbot/src/db.js
T
D4rkst3randClaude Fable 5 1e2d295665
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
Erreichbarkeits-Balken auf der Server-Seite
Ein Strich pro Stunde ueber 7 Tage, gruen/gelb/rot je nachdem, ob in der Stunde
alle, ein Teil oder keine Messung erreichbar war.

Die Daten lagen laengst da: player_history haelt 7 Tage vor, die API schnitt
aber auf 24 Stunden ab. Statt Rohdaten (ueber eine Woche rund 5000 Zeilen pro
Server) fasst uptimeBuckets() in SQL stundenweise zusammen — hoechstens 168
Zeilen.

- Stunden ohne Messung werden als Luecke gezeichnet, nicht als "online". Sonst
  saehe eine Bot-Auszeit aus wie ein einwandfrei laufender Server.
- Die alte 24h-Uptime-Zahl neben dem Peak ist raus: zweimal dieselbe Groesse
  ueber verschiedene Zeitraeume verwirrt nur.
- Tooltip zeigt Ortszeit. Die Eimer werden in UTC abgeglichen (SQLite rechnet
  so), angezeigt wird lokal — sonst stuende dort eine Stunde, die niemand
  wiedererkennt.

Zwei Fehler beim Bauen gefunden und behoben:
- .srv-card ist ein Grid-Item mit min-width:auto und wuchs durch die 168
  Striche auf 724 statt 335 px, die Karte sprengte auf dem Handy das Raster.
- "Uhr" stand hart deutsch im Tooltip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 04:09:19 +02:00

1138 lines
51 KiB
JavaScript

// 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<string, number>} 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;
}
// Frei anlegbare Seiten (Regeln, Über uns, Mitmachen …) — Inhalte pflegen
// ohne Code-Änderung. Rechtstexte laufen bewusst separat.
db.exec(`
CREATE TABLE IF NOT EXISTS pages (
slug TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
published INTEGER NOT NULL DEFAULT 0,
in_menu INTEGER NOT NULL DEFAULT 1,
sort INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
const upsertPage = db.prepare(`
INSERT INTO pages (slug, title, content, published, in_menu, sort, updated_at)
VALUES (@slug, @title, @content, @published, @in_menu, @sort, datetime('now'))
ON CONFLICT(slug) DO UPDATE SET
title = excluded.title, content = excluded.content,
published = excluded.published, in_menu = excluded.in_menu,
sort = excluded.sort, updated_at = datetime('now')
`);
const getPageStmt = db.prepare('SELECT * FROM pages WHERE slug = ?');
const listPagesStmt = db.prepare('SELECT * FROM pages ORDER BY sort, title');
const listPublishedPagesStmt = db.prepare(
'SELECT slug, title, in_menu, sort FROM pages WHERE published = 1 ORDER BY sort, title'
);
const deletePageStmt = db.prepare('DELETE FROM pages WHERE slug = ?');
export const savePage = (p) => upsertPage.run(p);
export const getPage = (slug) => getPageStmt.get(slug) ?? null;
export const listPages = () => listPagesStmt.all();
export const listPublishedPages = () => listPublishedPagesStmt.all();
export const deletePage = (slug) => deletePageStmt.run(slug).changes > 0;
// 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')`);
// Erreichbarkeit stundenweise zusammengefasst — für den Uptime-Balken.
// Die Rohdaten (alle 2 min) wären über eine Woche rund 5000 Zeilen pro Server;
// als Stundeneimer sind es höchstens 168 und die Seite bleibt schlank.
const uptimeBucketsStmt = db.prepare(`
SELECT strftime('%Y-%m-%dT%H', ts) AS stunde,
COUNT(*) AS proben,
SUM(online) AS an
FROM player_history
WHERE server_id = ? AND ts >= datetime('now', ?)
GROUP BY stunde ORDER BY stunde
`);
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 uptimeBuckets = (serverId, hours = 168) => uptimeBucketsStmt.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 };
}
// Nach Projekt filtern. Das Projekt steht in der ersten Zeile („Devlog — X"),
// es gibt dafür keine eigene Spalte — LIKE auf den Anfang reicht und kommt
// ohne Migration aus.
const selectDevlogsByProject = db.prepare(`
SELECT message_id, content, author_name, posted_at, images
FROM devlogs WHERE content LIKE ? ESCAPE '!' ORDER BY posted_at DESC LIMIT ? OFFSET ?
`);
const countDevlogsByProject = db.prepare(
"SELECT COUNT(*) AS n FROM devlogs WHERE content LIKE ? ESCAPE '!'"
);
export function listDevlogsByProject(project, limit, offset) {
// Escape-Zeichen ist `!` und nicht der übliche Backslash: der müsste hier
// zweimal entwertet werden (einmal für JavaScript, einmal für SQL) und
// landet dadurch leicht als leere Zeichenkette in der Abfrage.
const escaped = String(project).replace(/[!%_]/g, (c) => `!${c}`);
const pattern = `Devlog — ${escaped}%`;
return {
items: selectDevlogsByProject.all(pattern, limit, offset),
total: countDevlogsByProject.get(pattern).n,
};
}
/** Erste Zeile aller Devlogs — daraus leitet die API die Projektliste ab */
const selectDevlogHeadsStmt = db.prepare('SELECT content FROM devlogs');
export const devlogHeads = () =>
selectDevlogHeadsStmt.all().map((r) => String(r.content).split('\n', 1)[0]);
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,
};
}