From edf8fa14c5fce1fdd12c2408857695a947f1240c Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Sat, 1 Aug 2026 23:31:55 +0200 Subject: [PATCH] Ideen bekommen Bereiche und einen Thread zum Reden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nach dem Vorbild von windrose.support: Bereiche zum Einsortieren, Sortierung nach Top / Bewegung / Neu, und eine Zahl daneben, wie viel darueber geredet wird. Der Unterschied zu so einem Dienst ist, wo geredet wird. Zu jedem Wunsch macht der Bot jetzt einen Thread unter dem Post auf. Der Kommentar-Zaehler auf der Roadmap zaehlt die Beitraege darin und verlinkt hinein — es gibt also einen Zaehler wie bei einem Ideen-Board, aber ohne ein zweites Kommentarsystem neben Discord zu stellen. Genau das hatte ich beim letzten Mal als das Falsche bezeichnet, und dabei bleibt es. Eingereicht wird jetzt auch ueber einen Aufruf-Post: /wunsch-setup postet ihn, und mit Bereichen wird daraus ein Auswahlmenue — erst wohin, dann das Formular. Ohne Bereiche bleibt es beim einen Knopf. Derselbe Aufbau wie bei den Ticket-Anliegen, bis hin zur Emoji-Pruefung, die ich mir dort schon geschrieben hatte. Das Anlegen selbst ist nach bot/wishes.js gewandert. /wunsch, der Knopf und die Webseite gehen jetzt denselben Weg — vorher hatte die Webseite ihr eigenes Embed zusammengebaut, das dem aus dem Befehl nur aehnlich sah und keinen Thread bekam. "Bewegung" sind die Stimmen der letzten sieben Tage. Die Rangliste allein zementiert alte Wuensche: was einmal oben steht, bleibt oben, egal ob noch jemand hinschaut. Sortiert wird auf dem Server, weil die Zeitstempel der Stimmen im Browser gar nicht ankommen. Bewusst nicht uebernommen: Gegenstimmen. Windrose hat sie, aber bei einer Community dieser Groesse laden sie zum Nachtreten ein und schrecken vom Einreichen ab — und eine Zahl, die aus zwei Richtungen kommt, sagt am Ende weniger als eine, die nur zaehlt, wer etwas will. Geprueft: Migration erneut durchgespielt (das Schema hat drei Spalten dazubekommen), Routen, SQL gegen das Schema, und im Browser alle drei Filterreihen einzeln und kombiniert, der Sortierwechsel, die Thread-Links und die Bereichs-Pflege im Panel. Co-Authored-By: Claude Opus 5 --- README.md | 3 +- frontend/src/locales/de.js | 7 ++ frontend/src/locales/en.js | 7 ++ frontend/src/pages/Roadmap.jsx | 136 ++++++++++++++++++++++----- frontend/src/pages/Settings.jsx | 88 +++++++++++++++++- frontend/src/style.css | 26 +++++- src/bot/client.js | 26 +++++- src/bot/commands/wunsch-setup.js | 39 ++++++++ src/bot/commands/wunsch.js | 94 ++++++------------- src/bot/wishes.js | 154 +++++++++++++++++++++++++++++++ src/db.js | 85 ++++++++++++++--- src/templates.js | 16 ++++ src/web/api.js | 104 ++++++++++++++++----- 13 files changed, 657 insertions(+), 128 deletions(-) create mode 100644 src/bot/commands/wunsch-setup.js create mode 100644 src/bot/wishes.js diff --git a/README.md b/README.md index 3bc2f66..171c3b7 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ auf nichts mehr: keine Posts, keine Hintergrund-Prüfungen. | 📈 **Level-System** | XP pro Nachricht (Cooldown, MEE6-Formel), `/rank`, Level-Up-Announce, Rollen-Belohnungen, öffentliche Bestenliste | | ⭐ **Starboard** | Nachrichten mit genug ⭐-Reaktionen landen im Best-of-Kanal | | 📸 **Screenshot-Galerie** | Bilder aus dem Screenshot-Kanal → öffentliche Galerie (lokal gespeichert, weil Discord-CDN-Links ablaufen) | -| 💡 **Feature-Voting** | `/wunsch` öffnet ein Eingabefenster → Voting-Post mit 👍; Wünsche mit **Stand** (wird geprüft · geplant · in Arbeit · umgesetzt · nicht geplant) samt Begründung auf der Roadmap und im Discord-Post. Wer für einen Wunsch gestimmt hat, bekommt eine DM, sobald er umgesetzt ist. Eine Stimme je Person, egal ob 👍 oder Web-Knopf; Doppler lassen sich zusammenführen | +| 💡 **Feature-Voting** | `/wunsch-setup` postet den Ideen-Aufruf: Bereich wählen, Formular ausfüllen → Voting-Post mit 👍 und **Thread zum Reden**. Wünsche haben einen **Stand** (wird geprüft · geplant · in Arbeit · umgesetzt · nicht geplant) samt Begründung — auf der Roadmap und im Discord-Post. Wer gestimmt hat, bekommt eine DM, sobald es umgesetzt ist. Die Roadmap filtert nach Stand und Bereich, sortiert nach Top / Bewegung / Neu und zeigt die Zahl der Beiträge im Thread. Eine Stimme je Person, egal ob 👍 oder Web-Knopf; Doppler lassen sich zusammenführen | | 🗳️ **Umfragen** | `/umfrage` erzeugt eine **native Discord-Poll** — keine Reaktions-Bastelei, Discord zählt selbst | | 🎉 **Giveaways** | `/giveaway` (Admin): Teilnahme-Button, automatische Ziehung; am Gewinner-Post 🔁 Neu auslosen + 👥 Teilnehmerliste | | 🎂 **Geburtstage** | `/geburtstag` zum Eintragen; morgens Gratulation im Kanal + Tages-Rolle | @@ -454,6 +454,7 @@ urllib.request.urlopen(req) | `GET /api/legal` · `DELETE /api/profile` | — / Member | Impressums-Angaben · DSGVO-Löschung | | `GET /api/profile` · `/api/myroles*` · `POST /api/wishes*` | Member | Profil · Rollen-Selfservice · Wunsch einreichen/voten | | `PUT /api/wishes/:id/status` · `POST /api/wishes/:id/merge` | Team (community) | Stand setzen (+ DM an Stimmen-Geber) · Doppler zusammenführen | +| `GET/POST/PUT/DELETE /api/wish-categories` | Team (community) | Bereiche für Wünsche pflegen | | `GET /linked-roles` · `/linked-roles/callback` | Member | Discords Verknüpfungs-Ablauf für Linked Roles | | `GET /sso/authorize` · `POST /sso/verify` | App-Secret | **Single Sign-On** für andere Dienste — siehe [docs/sso.md](docs/sso.md) | | `POST/GET /api/v1/*` | API-Key (Bearer) | Externe Skripte — siehe „API v1" oben | diff --git a/frontend/src/locales/de.js b/frontend/src/locales/de.js index 03b60fa..4edaee6 100644 --- a/frontend/src/locales/de.js +++ b/frontend/src/locales/de.js @@ -120,6 +120,13 @@ export default { 'roadmap.activity': '// Aktivität — %s Commits in 12 Monaten', 'roadmap.commitsOn': '%s: %s Commits', 'roadmap.commitOn': '%s: 1 Commit', + 'roadmap.sort': 'Sortierung', + 'roadmap.sortTop': 'Top', + 'roadmap.sortTrend': 'Bewegung', + 'roadmap.sortNew': 'Neu', + 'roadmap.status': 'Stand', + 'roadmap.area': 'Bereich', + 'roadmap.areaPick': '— Bereich —', 'wish.alle': 'Alle', 'wish.offen': 'Wird geprüft', 'wish.geplant': 'Geplant', diff --git a/frontend/src/locales/en.js b/frontend/src/locales/en.js index c34fb58..b1d5eb1 100644 --- a/frontend/src/locales/en.js +++ b/frontend/src/locales/en.js @@ -119,6 +119,13 @@ export default { 'roadmap.activity': '// Activity — %s commits in 12 months', 'roadmap.commitsOn': '%s: %s commits', 'roadmap.commitOn': '%s: 1 commit', + 'roadmap.sort': 'Sort', + 'roadmap.sortTop': 'Top', + 'roadmap.sortTrend': 'Trending', + 'roadmap.sortNew': 'New', + 'roadmap.status': 'Status', + 'roadmap.area': 'Area', + 'roadmap.areaPick': '— Area —', 'wish.alle': 'All', 'wish.offen': 'Under review', 'wish.geplant': 'Planned', diff --git a/frontend/src/pages/Roadmap.jsx b/frontend/src/pages/Roadmap.jsx index 7073856..69970d1 100644 --- a/frontend/src/pages/Roadmap.jsx +++ b/frontend/src/pages/Roadmap.jsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { apiGet, apiPost } from '../api.js'; import { Markdown } from '../markdown.jsx'; import { SkeletonCards } from '../components/Skeleton.jsx'; -import { IconThumbsUp } from '../icons.jsx'; +import { IconThumbsUp, IconMessage } from '../icons.jsx'; import { useT, useFmt } from '../i18n.jsx'; const ZIEL = { month: 'long', year: 'numeric' }; @@ -27,6 +27,11 @@ const WISH_STATUS = { umgesetzt: 'wish.umgesetzt', abgelehnt: 'wish.abgelehnt', }; +const WISH_SORT = [ + { id: 'top', key: 'roadmap.sortTop' }, + { id: 'bewegung', key: 'roadmap.sortTrend' }, + { id: 'neu', key: 'roadmap.sortNew' }, +]; const WISH_FILTER = [ { id: 'alle', key: 'wish.alle' }, { id: 'offen', key: 'wish.offen' }, @@ -113,15 +118,30 @@ export default function Roadmap() { const [heatmap, setHeatmap] = useState(null); const [error, setError] = useState(false); const [filter, setFilter] = useState('alle'); + const [bereich, setBereich] = useState('alle'); + const [bereiche, setBereiche] = useState([]); + const [neuerBereich, setNeuerBereich] = useState(''); + const [sort, setSort] = useState('top'); const [aehnliche, setAehnliche] = useState([]); useEffect(() => { apiGet('/api/roadmap').then(setData).catch(() => setError(true)); - apiGet('/api/wishes').then((d) => { setWishes(d.wishes); setCanVote(d.canVote); }).catch(() => {}); apiGet('/api/heatmap').then((d) => setHeatmap(d.days)).catch(() => {}); window.scrollTo(0, 0); }, []); + // Sortiert wird auf dem Server — „Bewegung" braucht die Zeitstempel der + // Stimmen, die hier gar nicht ankommen + useEffect(() => { + apiGet(`/api/wishes?sort=${sort}`) + .then((d) => { + setWishes(d.wishes); + setCanVote(d.canVote); + setBereiche(d.bereiche ?? []); + }) + .catch(() => {}); + }, [sort]); + // Während des Tippens nachschlagen, ob es das schon gibt. Kurz warten, // damit nicht jeder Tastendruck eine Anfrage auslöst. useEffect(() => { @@ -135,9 +155,9 @@ export default function Roadmap() { return () => clearTimeout(timer); }, [idea]); - const sichtbareWuensche = filter === 'alle' - ? wishes - : wishes.filter((w) => (w.status ?? 'offen') === filter); + const sichtbareWuensche = wishes + .filter((w) => filter === 'alle' || (w.status ?? 'offen') === filter) + .filter((w) => bereich === 'alle' || w.category_id === bereich); async function vote(id) { try { @@ -155,10 +175,13 @@ export default function Roadmap() { const text = idea.trim(); if (text.length < 5) { setSubmitMsg(t('roadmap.tooShort')); return; } try { - await apiPost('/api/wishes', { idea: text }); + await apiPost('/api/wishes', { + idea: text, + category_id: neuerBereich ? Number(neuerBereich) : null, + }); setIdea(''); setSubmitMsg(t('roadmap.submitted')); - apiGet('/api/wishes').then((d) => setWishes(d.wishes)).catch(() => {}); + apiGet(`/api/wishes?sort=${sort}`).then((d) => setWishes(d.wishes)).catch(() => {}); } catch (e) { setSubmitMsg(e.status === 403 ? t('roadmap.submitMembersOnly') : t('roadmap.submitFailed')); } @@ -189,25 +212,62 @@ export default function Roadmap() {

{t('roadmap.wishes')}

- {/* Ein Wunsch ohne sichtbaren Ausgang ist ein Wunsch, den - niemand mehr einreicht — deshalb steht der Stand vorne - dran und lässt sich filtern. */} -
- {WISH_FILTER.map(({ id, key }) => { - const n = id === 'alle' - ? wishes.length - : wishes.filter((w) => (w.status ?? 'offen') === id).length; - if (n === 0 && id !== 'alle') return null; - return ( +
+ {/* Sortierung: die Rangliste allein zementiert alte + Wünsche — „Bewegung" zeigt, wo diese Woche etwas + passiert, „Neu" das Frischeste. */} +
+ {t('roadmap.sort')} + {WISH_SORT.map(({ id, key }) => ( - ); - })} + ))} +
+ +
+ {t('roadmap.status')} + {WISH_FILTER.map(({ id, key }) => { + const n = id === 'alle' + ? wishes.length + : wishes.filter((w) => (w.status ?? 'offen') === id).length; + if (n === 0 && id !== 'alle') return null; + return ( + + ); + })} +
+ + {bereiche.length > 0 && ( +
+ {t('roadmap.area')} + + {bereiche.map((b) => ( + + ))} +
+ )}
{sichtbareWuensche.length === 0 && ( @@ -230,13 +290,27 @@ export default function Roadmap() { {w.score} )} + + {status !== 'offen' && ( + {t(WISH_STATUS[status])} + )} + {w.kategorie && ( + + {w.kategorie_emoji} {w.kategorie} + + )} + {w.idea} {w.status_grund && ( {w.status_grund} )} - {status !== 'offen' && ( - {t(WISH_STATUS[status])} + {/* Geredet wird im Thread unter dem Post — der + Zähler führt direkt dorthin */} + {w.thread_url && ( + + {w.kommentare ?? 0} + )} {w.author}
@@ -246,6 +320,20 @@ export default function Roadmap() { {canVote ? (
+ {bereiche.length > 0 && ( + + )} {}); 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/wishes').then((d) => { + setWuensche(d.wishes ?? []); + setBereiche(d.bereiche ?? []); + }).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(() => {}); @@ -1865,6 +1870,64 @@ export default function Settings({ me }) { )} +
+

// Wunsch-Bereiche

+

+ Schubladen für Ideen — „Fahrzeuge", „Wirtschaft", „Technik". Ohne Bereiche + bleibt die Liste eine Liste; mit ihnen kann man auf der Roadmap filtern, und + der Aufruf-Post in Discord fragt beim Einreichen nach dem Bereich. Nach dem + Ändern /wunsch-setup neu ausführen. +

+ + {bereiche.length === 0 &&

Noch keine Bereiche.

} + + {bereiche.length > 0 && ( +
+ {bereiche.map((b) => ( +
+ {b.emoji} + {b.name} + + {(() => { + const n = wuensche.filter((w) => w.category_id === b.id).length; + return `${n} ${n === 1 ? 'Wunsch' : 'Wünsche'}`; + })()} + + + +
+ ))} +
+ )} + +
+ setBereichDraft({ ...bereichDraft, emoji: e.target.value })} + /> + setBereichDraft({ ...bereichDraft, name: e.target.value })} + onKeyDown={(e) => { if (e.key === 'Enter') bereichSpeichern(); }} + /> + + {bereichDraft.id && ( + + )} +
+
+

// Geburtstage

@@ -2080,6 +2143,29 @@ export default function Settings({ me }) { ); + async function bereichSpeichern() { + try { + const res = bereichDraft.id + ? await apiPut(`/api/wish-categories/${bereichDraft.id}`, bereichDraft) + : await apiPost('/api/wish-categories', bereichDraft); + setBereiche(res.bereiche ?? []); + setBereichDraft({ id: null, name: '', emoji: '' }); + } catch (e) { + flash(`✗ ${e.body?.error ?? 'Fehlgeschlagen'}`); + } + } + + async function bereichLoeschen(b) { + if (!window.confirm(`Bereich „${b.name}" entfernen? Die Wünsche darin bleiben, stehen dann nur ohne Schild da.`)) return; + try { + const res = await apiDelete(`/api/wish-categories/${b.id}`); + setBereiche(res.bereiche ?? []); + if (res.wishes) setWuensche(res.wishes); + } catch { + flash('✗ Entfernen fehlgeschlagen'); + } + } + /** Status setzen — mit Begruendung, denn genau die fehlt sonst */ function wunschStatus(wunsch, status) { const label = Object.fromEntries(WUNSCH_STATUS)[status]; diff --git a/frontend/src/style.css b/frontend/src/style.css index 19309f2..5a5e57c 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -488,7 +488,31 @@ body::after { /* 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; } +/* Schilder stehen über der Idee, nicht dahinter — sonst rutschen sie bei + langen Texten aus dem Blick */ +.wish-schilder { display: flex; gap: .35rem; flex-wrap: wrap; margin-bottom: .25rem; } +.wish-area { + font-family: var(--mono); font-size: .58rem; letter-spacing: .1em; + text-transform: uppercase; white-space: nowrap; color: var(--muted2); + padding: .18rem .5rem; border-radius: 999px; border: 1px solid var(--border); +} +/* Kommentare liegen im Discord-Thread — der Zähler führt direkt hinein */ +.wish-komm { + display: inline-flex; align-items: center; gap: .3rem; + font-family: var(--mono); font-size: .68rem; color: var(--muted2); + text-decoration: none; white-space: nowrap; +} +.wish-komm:hover { color: var(--neon); } + +.wish-bar { display: flex; flex-direction: column; gap: .3rem; margin-bottom: 1rem; } +.wish-filter-label { + font-family: var(--mono); font-size: .58rem; letter-spacing: .12em; + text-transform: uppercase; color: var(--muted2); + align-self: center; margin-right: .2rem; min-width: 5.2rem; +} +.wish-area-select { flex: 0 0 auto; max-width: 11rem; } + +.wish-filter { display: flex; flex-wrap: wrap; gap: .4rem; align-items: center; } .wish-chip { background: var(--bg2); border: 1px solid var(--border); border-radius: 999px; color: var(--muted); font-family: var(--mono); font-size: .62rem; diff --git a/src/bot/client.js b/src/bot/client.js index 4df673f..2df4569 100644 --- a/src/bot/client.js +++ b/src/bot/client.js @@ -20,6 +20,9 @@ import { claimTicketButton, unclaimTicketButton, } from './tickets.js'; import { handleRoleMenuButton } from './role-menus.js'; +import { + handleWunschPick, handleWunschButton, handleWunschModal, registerWishThreads, +} from './wishes.js'; import { handleGiveawayAdminButton } from './giveaways.js'; import { removePlaytester, playtesterForm, wishByMessage, addWishVote, removeWishVote, @@ -31,6 +34,7 @@ import * as bug from './commands/bug.js'; import * as playtesterSetup from './commands/playtester-setup.js'; import * as galerieBackfill from './commands/galerie-backfill.js'; import * as wunsch from './commands/wunsch.js'; +import * as wunschSetup from './commands/wunsch-setup.js'; import * as giveaway from './commands/giveaway.js'; import * as ticketSetup from './commands/ticket-setup.js'; import * as warn from './commands/warn.js'; @@ -47,7 +51,7 @@ import * as umfrage from './commands/umfrage.js'; // Alle Commands hier eintragen — jedes Modul exportiert { data, execute } const commandModules = [ ping, devlogBackfill, bug, playtesterSetup, galerieBackfill, - wunsch, giveaway, ticketSetup, warn, warns, timeout, purge, rank, tag, remind, geburtstag, + wunsch, wunschSetup, giveaway, ticketSetup, warn, warns, timeout, purge, rank, tag, remind, geburtstag, raid, umfrage, ]; @@ -196,9 +200,22 @@ export async function startBot() { return; } - // Eingabefenster von /bug und /wunsch - if (interaction.isModalSubmit() && (interaction.customId === 'bugmodal' || interaction.customId === 'wunschmodal')) { - const handler = interaction.customId === 'bugmodal' ? bug.handleModal : wunsch.handleModal; + // Ideen einreichen: Knopf oder Auswahl im Aufruf-Post -> Eingabefenster + if ((interaction.isButton() && interaction.customId === 'wunsch_neu') + || (interaction.isStringSelectMenu() && interaction.customId === 'wunsch_pick')) { + try { + if (interaction.isButton()) await handleWunschButton(interaction); + else await handleWunschPick(interaction); + } catch (error) { + console.error('[voting] Eingabefenster fehlgeschlagen:', error); + } + return; + } + + // Eingabefenster von /bug und /wunsch (der Bereich haengt hinter dem Doppelpunkt) + if (interaction.isModalSubmit() + && (interaction.customId === 'bugmodal' || interaction.customId.startsWith('wunschmodal'))) { + const handler = interaction.customId === 'bugmodal' ? bug.handleModal : handleWunschModal; try { await handler(interaction); } catch (error) { @@ -374,6 +391,7 @@ export async function startBot() { // Vor den anderen Beitritts-Handlern egal — die Erkennung zaehlt nur mit registerAntiRaid(client); registerAutoMod(client); + registerWishThreads(client); registerLevels(client); registerPresence(client); registerExtras(client); diff --git a/src/bot/commands/wunsch-setup.js b/src/bot/commands/wunsch-setup.js new file mode 100644 index 0000000..96d3041 --- /dev/null +++ b/src/bot/commands/wunsch-setup.js @@ -0,0 +1,39 @@ +// /wunsch-setup — postet den Ideen-Aufruf in den aktuellen Kanal (Admin). +// +// Gibt es Bereiche, wird daraus ein Auswahlmenü: erst wohin, dann das +// Eingabefenster. Ohne Bereiche bleibt es beim einen Knopf. +import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, EmbedBuilder } from 'discord.js'; +import { votingChannelId, brandColor, brandFooter } from '../../runtime-settings.js'; +import { renderTemplate } from '../../templates.js'; +import { listWishCategories } from '../../db.js'; +import { wunschPanelComponents } from '../wishes.js'; + +export const data = new SlashCommandBuilder() + .setName('wunsch-setup') + .setDescription('Postet den Ideen-Aufruf mit Knopf in diesen Kanal') + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator); + +export async function execute(interaction) { + if (!votingChannelId()) { + await interaction.reply({ + content: '❌ Erst in der Config einen Voting-Kanal wählen.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + const embed = new EmbedBuilder() + .setColor(brandColor()) + .setTitle(renderTemplate('wish.panel_title')) + .setDescription(renderTemplate('wish.panel_text')) + .setFooter({ text: brandFooter('VOTING') }); + + const anzahl = listWishCategories().length; + await interaction.channel.send({ embeds: [embed], components: wunschPanelComponents() }); + await interaction.reply({ + content: anzahl > 0 + ? `✅ Aufruf gepostet — mit Auswahl aus ${anzahl} Bereichen.` + : '✅ Aufruf gepostet. Bereiche zum Einsortieren legst du in der Config unter Community an.', + flags: MessageFlags.Ephemeral, + }); +} diff --git a/src/bot/commands/wunsch.js b/src/bot/commands/wunsch.js index 5e7cf2f..74d73db 100644 --- a/src/bot/commands/wunsch.js +++ b/src/bot/commands/wunsch.js @@ -1,21 +1,40 @@ // /wunsch — Feature-Wunsch einreichen: Eingabefenster → Voting-Post mit 👍, -// Rangliste auf der Webseite. +// Thread zum Reden, Rangliste auf der Webseite. // // Das Fenster statt einer Slash-Option, weil eine Idee selten in eine Zeile // passt — und weil die Rückfrage „warum wäre das gut?" die Vorschläge // deutlich besser macht als ein Einzeiler. -import { - SlashCommandBuilder, MessageFlags, EmbedBuilder, - ModalBuilder, ActionRowBuilder, TextInputBuilder, TextInputStyle, -} from 'discord.js'; -import { votingChannelId, publicUrl, brandColor, brandFooter } from '../../runtime-settings.js'; -import { saveWish } from '../../db.js'; -import { renderTemplate } from '../../templates.js'; +// +// Das Posten selbst steckt in bot/wishes.js, damit Befehl, Knopf unter dem +// Aufruf-Post und Webseite denselben Weg gehen und ein Wunsch überall gleich +// aussieht. +import { SlashCommandBuilder, MessageFlags } from 'discord.js'; +import { votingChannelId } from '../../runtime-settings.js'; +import { listWishCategories } from '../../db.js'; +import { wunschModal } from '../wishes.js'; -export const data = new SlashCommandBuilder() +const builder = new SlashCommandBuilder() .setName('wunsch') .setDescription('Feature-Wunsch einreichen — die Community stimmt ab'); +// Discord will die Auswahlmöglichkeiten schon beim Registrieren kennen. Wer +// Bereiche nachträglich anlegt, bekommt sie hier beim nächsten Bot-Start — +// über den Aufruf-Post (/wunsch-setup) stehen sie sofort zur Verfügung. +{ + const bereiche = listWishCategories(); + if (bereiche.length > 0) { + builder.addStringOption((option) => option + .setName('bereich') + .setDescription('Worum geht es?') + .addChoices(...bereiche.slice(0, 25).map((b) => ({ + name: `${b.emoji} ${b.name}`.trim().slice(0, 100), + value: String(b.id), + })))); + } +} + +export const data = builder; + export async function execute(interaction) { if (!votingChannelId()) { await interaction.reply({ @@ -24,59 +43,6 @@ export async function execute(interaction) { }); return; } - - const modal = new ModalBuilder().setCustomId('wunschmodal').setTitle('Feature-Wunsch'); - modal.addComponents( - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('idee').setLabel('Deine Idee') - .setPlaceholder('Was soll dazukommen oder anders laufen?') - .setStyle(TextInputStyle.Paragraph).setMaxLength(500).setRequired(true) - ), - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('warum').setLabel('Warum wäre das gut?') - .setPlaceholder('Was wird dadurch besser oder einfacher?') - .setStyle(TextInputStyle.Paragraph).setMaxLength(400).setRequired(false) - ) - ); - await interaction.showModal(modal); -} - -/** Formular abgeschickt → Voting-Post */ -export async function handleModal(interaction) { - const channelId = votingChannelId(); - if (!channelId) { - await interaction.reply({ - content: '❌ Feature-Voting ist gerade nicht aktiviert.', - flags: MessageFlags.Ephemeral, - }); - return; - } - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - - const idee = interaction.fields.getTextInputValue('idee'); - const warum = interaction.fields.getTextInputValue('warum')?.trim(); - const channel = await interaction.client.channels.fetch(channelId); - - const embed = new EmbedBuilder() - .setColor(brandColor()) - .setAuthor({ - name: interaction.member?.displayName ?? interaction.user.username, - iconURL: interaction.user.displayAvatarURL({ size: 64 }), - }) - .setTitle('💡 Feature-Wunsch') - .setDescription(warum ? `${idee}\n\n**Warum:** ${warum}` : idee) - .setURL(`${publicUrl()}/roadmap`) - .setFooter({ text: `${brandFooter('VOTING')} • 👍 = will ich!` }); - - const message = await channel.send({ embeds: [embed] }); - await message.react('👍'); - saveWish(message.id, interaction.user.username, idee); - - await interaction.editReply(renderTemplate('wish.submitted', { - user: interaction.member?.displayName ?? interaction.user.username, - mention: `<@${interaction.user.id}>`, - url: message.url, - })); + const gewaehlt = Number(interaction.options.getString('bereich')); + await interaction.showModal(wunschModal(Number.isFinite(gewaehlt) ? gewaehlt : null)); } diff --git a/src/bot/wishes.js b/src/bot/wishes.js new file mode 100644 index 0000000..d0a6e25 --- /dev/null +++ b/src/bot/wishes.js @@ -0,0 +1,154 @@ +// Feature-Wünsche: einreichen, posten, bereden. +// +// Ein Wunsch entsteht an drei Stellen — per /wunsch, über den Knopf unter dem +// Aufruf-Post und auf der Webseite. Alle drei landen hier, damit ein Wunsch +// überall gleich aussieht: gebrandetes Embed, 👍 zum Abstimmen, und ein Thread +// darunter fürs Reden. +// +// Der Thread ist bewusst der Diskussionsort und nicht ein Kommentarfeld auf der +// Webseite: die Leute sind ohnehin in Discord, und ein zweiter Ort würde die +// Unterhaltung nur zerreißen. Die Roadmap zeigt die Zahl der Beiträge und +// verlinkt hinein. +import { + ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, Events, + ModalBuilder, MessageFlags, StringSelectMenuBuilder, TextInputBuilder, TextInputStyle, +} from 'discord.js'; +import { + saveWish, setWishThread, bumpWishKommentare, getWishCategory, listWishCategories, +} from '../db.js'; +import { votingChannelId, publicUrl, brandColor, brandFooter } from '../runtime-settings.js'; +import { renderTemplate } from '../templates.js'; +import { tuning } from '../tuning.js'; +import { alsEmoji } from './tickets.js'; + +/** Knopf oder Auswahlmenü für den Aufruf-Post — je nachdem, ob es Bereiche gibt */ +export function wunschPanelComponents() { + const bereiche = listWishCategories(); + if (bereiche.length === 0) { + return [new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('wunsch_neu') + .setStyle(ButtonStyle.Primary) + .setLabel('Idee einreichen') + .setEmoji('💡') + )]; + } + const menu = new StringSelectMenuBuilder() + .setCustomId('wunsch_pick') + .setPlaceholder('Worum geht es?') + .addOptions(bereiche.slice(0, 25).map((b) => { + const option = { label: b.name.slice(0, 100), value: String(b.id) }; + const emoji = alsEmoji(b.emoji); + if (emoji) option.emoji = emoji; + return option; + })); + return [new ActionRowBuilder().addComponents(menu)]; +} + +/** Das Eingabefenster. Der Bereich reist in der Kennung mit. */ +export function wunschModal(categoryId = null) { + const bereich = categoryId ? getWishCategory(categoryId) : null; + return new ModalBuilder() + .setCustomId(`wunschmodal:${categoryId ?? ''}`) + .setTitle(bereich ? `Idee — ${bereich.name}`.slice(0, 45) : 'Feature-Wunsch') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('idee').setLabel('Deine Idee') + .setPlaceholder('Was soll dazukommen oder anders laufen?') + .setStyle(TextInputStyle.Paragraph).setMaxLength(500).setRequired(true) + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('warum').setLabel('Warum wäre das gut?') + .setPlaceholder('Was wird dadurch besser oder einfacher?') + .setStyle(TextInputStyle.Paragraph).setMaxLength(400).setRequired(false) + ) + ); +} + +/** + * Wunsch posten, speichern und den Thread aufmachen. + * @returns {Promise<{id: number, url: string}>} + */ +export async function wunschAnlegen(client, { idee, warum, autor, autorId, avatar, categoryId }) { + const channelId = votingChannelId(); + if (!channelId) throw new Error('Feature-Voting ist gerade nicht aktiviert.'); + const channel = await client.channels.fetch(channelId); + if (!channel?.isTextBased()) throw new Error('Voting-Kanal nicht gefunden.'); + + const bereich = categoryId ? getWishCategory(categoryId) : null; + const kopf = bereich ? `${bereich.emoji || '💡'} ${bereich.name}` : '💡 Feature-Wunsch'; + const embed = new EmbedBuilder() + .setColor(brandColor()) + .setAuthor({ name: autor, iconURL: avatar || undefined }) + .setTitle(kopf) + .setDescription(warum ? `${idee}\n\n**Warum:** ${warum}` : idee) + .setURL(`${publicUrl()}/roadmap`) + .setFooter({ text: `${brandFooter('VOTING')} • 👍 = will ich!` }); + + const message = await channel.send({ embeds: [embed] }); + await message.react('👍').catch(() => {}); + const id = saveWish(message.id, autor, idee, autorId ?? null, categoryId ?? null); + + // Thread zum Reden. Scheitert er (fehlende Rechte, kein Thread-fähiger + // Kanal), bleibt der Wunsch trotzdem stehen — abstimmen geht auch ohne. + try { + const thread = await message.startThread({ + name: `💬 ${idee}`.slice(0, 100), + autoArchiveDuration: tuning('thread_archive_days') * 1440, + }); + setWishThread(id, thread.id); + } catch (error) { + console.error('[voting] Thread nicht angelegt:', error.message); + } + return { id, url: message.url }; +} + +/** Auswahl im Aufruf-Post → Eingabefenster für diesen Bereich */ +export async function handleWunschPick(interaction) { + const gewaehlt = Number(interaction.values?.[0]); + await interaction.showModal(wunschModal(Number.isFinite(gewaehlt) ? gewaehlt : null)); +} + +/** Knopf ohne Bereiche → Eingabefenster ohne Bereich */ +export async function handleWunschButton(interaction) { + await interaction.showModal(wunschModal(null)); +} + +/** Formular abgeschickt → Wunsch anlegen */ +export async function handleWunschModal(interaction) { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + const rohBereich = interaction.customId.split(':')[1]; + const categoryId = rohBereich ? Number(rohBereich) : null; + const name = interaction.member?.displayName ?? interaction.user.username; + try { + const { url } = await wunschAnlegen(interaction.client, { + idee: interaction.fields.getTextInputValue('idee'), + warum: interaction.fields.getTextInputValue('warum')?.trim(), + autor: name, + autorId: interaction.user.id, + avatar: interaction.user.displayAvatarURL({ size: 64 }), + categoryId, + }); + await interaction.editReply(renderTemplate('wish.submitted', { + user: name, + mention: `<@${interaction.user.id}>`, + url, + })); + } catch (error) { + await interaction.editReply(`❌ ${error.message}`).catch(() => {}); + } +} + +/** Beiträge in Wunsch-Threads mitzählen — das ist der Kommentar-Zähler */ +export function registerWishThreads(client) { + client.on(Events.MessageCreate, (message) => { + try { + if (message.author?.bot || !message.channel?.isThread?.()) return; + bumpWishKommentare(message.channelId); + } catch (error) { + console.error('[voting] Kommentar-Zähler:', error); + } + }); +} diff --git a/src/db.js b/src/db.js index b1a9312..23dfc38 100644 --- a/src/db.js +++ b/src/db.js @@ -294,24 +294,80 @@ db.exec(` } } +// Bereiche, in die ein Wunsch faellt — „Fahrzeuge", „Wirtschaft", „Technik". +// Ohne Eintrag bleibt alles wie bisher: eine Liste ohne Schubladen. +db.exec(` + CREATE TABLE IF NOT EXISTS wish_categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + emoji TEXT NOT NULL DEFAULT '', + sort INTEGER NOT NULL DEFAULT 0 + ); +`); +{ + const spalten = db.prepare('PRAGMA table_info(wishes)').all().map((c) => c.name); + if (!spalten.includes('category_id')) db.exec('ALTER TABLE wishes ADD COLUMN category_id INTEGER'); + // Der Thread unter dem Wunsch-Post ist der Diskussionsort. Die Zahl der + // Beitraege darin zaehlen wir mit, statt sie fuer jede Seitenansicht bei + // Discord zu erfragen — 50 Threads je Aufruf waere nicht zu bezahlen. + if (!spalten.includes('thread_id')) db.exec('ALTER TABLE wishes ADD COLUMN thread_id TEXT'); + if (!spalten.includes('kommentare')) { + db.exec('ALTER TABLE wishes ADD COLUMN kommentare INTEGER NOT NULL DEFAULT 0'); + } +} +const listWishCatsStmt = db.prepare('SELECT * FROM wish_categories ORDER BY sort, id'); +const getWishCatStmt = db.prepare('SELECT * FROM wish_categories WHERE id = ?'); +const insertWishCatStmt = db.prepare( + 'INSERT INTO wish_categories (name, emoji, sort) VALUES (@name, @emoji, @sort)' +); +const updateWishCatStmt = db.prepare( + 'UPDATE wish_categories SET name = @name, emoji = @emoji, sort = @sort WHERE id = @id' +); +const deleteWishCatStmt = db.prepare('DELETE FROM wish_categories WHERE id = ?'); +// Wuensche im geloeschten Bereich bleiben, sie stehen dann nur ohne Schild da +const orphanWishesStmt = db.prepare('UPDATE wishes SET category_id = NULL WHERE category_id = ?'); +export const listWishCategories = () => listWishCatsStmt.all(); +export const getWishCategory = (id) => getWishCatStmt.get(id) ?? null; +export const createWishCategory = (c) => insertWishCatStmt.run(c).lastInsertRowid; +export const updateWishCategory = (c) => updateWishCatStmt.run(c); +export const deleteWishCategory = db.transaction((id) => { + orphanWishesStmt.run(id); + return deleteWishCatStmt.run(id).changes > 0; +}); + /** 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 + w.id, w.message_id, w.thread_id, w.idea, w.author, w.author_id, w.status, + w.status_grund, w.status_at, w.created_at, w.kommentare, w.category_id, + (SELECT k.name FROM wish_categories k WHERE k.id = w.category_id) AS kategorie, + (SELECT k.emoji FROM wish_categories k WHERE k.id = w.category_id) AS kategorie_emoji, + w.bonus + (SELECT COUNT(*) FROM wish_votes v WHERE v.wish_id = w.id) AS score, + -- Bewegung: Stimmen der letzten sieben Tage. Ein alter Wunsch mit vielen + -- Stimmen steht sonst fuer immer oben, egal ob noch jemand hinschaut. + (SELECT COUNT(*) FROM wish_votes v + WHERE v.wish_id = w.id AND v.created_at >= datetime('now', '-7 days')) AS bewegung `; const insertWish = db.prepare( - 'INSERT INTO wishes (message_id, author, author_id, idea) VALUES (?, ?, ?, ?)' + 'INSERT INTO wishes (message_id, author, author_id, idea, category_id) VALUES (?, ?, ?, ?, ?)' ); +const setWishThreadStmt = db.prepare('UPDATE wishes SET thread_id = ? WHERE id = ?'); +const bumpKommentareStmt = db.prepare( + 'UPDATE wishes SET kommentare = kommentare + 1 WHERE thread_id = ?' +); +const setWishCategoryStmt = db.prepare('UPDATE wishes SET category_id = ? WHERE id = ?'); 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 SORTIERUNG = { + top: 'score DESC, w.created_at DESC', + bewegung: 'bewegung DESC, score DESC', + neu: 'w.created_at DESC', +}; +const topWishesStmts = Object.fromEntries(Object.entries(SORTIERUNG).map(([k, ord]) => [ + k, + db.prepare(`SELECT ${WUNSCH_FELDER} FROM wishes w WHERE w.merged_into IS NULL ORDER BY ${ord} LIMIT ?`), +])); const searchWishesStmt = db.prepare(` SELECT ${WUNSCH_FELDER} FROM wishes w WHERE w.merged_into IS NULL AND w.idea LIKE ? @@ -327,12 +383,17 @@ const moveVotesStmt = db.prepare( ); 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 saveWish = (messageId, author, idea, authorId = null, categoryId = null) => + insertWish.run(messageId, author, authorId, idea, categoryId ?? null).lastInsertRowid; +export const setWishThread = (id, threadId) => setWishThreadStmt.run(threadId, id); +export const setWishCategory = (id, categoryId) => setWishCategoryStmt.run(categoryId ?? null, id); +/** @returns {boolean} true, wenn der Thread zu einem Wunsch gehoerte */ +export const bumpWishKommentare = (threadId) => bumpKommentareStmt.run(threadId).changes > 0; 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 topWishes = (limit = 20, sortierung = 'top') => + (topWishesStmts[sortierung] ?? topWishesStmts.top).all(limit); export const searchWishes = (text, limit = 8) => searchWishesStmt.all(`%${text}%`, limit); export const deleteWish = (id) => { dropVotesStmt.run(id); diff --git a/src/templates.js b/src/templates.js index 5de5b3d..dd734d5 100644 --- a/src/templates.js +++ b/src/templates.js @@ -190,6 +190,22 @@ export const TEMPLATES = [ vars: [...WHO, { key: 'url', desc: 'Link zum Voting-Post' }], default: '✅ Wunsch eingereicht — [zur Abstimmung]({url})!', }, + { + id: 'wish.panel_title', group: 'sonstiges', module: 'voting', + name: 'Ideen-Aufruf — Überschrift', + vars: [], + default: '💡 Deine Idee für EcoGame', + }, + { + id: 'wish.panel_text', group: 'sonstiges', module: 'voting', + name: 'Ideen-Aufruf — Text', long: true, + vars: [], + default: + 'Dir fehlt etwas, oder du hättest gern, dass etwas anders läuft?\n\n' + + 'Such dir unten den passenden Bereich, schreib deine Idee auf — sie landet als ' + + 'Post hier im Kanal. Mit 👍 stimmt die Community ab, und im Thread darunter ' + + 'wird darüber geredet. Auf der Roadmap siehst du, was daraus geworden ist.', + }, { id: 'bug.submitted', group: 'sonstiges', module: 'bug_reports', name: 'Fehlermeldung — Bestätigung', long: true, diff --git a/src/web/api.js b/src/web/api.js index f016353..2af2d1a 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -15,7 +15,8 @@ import { saveWebAdmin, webAdminScopes, listWebAdmins, deleteWebAdmin, saveTemplate, listTemplates, deleteTemplate, getLevelRow, isPlaytester, saveWish, toggleWishVote, myWishVotes, - getWish, searchWishes, setWishStatus, mergeWish, deleteWish, wishVoters, + getWish, searchWishes, setWishStatus, mergeWish, deleteWish, wishVoters, setWishCategory, + listWishCategories, createWishCategory, updateWishCategory, deleteWishCategory, logAudit, listAudit, playerHistory, uptimeBuckets, watchdogBuckets, watchdogDays, heartbeatBuckets, heartbeatFirst, freeAlphaKeys, deleteFreeAlphaKey, removePlaytester, @@ -33,6 +34,7 @@ import { tuningStates, setTuning, tuning, TUNING_GROUPS } from '../tuning.js'; import { lastResults, GAME_ICONS } from '../bot/server-monitor.js'; import { watchdogState, watchedServices } from '../bot/watchdog.js'; import { listRules, setRuleEnabled } from '../bot/automod.js'; +import { wunschAnlegen } from '../bot/wishes.js'; import { heartbeatSerie } from '../bot/heartbeat.js'; import { publishAppForm, unpublishAppForm, MAX_QUESTIONS } from '../bot/app-forms.js'; import { sanitizeEmbed } from './api-v1.js'; @@ -186,8 +188,19 @@ export function registerApiRoutes(app, client) { app.get('/api/wishes', async (request) => { const user = getSessionUser(request); const mine = user ? new Set(myWishVotes(user.id)) : new Set(); + const sortierung = String(request.query?.sort ?? 'top'); + // Den Link zum Thread bauen wir hier — die Guild-ID gehört nicht auf + // die öffentliche Seite, nur der fertige Verweis + const gid = discordGuildId(); return { - wishes: topWishes(50).map((w) => ({ ...w, voted: mine.has(w.id) })), + wishes: topWishes(50, sortierung).map((w) => ({ + ...w, + voted: mine.has(w.id), + thread_url: gid && w.thread_id + ? `https://discord.com/channels/${gid}/${w.thread_id}` + : null, + })), + bereiche: listWishCategories(), canVote: Boolean(user), }; }); @@ -1925,31 +1938,26 @@ ${rssItems} } }); - // Wunsch einreichen (postet wie /wunsch in den Voting-Kanal) + // Wunsch einreichen — läuft durch denselben Weg wie /wunsch, damit ein + // Wunsch von hier genauso aussieht: Embed, 👍 und Thread zum Reden app.post('/api/wishes', async (request, reply) => { if (await requireMember(request, reply)) return; const user = getSessionUser(request); const idea = String(request.body?.idea ?? '').trim().slice(0, 500); if (idea.length < 5) return reply.code(400).send({ error: 'Idee zu kurz' }); - const channelId = votingChannelId(); - if (!channelId) return reply.code(400).send({ error: 'Feature-Voting ist nicht aktiviert' }); - const channel = await client.channels.fetch(channelId).catch(() => null); - if (!channel?.isTextBased()) return reply.code(500).send({ error: 'Voting-Kanal nicht gefunden' }); - - const embed = new EmbedBuilder() - .setColor(brandColor()) - .setAuthor({ name: user.username, iconURL: user.avatar ?? undefined }) - .setTitle('💡 Feature-Wunsch') - .setDescription(idea) - .setURL(`${publicUrl()}/roadmap`) - .setFooter({ - text: `${brandFooter('VOTING')} • 👍 = will ich!`, - iconURL: client.user?.displayAvatarURL?.({ size: 64 }), + const categoryId = Number(request.body?.category_id) || null; + try { + const { id } = await wunschAnlegen(client, { + idee: idea, + autor: user.username, + autorId: user.id, + avatar: user.avatar ?? null, + categoryId, }); - const message = await channel.send({ embeds: [embed] }); - await message.react('👍').catch(() => {}); - const id = saveWish(message.id, user.username, idea, user.id); - return { ok: true, id }; + return { ok: true, id }; + } catch (error) { + return reply.code(400).send({ error: error.message }); + } }); // Eine Stimme je Person — ob hier geklickt oder als 👍 in Discord, ist @@ -1969,6 +1977,52 @@ ${rssItems} return { treffer: q.length >= 3 ? searchWishes(q) : [] }; }); + // Bereiche, in die ein Wunsch faellt. Ohne Eintrag bleibt die Liste eine + // Liste — Schubladen lohnen sich erst ab einer gewissen Zahl. + app.get('/api/wish-categories', async (request, reply) => { + if (requireAnyScope(request, reply)) return; + return { bereiche: listWishCategories() }; + }); + + function bereichBody(request, reply) { + const name = String(request.body?.name ?? '').trim(); + if (!name) { + reply.code(400).send({ error: 'Name fehlt.' }); + return null; + } + return { + name: name.slice(0, 40), + emoji: String(request.body?.emoji ?? '').trim().slice(0, 32), + sort: Number(request.body?.sort) || 0, + }; + } + + app.post('/api/wish-categories', async (request, reply) => { + if (requireScope(request, reply, 'community')) return; + const b = bereichBody(request, reply); + if (!b) return; + createWishCategory(b); + logAudit(getSessionUser(request), 'wunsch-bereich angelegt', b.name); + return { ok: true, bereiche: listWishCategories() }; + }); + + app.put('/api/wish-categories/:id', async (request, reply) => { + if (requireScope(request, reply, 'community')) return; + const b = bereichBody(request, reply); + if (!b) return; + updateWishCategory({ ...b, id: Number(request.params.id) }); + logAudit(getSessionUser(request), 'wunsch-bereich geaendert', b.name); + return { ok: true, bereiche: listWishCategories() }; + }); + + // Wuensche im geloeschten Bereich bleiben stehen, nur ohne Schild + app.delete('/api/wish-categories/:id', async (request, reply) => { + if (requireScope(request, reply, 'community')) return; + const weg = deleteWishCategory(Number(request.params.id)); + logAudit(getSessionUser(request), 'wunsch-bereich entfernt', String(request.params.id)); + return { deleted: weg, bereiche: listWishCategories(), wishes: topWishes(50) }; + }); + // --- Wünsche verwalten (Team) --- const STATUS = { @@ -2034,6 +2088,14 @@ ${rssItems} return { ok: true, benachrichtigt, wishes: topWishes(50) }; }); + app.put('/api/wishes/:id/category', async (request, reply) => { + if (requireScope(request, reply, 'community')) return; + const id = Number(request.params.id); + if (!getWish(id)) return reply.code(404).send({ error: 'Wunsch nicht gefunden.' }); + setWishCategory(id, Number(request.body?.category_id) || null); + return { ok: true, 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) => {