setIdea(e.target.value)}
- onKeyDown={(e) => { if (e.key === 'Enter') submitWish(); }}
- />
-
diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx
index e58c6b2..9309f22 100644
--- a/frontend/src/pages/Settings.jsx
+++ b/frontend/src/pages/Settings.jsx
@@ -76,6 +76,16 @@ const TABS = [
find: 'mitarbeiter rechte bereiche protokoll audit zugriff' },
];
+// Stand eines Wunsches. Reihenfolge = Weg durchs Verfahren, damit die Knöpfe
+// in der Ansicht so nebeneinander liegen, wie man sie durchläuft.
+const WUNSCH_STATUS = [
+ ['offen', 'Wird geprüft'],
+ ['geplant', 'Geplant'],
+ ['in_arbeit', 'In Arbeit'],
+ ['umgesetzt', 'Umgesetzt'],
+ ['abgelehnt', 'Nicht geplant'],
+];
+
// Tabs, die nur aus Formular-Blöcken bestehen — die dürfen zweispaltig laufen.
// Alles mit Editor, Vorschau oder Liste bleibt einspaltig.
const DENSE_TABS = new Set(['feeds', 'community', 'support', 'system']);
@@ -351,6 +361,10 @@ export default function Settings({ me }) {
const [watchdog, setWatchdog] = useState(null);
const [automod, setAutomod] = useState(null);
const [anliegen, setAnliegen] = useState([]);
+ const [wuensche, setWuensche] = useState([]);
+ const [statusDialog, setStatusDialog] = useState(null);
+ const [statusGrund, setStatusGrund] = useState('');
+ const [mergeVon, setMergeVon] = useState(null);
const [anliegenDraft, setAnliegenDraft] = useState({ id: null, name: '', emoji: '🎫', beschreibung: '', ping_role_id: '', intro: '' });
const [dienstDraft, setDienstDraft] = useState({ id: null, name: '', url: '', gruppe: 'Dienste', oeffentlich: false });
const [owner, setOwner] = useState(null);
@@ -481,6 +495,7 @@ export default function Settings({ me }) {
apiGet('/api/watchdog').then(setWatchdog).catch(() => {});
apiGet('/api/automod').then(setAutomod).catch(() => {});
apiGet('/api/ticket-categories').then((d) => setAnliegen(d.kategorien ?? [])).catch(() => {});
+ apiGet('/api/wishes').then((d) => setWuensche(d.wishes ?? [])).catch(() => {});
apiGet('/api/ssoapps').then((d) => setSsoApps(d.apps)).catch(() => {});
apiGet('/api/services').then((d) => setServices(d.services)).catch(() => {});
apiGet('/api/pages/all/list').then((d) => setPages(d.pages)).catch(() => {});
@@ -1754,6 +1769,102 @@ export default function Settings({ me }) {
const tabCommunity = (
<>
+
// Geburtstage
@@ -1969,6 +2080,48 @@ export default function Settings({ me }) {
>
);
+ /** Status setzen — mit Begruendung, denn genau die fehlt sonst */
+ function wunschStatus(wunsch, status) {
+ const label = Object.fromEntries(WUNSCH_STATUS)[status];
+ setStatusGrund(wunsch.status === status ? (wunsch.status_grund ?? '') : '');
+ setStatusDialog({ wunsch, status, label });
+ }
+
+ async function statusSpeichern() {
+ const { wunsch, status } = statusDialog;
+ try {
+ const res = await apiPut(`/api/wishes/${wunsch.id}/status`, { status, grund: statusGrund });
+ setWuensche(res.wishes ?? []);
+ setStatusDialog(null);
+ flash(res.benachrichtigt
+ ? `✓ Gespeichert — ${res.benachrichtigt} benachrichtigt`
+ : '✓ Gespeichert');
+ } catch (e) {
+ flash(`✗ ${e.body?.error ?? 'Fehlgeschlagen'}`);
+ }
+ }
+
+ async function zusammenfuehren(doppler, original) {
+ try {
+ const res = await apiPost(`/api/wishes/${doppler.id}/merge`, { original: original.id });
+ setWuensche(res.wishes ?? []);
+ setMergeVon(null);
+ flash('✓ Zusammengeführt');
+ } catch (e) {
+ flash(`✗ ${e.body?.error ?? 'Fehlgeschlagen'}`);
+ }
+ }
+
+ async function wunschLoeschen(wunsch) {
+ if (!window.confirm(`„${wunsch.idea}" löschen? Die Stimmen gehen mit.`)) return;
+ try {
+ const res = await apiDelete(`/api/wishes/${wunsch.id}`);
+ setWuensche(res.wishes ?? []);
+ } catch {
+ flash('✗ Löschen fehlgeschlagen');
+ }
+ }
+
async function anliegenSpeichern() {
const body = { ...anliegenDraft };
try {
diff --git a/frontend/src/style.css b/frontend/src/style.css
index 08b2e57..19309f2 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -466,9 +466,50 @@ body::after {
font-family: var(--mono); font-size: .78rem;
color: var(--neon); white-space: nowrap; min-width: 3.5rem;
}
-.wish-idea { flex: 1; font-weight: 300; }
+.wish-idea { flex: 1; font-weight: 300; min-width: 0; }
.wish-author { font-family: var(--mono); font-size: .62rem; color: var(--muted2); white-space: nowrap; }
+/* Begründung des Teams unter der Idee — der Teil, der einen abgelehnten
+ Wunsch von einem vergessenen unterscheidet */
+.wish-grund {
+ display: block; margin-top: .3rem;
+ font-size: .82rem; color: var(--muted);
+ border-left: 2px solid var(--border); padding-left: .6rem;
+}
+.wish-status {
+ font-family: var(--mono); font-size: .58rem; letter-spacing: .1em;
+ text-transform: uppercase; white-space: nowrap;
+ padding: .18rem .5rem; border-radius: 999px; border: 1px solid;
+}
+.wish-status.geplant { color: var(--neon); border-color: color-mix(in srgb, var(--neon) 45%, transparent); }
+.wish-status.in_arbeit { color: var(--neon2, var(--neon)); border-color: color-mix(in srgb, var(--neon2, var(--neon)) 45%, transparent); }
+.wish-status.umgesetzt { color: #3ba55d; border-color: rgba(59, 165, 93, .45); }
+.wish-status.abgelehnt { color: var(--muted2); border-color: var(--border); }
+/* Erledigtes tritt zurück, ohne zu verschwinden */
+.wish-row.st-umgesetzt, .wish-row.st-abgelehnt { opacity: .72; }
+
+.wish-filter { display: flex; flex-wrap: wrap; gap: .4rem; margin-bottom: .9rem; }
+.wish-chip {
+ background: var(--bg2); border: 1px solid var(--border); border-radius: 999px;
+ color: var(--muted); font-family: var(--mono); font-size: .62rem;
+ letter-spacing: .06em; padding: .3rem .7rem; cursor: pointer;
+ transition: border-color .15s, color .15s;
+}
+.wish-chip:hover { color: var(--text); }
+.wish-chip.aktiv { border-color: var(--neon); color: var(--neon); }
+.wish-chip-n { opacity: .6; }
+
+/* Treffer beim Tippen: lieber mitstimmen als denselben Wunsch nochmal stellen */
+.wish-hits { display: flex; flex-direction: column; gap: .3rem; margin-top: .6rem; }
+.wish-hits-label { font-family: var(--mono); font-size: .62rem; color: var(--muted2); }
+.wish-hit {
+ display: flex; align-items: center; gap: .45rem; text-align: left;
+ background: var(--bg3); border: 1px solid var(--border); border-radius: 8px;
+ color: var(--muted); font: inherit; font-size: .84rem;
+ padding: .4rem .7rem; cursor: pointer;
+}
+.wish-hit:hover { border-color: var(--neon); color: var(--neon); }
+
/* ── GALERIE ───────────────────────────────────────── */
.gallery-grid {
display: grid;
@@ -1888,6 +1929,16 @@ label.toggle {
.pt-key { font-family: var(--mono); font-size: .74rem; color: var(--neon); }
.pt-wartet { font-family: var(--mono); font-size: .66rem; color: var(--muted2); }
+/* Wunsch-Verwaltung im Panel */
+.wu-row { padding: .8rem 1rem; margin-bottom: .6rem; min-width: 0; }
+.wu-head { display: flex; align-items: baseline; gap: .8rem; }
+.wu-idee { flex: 1; min-width: 0; font-size: .92rem; }
+.wu-aktionen { display: flex; align-items: center; gap: .35rem; flex-wrap: wrap; margin-top: .6rem; }
+.wu-grund {
+ margin: .55rem 0 0; font-size: .82rem; color: var(--muted);
+ border-left: 2px solid var(--border); padding-left: .6rem;
+}
+
/* ── DIALOG ────────────────────────────────────────── */
.dlg-backdrop {
position: fixed; inset: 0; z-index: 1200;
diff --git a/src/bot/client.js b/src/bot/client.js
index 3cb01dd..4df673f 100644
--- a/src/bot/client.js
+++ b/src/bot/client.js
@@ -22,7 +22,7 @@ import {
import { handleRoleMenuButton } from './role-menus.js';
import { handleGiveawayAdminButton } from './giveaways.js';
import {
- removePlaytester, playtesterForm, isWish, bumpWish,
+ removePlaytester, playtesterForm, wishByMessage, addWishVote, removeWishVote,
getGiveaway, toggleGiveawayEntry, giveawayEntries,
} from '../db.js';
import * as ping from './commands/ping.js';
@@ -123,18 +123,24 @@ export async function startBot() {
}
});
- // 👍-Reaktionen auf Feature-Wünsche zählen (für die Rangliste auf der Webseite)
- const trackWishReaction = (delta) => async (reaction) => {
+ // 👍-Reaktionen auf Feature-Wünsche zählen — jetzt mit Namen dran. Früher
+ // wurde nur hochgezählt; wer im Web und in Discord geklickt hat, zählte
+ // zweimal. Eine Zeile je Person schließt das aus.
+ const trackWishReaction = (dabei) => async (reaction, user) => {
try {
if (reaction.emoji.name !== '👍') return;
+ if (user?.bot || !user?.id) return;
if (reaction.partial) await reaction.fetch().catch(() => {});
- if (isWish(reaction.message.id)) bumpWish(reaction.message.id, delta);
+ const wunsch = wishByMessage(reaction.message.id);
+ if (!wunsch) return;
+ if (dabei) addWishVote(wunsch.id, user.id, 'discord');
+ else removeWishVote(wunsch.id, user.id);
} catch (error) {
console.error('[voting] Reaction-Tracking fehlgeschlagen:', error);
}
};
- client.on(Events.MessageReactionAdd, trackWishReaction(1));
- client.on(Events.MessageReactionRemove, trackWishReaction(-1));
+ client.on(Events.MessageReactionAdd, trackWishReaction(true));
+ client.on(Events.MessageReactionRemove, trackWishReaction(false));
// In Discord gelöscht = im Archiv gelöscht (inkl. lokaler Bilder)
client.on(Events.MessageDelete, async (message) => {
diff --git a/src/db.js b/src/db.js
index 39a9323..b1a9312 100644
--- a/src/db.js
+++ b/src/db.js
@@ -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)
diff --git a/src/web/api.js b/src/web/api.js
index 1babbe8..f016353 100644
--- a/src/web/api.js
+++ b/src/web/api.js
@@ -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) ---