Wuensche bekommen einen Ausgang statt nur einer Punktzahl
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled

Voting hatten wir. Was fehlte, war alles danach: ein Wunsch sammelte zwoelf
Stimmen und dann passierte sichtbar nie wieder etwas damit. Genau das nennt der
Artikel, der die Idee angestossen hat, "letting the board become a graveyard" —
und das war der Zustand.

Jeder Wunsch hat jetzt einen Stand: wird geprueft, geplant, in Arbeit,
umgesetzt, nicht geplant — mit Begruendung. Die steht oeffentlich unter dem
Wunsch auf der Roadmap und wird in den Discord-Post zurueckgeschrieben, wo
abgestimmt wurde. Ein abgelehnter Wunsch mit einem Satz Begruendung ist mehr
wert als einer, der ewig oben schwebt.

Springt ein Wunsch auf "umgesetzt", bekommt jeder eine Direktnachricht, der
dafuer gestimmt hat. Das ist der Punkt, an dem unser Aufbau einem fertigen
Voting-Dienst ueberlegen ist: der Draht zu jedem Einzelnen ist ohnehin offen.

## Zwei Sachen, die kaputt waren

Doppelt abstimmen ging. Der Zuspruch wurde von zwei Wegen hochgezaehlt — 👍 in
Discord und der Web-Knopf — und nur der Web-Weg merkte sich, wer geklickt hat.
Dieselbe Person zaehlte zweimal. Jetzt wird nicht mehr hochgezaehlt, sondern
gezaehlt: eine Zeile je Person, egal woher der Klick kam. Der zusammengesetzte
Schluessel schliesst den Fall aus, statt ihn nachtraeglich zu korrigieren.

Die Discord-Nachrichten-ID war der Primaerschluessel. Damit konnte ein Wunsch
nur existieren, solange seine Nachricht existiert, das Team konnte keinen von
Hand eintragen, und zwei Doppler liessen sich nicht zusammenfuehren. Wuensche
haben jetzt eine eigene ID; die Nachrichten-ID ist nur noch ein Verweis.

Die Migration erhaelt den Zuspruch: was vom alten Punktestand nicht auf
gespeicherte Web-Stimmen zurueckgeht, waren Reaktionen ohne Namen — die Zahl
bleibt als Sockel stehen, weil rueckwirkend niemand mehr feststellen kann, wer
das war. Durchgespielt gegen eine Datenbank im alten Aufbau: Punktestand
erhalten, verwaiste Stimmen fallen raus, ein zweiter Start migriert nicht
nochmal, und der Fall, in dem der alte Zaehler hinter den echten Stimmen
zurueckhing, zaehlt jetzt richtig.

## Dazu

Doppler zusammenfuehren: die Stimmen wandern zum Original, wer fuer beide
gestimmt hat, zaehlt dort weiterhin einmal. Beim Tippen im Wunsch-Feld zeigt die
Seite, was es schon gibt — ein Klick darauf stimmt mit, statt einen zweiten
gleichen Wunsch anzulegen. Und die Liste laesst sich nach Stand filtern.

Was ich bewusst nicht gebaut habe: Kommentare auf der Webseite. Jeder Wunsch
ist schon eine Discord-Nachricht — die Diskussion gehoert in den Thread
darunter und nicht in ein zweites System.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 23:18:29 +02:00
co-authored by Claude Opus 5
parent 1c5c7d2470
commit fb61e7279d
9 changed files with 634 additions and 77 deletions
+173 -35
View File
@@ -182,13 +182,6 @@ export const listGallery = (limit, offset) => ({
// 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,
@@ -204,41 +197,186 @@ db.exec(`
);
`);
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)
// Wünsche haben eine eigene ID. Früher war die Discord-Nachrichten-ID der
// Schlüssel — damit konnte ein Wunsch nur existieren, solange seine Nachricht
// existiert, das Team konnte keinen von Hand eintragen, und zwei Doppler ließen
// sich nicht zusammenführen.
//
// Der Zuspruch wird nicht mehr hochgezählt, sondern gezählt: eine Zeile je
// Person in wish_votes, egal ob der Klick im Web oder als 👍 in Discord kam.
// Der zusammengesetzte Schlüssel schließt Doppelstimmen damit aus, statt sie
// nachträglich zu korrigieren.
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)
CREATE TABLE IF NOT EXISTS wishes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id TEXT UNIQUE,
author TEXT,
author_id TEXT,
idea TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'offen',
status_grund TEXT,
status_at TEXT,
merged_into INTEGER,
-- Alter Zuspruch aus der Zeit, als Reaktionen nur gezählt und nicht
-- zugeordnet wurden. Rückwirkend ist nicht mehr feststellbar, wer das
-- war, also bleibt die Zahl als Sockel stehen.
bonus INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS wish_votes (
wish_id INTEGER NOT NULL,
user_id TEXT NOT NULL,
quelle TEXT NOT NULL DEFAULT 'web',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (wish_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_wish_votes_user ON wish_votes (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);
// Einmalige Übernahme aus dem alten Aufbau (message_id als Schlüssel, Zuspruch
// als hochgezählte Spalte). Läuft nur, solange die alte Form noch dasteht.
{
const spalten = db.prepare('PRAGMA table_info(wishes)').all().map((c) => c.name);
if (spalten.includes('score')) {
const umziehen = db.transaction(() => {
db.exec('ALTER TABLE wishes RENAME TO wishes_alt');
db.exec('ALTER TABLE wish_votes RENAME TO wish_votes_alt');
// Der Index oben wurde gerade mit umbenannt und blockiert sonst
// den gleichnamigen für die neue Tabelle
db.exec('DROP INDEX IF EXISTS idx_wish_votes_user');
db.exec(`
CREATE TABLE wishes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id TEXT UNIQUE,
author TEXT,
author_id TEXT,
idea TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'offen',
status_grund TEXT,
status_at TEXT,
merged_into INTEGER,
bonus INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE wish_votes (
wish_id INTEGER NOT NULL,
user_id TEXT NOT NULL,
quelle TEXT NOT NULL DEFAULT 'web',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (wish_id, user_id)
);
CREATE INDEX idx_wish_votes_user ON wish_votes (user_id);
`);
db.exec(`
INSERT INTO wishes (message_id, author, idea, created_at)
SELECT message_id, author, idea, created_at FROM wishes_alt
`);
// Web-Stimmen behalten ihre Person
db.exec(`
INSERT OR IGNORE INTO wish_votes (wish_id, user_id, quelle)
SELECT w.id, v.user_id, 'web'
FROM wish_votes_alt v JOIN wishes w ON w.message_id = v.message_id
`);
// Was vom alten Zuspruch übrig bleibt, waren Discord-Reaktionen
// ohne Namen — als Sockel erhalten, damit keine Zahl einbricht
db.exec(`
UPDATE wishes SET bonus = MAX(0, (
SELECT a.score - (SELECT COUNT(*) FROM wish_votes v WHERE v.wish_id = wishes.id)
FROM wishes_alt a WHERE a.message_id = wishes.message_id
))
`);
db.exec('DROP TABLE wish_votes_alt');
db.exec('DROP TABLE wishes_alt');
});
umziehen();
const n = db.prepare('SELECT count(*) AS n FROM wishes').get().n;
console.log(`[db] ${n} Wunsch/Wünsche auf eigene IDs umgestellt`);
}
}
/** Zuspruch = eigene Stimmen + nicht mehr zuordenbarer Sockel aus der Altzeit */
const WUNSCH_FELDER = `
w.id, w.message_id, w.idea, w.author, w.author_id, w.status, w.status_grund,
w.status_at, w.created_at,
w.bonus + (SELECT COUNT(*) FROM wish_votes v WHERE v.wish_id = w.id) AS score
`;
const insertWish = db.prepare(
'INSERT INTO wishes (message_id, author, author_id, idea) VALUES (?, ?, ?, ?)'
);
const setWishMessageStmt = db.prepare('UPDATE wishes SET message_id = ? WHERE id = ?');
const wishByMessageStmt = db.prepare('SELECT * FROM wishes WHERE message_id = ?');
const wishByIdStmt = db.prepare(`SELECT ${WUNSCH_FELDER} FROM wishes w WHERE w.id = ?`);
// Zusammengeführte Doppler tauchen nirgends mehr auf — ihre Stimmen stehen beim Original
const topWishesStmt = db.prepare(`
SELECT ${WUNSCH_FELDER} FROM wishes w
WHERE w.merged_into IS NULL
ORDER BY score DESC, w.created_at DESC LIMIT ?
`);
const searchWishesStmt = db.prepare(`
SELECT ${WUNSCH_FELDER} FROM wishes w
WHERE w.merged_into IS NULL AND w.idea LIKE ?
ORDER BY score DESC, w.created_at DESC LIMIT ?
`);
const deleteWishStmt = db.prepare('DELETE FROM wishes WHERE id = ?');
const setWishStatusStmt = db.prepare(`
UPDATE wishes SET status = ?, status_grund = ?, status_at = datetime('now') WHERE id = ?
`);
const mergeWishStmt = db.prepare('UPDATE wishes SET merged_into = ? WHERE id = ?');
const moveVotesStmt = db.prepare(
'INSERT OR IGNORE INTO wish_votes (wish_id, user_id, quelle) SELECT ?, user_id, quelle FROM wish_votes WHERE wish_id = ?'
);
const dropVotesStmt = db.prepare('DELETE FROM wish_votes WHERE wish_id = ?');
export const saveWish = (messageId, author, idea, authorId = null) =>
insertWish.run(messageId, author, authorId, idea).lastInsertRowid;
export const setWishMessage = (id, messageId) => setWishMessageStmt.run(messageId, id);
export const wishByMessage = (messageId) => wishByMessageStmt.get(messageId) ?? null;
export const getWish = (id) => wishByIdStmt.get(id) ?? null;
export const topWishes = (limit = 20) => topWishesStmt.all(limit);
export const searchWishes = (text, limit = 8) => searchWishesStmt.all(`%${text}%`, limit);
export const deleteWish = (id) => {
dropVotesStmt.run(id);
return deleteWishStmt.run(id).changes > 0;
};
export const setWishStatus = (id, status, grund) =>
setWishStatusStmt.run(status, grund || null, id).changes > 0;
/**
* Doppler auf das Original zeigen lassen. Die Stimmen wandern mit; wer für
* beide gestimmt hat, zählt beim Original weiterhin einmal (INSERT OR IGNORE
* auf dem zusammengesetzten Schlüssel).
*/
export const mergeWish = db.transaction((doppler, original) => {
moveVotesStmt.run(original, doppler);
// Der Sockel aus der Altzeit wandert mit, sonst verschwindet Zuspruch
const alt = db.prepare('SELECT bonus FROM wishes WHERE id = ?').get(doppler)?.bonus ?? 0;
if (alt > 0) db.prepare('UPDATE wishes SET bonus = bonus + ? WHERE id = ?').run(alt, original);
dropVotesStmt.run(doppler);
mergeWishStmt.run(original, doppler);
});
const hasWishVoteStmt = db.prepare('SELECT 1 FROM wish_votes WHERE wish_id = ? AND user_id = ?');
const insertWishVote = db.prepare(
'INSERT OR IGNORE INTO wish_votes (wish_id, user_id, quelle) VALUES (?, ?, ?)'
);
const deleteWishVote = db.prepare('DELETE FROM wish_votes WHERE wish_id = ? AND user_id = ?');
const myWishVotesStmt = db.prepare('SELECT wish_id FROM wish_votes WHERE user_id = ?');
const wishVotersStmt = db.prepare('SELECT user_id FROM wish_votes WHERE wish_id = ?');
/** Stimme umschalten. @returns {boolean} true = zählt jetzt */
export function toggleWishVote(wishId, userId, quelle = 'web') {
if (hasWishVoteStmt.get(wishId, userId)) {
deleteWishVote.run(wishId, userId);
return false;
}
insertWishVote.run(messageId, userId);
wishScoreStmt.run(1, messageId);
insertWishVote.run(wishId, userId, quelle);
return true;
}
export const myWishVotes = (userId) => myWishVotesStmt.all(userId).map((r) => r.message_id);
export const addWishVote = (wishId, userId, quelle = 'web') =>
insertWishVote.run(wishId, userId, quelle).changes > 0;
export const removeWishVote = (wishId, userId) => deleteWishVote.run(wishId, userId).changes > 0;
export const myWishVotes = (userId) => myWishVotesStmt.all(userId).map((r) => r.wish_id);
export const wishVoters = (wishId) => wishVotersStmt.all(wishId).map((r) => r.user_id);
const insertGiveaway = db.prepare(`
INSERT INTO giveaways (message_id, channel_id, prize, winners, ends_at)