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
+107 -9
View File
@@ -15,6 +15,7 @@ import {
saveWebAdmin, webAdminScopes, listWebAdmins, deleteWebAdmin,
saveTemplate, listTemplates, deleteTemplate,
getLevelRow, isPlaytester, saveWish, toggleWishVote, myWishVotes,
getWish, searchWishes, setWishStatus, mergeWish, deleteWish, wishVoters,
logAudit, listAudit, playerHistory, uptimeBuckets, watchdogBuckets, watchdogDays,
heartbeatBuckets, heartbeatFirst,
freeAlphaKeys, deleteFreeAlphaKey, removePlaytester,
@@ -186,7 +187,7 @@ export function registerApiRoutes(app, client) {
const user = getSessionUser(request);
const mine = user ? new Set(myWishVotes(user.id)) : new Set();
return {
wishes: topWishes(20).map((w) => ({ ...w, voted: mine.has(w.message_id) })),
wishes: topWishes(50).map((w) => ({ ...w, voted: mine.has(w.id) })),
canVote: Boolean(user),
};
});
@@ -1947,19 +1948,116 @@ ${rssItems}
});
const message = await channel.send({ embeds: [embed] });
await message.react('👍').catch(() => {});
saveWish(message.id, user.username, idea);
return { ok: true };
const id = saveWish(message.id, user.username, idea, user.id);
return { ok: true, id };
});
// Web-Vote togglen (unabhängig von Discord-👍)
app.post('/api/wishes/:messageId/vote', async (request, reply) => {
// Eine Stimme je Person — ob hier geklickt oder als 👍 in Discord, ist
// dieselbe Zeile in wish_votes
app.post('/api/wishes/:id/vote', async (request, reply) => {
if (await requireMember(request, reply)) return;
const user = getSessionUser(request);
const messageId = String(request.params.messageId);
if (!topWishes(1000).some((w) => w.message_id === messageId)) {
return reply.code(404).send({ error: 'Wunsch nicht gefunden' });
const id = Number(request.params.id);
const wunsch = getWish(id);
if (!wunsch) return reply.code(404).send({ error: 'Wunsch nicht gefunden' });
return { voted: toggleWishVote(id, user.id) };
});
// Suche vor dem Einreichen — Doppler gar nicht erst entstehen lassen
app.get('/api/wishes/suche', async (request) => {
const q = String(request.query?.q ?? '').trim();
return { treffer: q.length >= 3 ? searchWishes(q) : [] };
});
// --- Wünsche verwalten (Team) ---
const STATUS = {
offen: { label: 'Wird geprüft', emoji: '💡' },
geplant: { label: 'Geplant', emoji: '📌' },
in_arbeit: { label: 'In Arbeit', emoji: '🔨' },
umgesetzt: { label: 'Umgesetzt', emoji: '✅' },
abgelehnt: { label: 'Nicht geplant', emoji: '🚫' },
};
/**
* Den Wunsch-Post in Discord auf den neuen Stand bringen. Dort haben die
* Leute abgestimmt, dort gehört die Antwort hin — die Webseite allein
* erreicht die meisten nie.
*/
async function wunschPostAktualisieren(wunsch) {
if (!wunsch.message_id) return;
const channelId = votingChannelId();
if (!channelId) return;
const channel = await client.channels.fetch(channelId).catch(() => null);
const message = await channel?.messages?.fetch(wunsch.message_id).catch(() => null);
if (!message?.embeds?.[0]) return;
const s = STATUS[wunsch.status] ?? STATUS.offen;
const embed = EmbedBuilder.from(message.embeds[0])
.setTitle(`${s.emoji} Feature-Wunsch — ${s.label}`);
const felder = wunsch.status_grund
? [{ name: 'Vom Team', value: String(wunsch.status_grund).slice(0, 1000) }]
: [];
embed.setFields(felder);
await message.edit({ embeds: [embed] }).catch(() => {});
}
app.put('/api/wishes/:id/status', async (request, reply) => {
if (requireScope(request, reply, 'community')) return;
const id = Number(request.params.id);
const status = String(request.body?.status ?? '');
if (!STATUS[status]) return reply.code(400).send({ error: 'Unbekannter Status.' });
const vorher = getWish(id);
if (!vorher) return reply.code(404).send({ error: 'Wunsch nicht gefunden.' });
setWishStatus(id, status, String(request.body?.grund ?? '').trim().slice(0, 500));
const wunsch = getWish(id);
await wunschPostAktualisieren(wunsch);
// Kreis schliessen: wer dafür gestimmt hat, erfährt es — aber nur beim
// Sprung auf umgesetzt, und nur einmal (vorher war ein anderer Status)
let benachrichtigt = 0;
if (status === 'umgesetzt' && vorher.status !== 'umgesetzt') {
const empfaenger = new Set(wishVoters(id));
if (wunsch.author_id) empfaenger.add(wunsch.author_id);
for (const userId of empfaenger) {
const user = await client.users.fetch(userId).catch(() => null);
if (!user) continue;
const ok = await user.send(
`✅ **Umgesetzt!** Der Wunsch, für den du gestimmt hast, ist jetzt drin:\n`
+ `> ${String(wunsch.idea).slice(0, 300)}`
+ (wunsch.status_grund ? `\n\n${wunsch.status_grund}` : '')
).then(() => true).catch(() => false);
if (ok) benachrichtigt++;
}
}
return { voted: toggleWishVote(messageId, user.id) };
logAudit(getSessionUser(request), `wunsch ${status}`, String(wunsch.idea).slice(0, 60));
return { ok: true, benachrichtigt, wishes: topWishes(50) };
});
// Doppler zusammenführen: Stimmen wandern zum Original, wer für beide
// gestimmt hat, zählt dort weiterhin einmal
app.post('/api/wishes/:id/merge', async (request, reply) => {
if (requireScope(request, reply, 'community')) return;
const doppler = Number(request.params.id);
const original = Number(request.body?.original);
if (!getWish(doppler) || !getWish(original)) {
return reply.code(404).send({ error: 'Wunsch nicht gefunden.' });
}
if (doppler === original) {
return reply.code(400).send({ error: 'Ein Wunsch kann nicht sein eigener Doppler sein.' });
}
mergeWish(doppler, original);
logAudit(getSessionUser(request), 'wünsche zusammengeführt', `#${doppler} → #${original}`);
return { ok: true, wishes: topWishes(50) };
});
app.delete('/api/wishes/:id', async (request, reply) => {
if (requireScope(request, reply, 'community')) return;
const wunsch = getWish(Number(request.params.id));
if (!wunsch) return { deleted: false, wishes: topWishes(50) };
deleteWish(wunsch.id);
logAudit(getSessionUser(request), 'wunsch gelöscht', String(wunsch.idea).slice(0, 60));
return { deleted: true, wishes: topWishes(50) };
});
// --- Team-Verwaltung (nur Owner) ---