Feature-Voting, Giveaways und Contribution-Heatmap

- /wunsch: Voting-Embed mit 👍, Reaction-Tracking (add/remove) in SQLite,
  Top-20-Rangliste öffentlich auf der Roadmap-Seite
- /giveaway (Admin): Preis/Dauer/Gewinnerzahl, 🎉-Teilnahme-Button (toggle),
  Minuten-Scheduler zieht Gewinner, schließt das Embed ab und pingt sie
- Contribution-Heatmap: 52-Wochen-Grid in Brand-Gelb aus dem Commit-Archiv
  (/api/heatmap) auf der Roadmap-Seite
- Setting voting_channel_id auf der Setup-Seite

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 13:20:44 +02:00
co-authored by Claude Opus 4.8
parent 075ea36bd2
commit bc000aaa9f
12 changed files with 391 additions and 4 deletions
+74
View File
@@ -178,6 +178,80 @@ export const listGallery = (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 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);
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();
}
// Modmail: DM-Konversationen ↔ Staff-Threads
db.exec(`
CREATE TABLE IF NOT EXISTS modmail (