diff --git a/README.md b/README.md index 655caf3..6667305 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-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. Ist der Voting-Kanal ein **Forum**, wird jeder Wunsch ein Forums-Beitrag mit **Tags** für Bereich und Stand — der Stand zieht beim Ändern automatisch mit. 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. Ein Klick öffnet die **eigene Seite der Idee** mit der Unterhaltung aus dem Discord-Thread — gespiegelt, geschrieben wird weiter dort. Ist der Voting-Kanal ein **Forum**, wird jeder Wunsch ein Forums-Beitrag mit **Tags** für Bereich und Stand — der Stand zieht beim Ändern automatisch mit. 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 | @@ -144,6 +144,7 @@ auf nichts mehr: keine Posts, keine Hintergrund-Prüfungen. | `/events` | öffentlich | Discord-Events (Playtests, Streams …) | | `/changelog` | öffentlich | Alle Releases mit Notes, Tag- und Pre-Release-Chips | | `/feed.xml` | öffentlich | RSS-Feed der Devlogs | +| `/wunsch/:id` | öffentlich | Eine Idee mit Stand, Begründung und der Unterhaltung aus dem Thread | | `/` | öffentlich | Frei angelegte Seiten aus dem Seiten-Editor | | `/impressum` · `/datenschutz` | öffentlich | Rechtstexte — Angaben aus der Config (Owner-only) | | `/profil` | Member | Eigenes Profil: Rang, XP, Rollen (togglebar), Alpha-Key, DSGVO-Löschung | @@ -454,6 +455,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 /api/wishes/:id` · `/api/wishes/suche?q=` | — | Eine Idee mit Beiträgen aus dem Thread · Suche vor dem Einreichen | | `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) | diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 43c9c96..b15dde3 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -17,6 +17,7 @@ import DevlogDetail from './pages/DevlogDetail.jsx'; import Impressum from './pages/Impressum.jsx'; import Datenschutz from './pages/Datenschutz.jsx'; import Seite from './pages/Seite.jsx'; +import WunschDetail from './pages/WunschDetail.jsx'; import { IconChevronDown, IconMenu, IconX, IconLock } from './icons.jsx'; import { useT, LangSwitch } from './i18n.jsx'; @@ -216,6 +217,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/locales/de.js b/frontend/src/locales/de.js index afea091..44b63ba 100644 --- a/frontend/src/locales/de.js +++ b/frontend/src/locales/de.js @@ -127,6 +127,15 @@ export default { 'roadmap.status': 'Stand', 'roadmap.area': 'Bereich', 'roadmap.areaPick': '— Bereich —', + 'wunsch.tag': '// Idee', + 'wunsch.back': 'Zurück zur Roadmap', + 'wunsch.notFound': 'Diese Idee gibt es nicht (mehr).', + 'wunsch.by': 'von %s', + 'wunsch.teamSays': 'Vom Team', + 'wunsch.comments': '// %s Beiträge', + 'wunsch.oneComment': '// 1 Beitrag', + 'wunsch.noComments': 'Noch nichts dazu geschrieben — mach den Anfang im Thread.', + 'wunsch.joinThread': 'Im Discord mitreden', '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 5075949..2a632b5 100644 --- a/frontend/src/locales/en.js +++ b/frontend/src/locales/en.js @@ -126,6 +126,15 @@ export default { 'roadmap.status': 'Status', 'roadmap.area': 'Area', 'roadmap.areaPick': '— Area —', + 'wunsch.tag': '// Idea', + 'wunsch.back': 'Back to the roadmap', + 'wunsch.notFound': 'This idea does not exist (any more).', + 'wunsch.by': 'by %s', + 'wunsch.teamSays': 'From the team', + 'wunsch.comments': '// %s comments', + 'wunsch.oneComment': '// 1 comment', + 'wunsch.noComments': 'Nothing said about this yet — start it off in the thread.', + 'wunsch.joinThread': 'Join the thread on Discord', '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 69970d1..2c49c20 100644 --- a/frontend/src/pages/Roadmap.jsx +++ b/frontend/src/pages/Roadmap.jsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; import { apiGet, apiPost } from '../api.js'; import { Markdown } from '../markdown.jsx'; import { SkeletonCards } from '../components/Skeleton.jsx'; @@ -289,7 +290,7 @@ export default function Roadmap() { ) : ( {w.score} )} - + {status !== 'offen' && ( {t(WISH_STATUS[status])} @@ -304,7 +305,7 @@ export default function Roadmap() { {w.status_grund && ( {w.status_grund} )} - + {/* Geredet wird im Thread unter dem Post — der Zähler führt direkt dorthin */} {w.thread_url && ( diff --git a/frontend/src/pages/WunschDetail.jsx b/frontend/src/pages/WunschDetail.jsx new file mode 100644 index 0000000..c87dac0 --- /dev/null +++ b/frontend/src/pages/WunschDetail.jsx @@ -0,0 +1,150 @@ +// Ein Wunsch für sich: Idee, Stand, Begründung — und darunter, was im +// Discord-Thread dazu geschrieben wurde. +// +// Die Beiträge sind gespiegelt, nicht abgetippt: geschrieben wird weiter in +// Discord, hier steht nur mit, was dort steht. Deshalb führt jeder Weg zum +// Mitreden in den Thread und nicht in ein Formular. +import { useEffect, useState } from 'react'; +import { useParams, Link } from 'react-router-dom'; +import { apiGet, apiPost } from '../api.js'; +import { SkeletonCards } from '../components/Skeleton.jsx'; +import { IconThumbsUp, IconMessage, IconArrowLeft } from '../icons.jsx'; +import { useT, useFmt } from '../i18n.jsx'; + +const WISH_STATUS = { + offen: 'wish.offen', + geplant: 'wish.geplant', + in_arbeit: 'wish.inArbeit', + umgesetzt: 'wish.umgesetzt', + abgelehnt: 'wish.abgelehnt', +}; + +export default function WunschDetail() { + const { id } = useParams(); + const { t } = useT(); + const { datum } = useFmt(); + const [daten, setDaten] = useState(null); + const [fehler, setFehler] = useState(false); + + useEffect(() => { + setDaten(null); + setFehler(false); + apiGet(`/api/wishes/${id}`).then(setDaten).catch(() => setFehler(true)); + window.scrollTo(0, 0); + }, [id]); + + async function vote() { + try { + const res = await apiPost(`/api/wishes/${id}/vote`); + setDaten((d) => ({ + ...d, + wunsch: { + ...d.wunsch, + voted: res.voted, + score: d.wunsch.score + (res.voted ? 1 : -1), + }, + })); + } catch { /* nicht angemeldet oder kein Member — der Knopf bleibt einfach */ } + } + + const w = daten?.wunsch; + const status = w?.status ?? 'offen'; + + return ( + <> +
+
IDEE
+
+ +
+ + {t('wunsch.back')} + + + {fehler &&

{t('wunsch.notFound')}

} + {!fehler && !daten && } + + {w && ( + <> +
+
+ {daten.canVote ? ( + + ) : ( + {w.score} + )} +
+ {t(WISH_STATUS[status])} + {w.kategorie && ( + {w.kategorie_emoji} {w.kategorie} + )} +
+
+

{w.idea}

+
+ {t('wunsch.by', w.author ?? '—')} + {datum(w.created_at, { day: '2-digit', month: '2-digit', year: 'numeric' })} +
+ {w.status_grund && ( +
+ {t('wunsch.teamSays')} + {w.status_grund} +
+ )} +
+ +
+

+ {daten.kommentare.length === 1 + ? t('wunsch.oneComment') + : t('wunsch.comments', daten.kommentare.length)} +

+ + {daten.kommentare.length === 0 && ( +

{t('wunsch.noComments')}

+ )} + + {daten.kommentare.map((k) => ( +
+ {k.avatar + ? + : } +
+
+ {k.author} + + {datum(k.created_at, { + day: '2-digit', month: '2-digit', + hour: '2-digit', minute: '2-digit', + })} + +
+

{k.content}

+
+
+ ))} + + {/* Geschrieben wird in Discord — hier steht nur mit, was dort steht */} + {w.thread_url && ( + + {t('wunsch.joinThread')} + + )} +
+ + )} +
+ + ); +} diff --git a/frontend/src/style.css b/frontend/src/style.css index 4f334d8..f61d869 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -2014,6 +2014,50 @@ label.toggle { .ep-zelle.aktiv { border-color: var(--neon); background: color-mix(in srgb, var(--neon) 12%, transparent); } .ep-nix { color: var(--muted); font-size: .84rem; margin: .6rem 0; } +/* ── EINE IDEE FÜR SICH ────────────────────────────── */ +.wd-zurueck { margin-bottom: 1.2rem; display: inline-flex; align-items: center; gap: .4rem; } +.wd-kopf { padding: 1.3rem 1.5rem; margin-bottom: 2rem; } +.wd-zeile { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; margin-bottom: .9rem; } +.wd-schilder { display: flex; gap: .35rem; flex-wrap: wrap; } +.wd-idee { margin: 0 0 .8rem; font-size: 1.05rem; font-weight: 300; line-height: 1.6; } +.wd-meta { + display: flex; gap: 1rem; flex-wrap: wrap; + font-family: var(--mono); font-size: .64rem; color: var(--muted2); +} +.wd-antwort { + margin-top: 1.1rem; padding: .8rem 1rem; + background: color-mix(in srgb, var(--neon) 6%, transparent); + border-left: 2px solid var(--neon); + font-size: .92rem; color: var(--text); +} +.wd-antwort-kopf { + display: block; margin-bottom: .3rem; + font-family: var(--mono); font-size: .58rem; letter-spacing: .12em; + text-transform: uppercase; color: var(--neon); +} + +/* Beiträge aus dem Thread — gespiegelt, nicht geschrieben */ +.wd-komm { + display: flex; gap: .8rem; padding: .8rem 0; + border-bottom: 1px solid rgba(255, 255, 255, .05); +} +.wd-komm:last-of-type { border-bottom: none; } +.wd-avatar { width: 34px; height: 34px; border-radius: 50%; flex: 0 0 auto; } +.wd-avatar.leer { background: var(--bg3); } +.wd-komm-text { min-width: 0; flex: 1; } +.wd-komm-kopf { display: flex; align-items: baseline; gap: .6rem; flex-wrap: wrap; } +.wd-komm-name { font-weight: 600; font-size: .88rem; } +.wd-komm-zeit { font-family: var(--mono); font-size: .6rem; color: var(--muted2); } +.wd-komm-inhalt { + margin: .25rem 0 0; font-weight: 300; line-height: 1.55; + white-space: pre-wrap; overflow-wrap: anywhere; +} +.wd-mitreden { margin-top: 1.2rem; display: inline-flex; align-items: center; gap: .45rem; } + +/* Die Idee in der Liste führt jetzt zur eigenen Seite */ +a.wish-idea { color: inherit; text-decoration: none; } +a.wish-idea:hover { color: var(--neon); } + /* ── DIALOG ────────────────────────────────────────── */ .dlg-backdrop { position: fixed; inset: 0; z-index: 1200; diff --git a/src/bot/wishes.js b/src/bot/wishes.js index ae7703e..67e4b7c 100644 --- a/src/bot/wishes.js +++ b/src/bot/wishes.js @@ -14,7 +14,8 @@ import { ModalBuilder, MessageFlags, StringSelectMenuBuilder, TextInputBuilder, TextInputStyle, } from 'discord.js'; import { - saveWish, setWishThread, bumpWishKommentare, getWishCategory, listWishCategories, + saveWish, setWishThread, wishByThread, getWishCategory, listWishCategories, + saveWishComment, updateWishComment, deleteWishComment, } from '../db.js'; import { votingChannelId, publicUrl, brandColor, brandFooter } from '../runtime-settings.js'; import { renderTemplate } from '../templates.js'; @@ -223,14 +224,51 @@ export async function handleWunschModal(interaction) { } } -/** Beiträge in Wunsch-Threads mitzählen — das ist der Kommentar-Zähler */ +/** + * Beiträge in Wunsch-Threads mitschreiben. + * + * Gespiegelt statt gezählt, damit die Detailseite die Unterhaltung zeigen + * kann. Bearbeiten und Löschen ziehen mit — sonst stünde auf der Webseite für + * immer, was in Discord längst zurückgenommen wurde. + */ export function registerWishThreads(client) { client.on(Events.MessageCreate, (message) => { try { if (message.author?.bot || !message.channel?.isThread?.()) return; - bumpWishKommentare(message.channelId); + const wunsch = wishByThread(message.channelId); + if (!wunsch) return; + const text = message.content?.trim(); + // Reine Bild-Posts haben keinen Text — die zeigt die Seite nicht + if (!text) return; + saveWishComment({ + message_id: message.id, + wish_id: wunsch.id, + author: message.member?.displayName ?? message.author.username, + author_id: message.author.id, + avatar: message.author.displayAvatarURL?.({ size: 64 }) ?? null, + content: text.slice(0, 2000), + created_at: new Date(message.createdTimestamp).toISOString().slice(0, 19).replace('T', ' '), + }); } catch (error) { - console.error('[voting] Kommentar-Zähler:', error); + console.error('[voting] Kommentar nicht gespeichert:', error); + } + }); + + client.on(Events.MessageUpdate, (alt, neu) => { + try { + if (!neu?.channel?.isThread?.()) return; + const text = neu.content?.trim(); + if (text) updateWishComment(neu.id, text.slice(0, 2000)); + } catch (error) { + console.error('[voting] Kommentar nicht geändert:', error); + } + }); + + client.on(Events.MessageDelete, (message) => { + try { + deleteWishComment(message.id); + } catch (error) { + console.error('[voting] Kommentar nicht entfernt:', error); } }); } diff --git a/src/db.js b/src/db.js index 23dfc38..2af9854 100644 --- a/src/db.js +++ b/src/db.js @@ -315,6 +315,37 @@ db.exec(` db.exec('ALTER TABLE wishes ADD COLUMN kommentare INTEGER NOT NULL DEFAULT 0'); } } +// Was im Thread unter einem Wunsch geschrieben wird. Gespiegelt, nicht +// gezaehlt: die Detailseite soll die Unterhaltung zeigen koennen, und 50 +// Threads je Seitenaufruf bei Discord abzufragen waere nicht zu bezahlen. +db.exec(` + CREATE TABLE IF NOT EXISTS wish_comments ( + message_id TEXT PRIMARY KEY, + wish_id INTEGER NOT NULL, + author TEXT, + author_id TEXT, + avatar TEXT, + content TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_wish_comments ON wish_comments (wish_id, created_at); +`); +const insertCommentStmt = db.prepare(` + INSERT OR REPLACE INTO wish_comments + (message_id, wish_id, author, author_id, avatar, content, created_at) + VALUES (@message_id, @wish_id, @author, @author_id, @avatar, @content, @created_at) +`); +const updateCommentStmt = db.prepare('UPDATE wish_comments SET content = ? WHERE message_id = ?'); +const deleteCommentStmt = db.prepare('DELETE FROM wish_comments WHERE message_id = ?'); +const listCommentsStmt = db.prepare( + 'SELECT * FROM wish_comments WHERE wish_id = ? ORDER BY created_at LIMIT ?' +); +const dropCommentsStmt = db.prepare('DELETE FROM wish_comments WHERE wish_id = ?'); +export const saveWishComment = (c) => insertCommentStmt.run(c); +export const updateWishComment = (messageId, text) => updateCommentStmt.run(text, messageId).changes > 0; +export const deleteWishComment = (messageId) => deleteCommentStmt.run(messageId).changes > 0; +export const wishComments = (wishId, limit = 200) => listCommentsStmt.all(wishId, limit); + 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( @@ -338,7 +369,8 @@ export const deleteWishCategory = db.transaction((id) => { /** Zuspruch = eigene Stimmen + nicht mehr zuordenbarer Sockel aus der Altzeit */ const WUNSCH_FELDER = ` 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, + w.status_grund, w.status_at, w.created_at, w.category_id, + (SELECT COUNT(*) FROM wish_comments c WHERE c.wish_id = w.id) AS kommentare, (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, @@ -351,9 +383,7 @@ const insertWish = db.prepare( '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 wishByThreadStmt = db.prepare('SELECT * FROM wishes 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 = ?'); @@ -387,18 +417,18 @@ export const saveWish = (messageId, author, idea, authorId = null, categoryId = 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 wishByThread = (threadId) => wishByThreadStmt.get(threadId) ?? null; 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, sortierung = 'top') => (topWishesStmts[sortierung] ?? topWishesStmts.top).all(limit); export const searchWishes = (text, limit = 8) => searchWishesStmt.all(`%${text}%`, limit); -export const deleteWish = (id) => { +export const deleteWish = db.transaction((id) => { dropVotesStmt.run(id); + dropCommentsStmt.run(id); return deleteWishStmt.run(id).changes > 0; -}; +}); export const setWishStatus = (id, status, grund) => setWishStatusStmt.run(status, grund || null, id).changes > 0; diff --git a/src/web/api.js b/src/web/api.js index 4a0dc54..87ddcab 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -16,6 +16,7 @@ import { saveTemplate, listTemplates, deleteTemplate, getLevelRow, isPlaytester, saveWish, toggleWishVote, myWishVotes, getWish, searchWishes, setWishStatus, mergeWish, deleteWish, wishVoters, setWishCategory, + wishComments, listWishCategories, createWishCategory, updateWishCategory, deleteWishCategory, logAudit, listAudit, playerHistory, uptimeBuckets, watchdogBuckets, watchdogDays, heartbeatBuckets, heartbeatFirst, @@ -1984,6 +1985,27 @@ ${rssItems} return { voted: toggleWishVote(id, user.id) }; }); + // Ein einzelner Wunsch mit allem, was im Thread darunter steht. + // Muss vor /api/wishes/:id/... stehen? Nein — andere Tiefe, kein Konflikt. + app.get('/api/wishes/:id', async (request, reply) => { + const id = Number(request.params.id); + const wunsch = getWish(id); + if (!wunsch) return reply.code(404).send({ error: 'Wunsch nicht gefunden' }); + const user = getSessionUser(request); + const gid = discordGuildId(); + return { + wunsch: { + ...wunsch, + voted: user ? myWishVotes(user.id).includes(id) : false, + thread_url: gid && wunsch.thread_id + ? `https://discord.com/channels/${gid}/${wunsch.thread_id}` + : null, + }, + kommentare: wishComments(id), + canVote: Boolean(user), + }; + }); + // Suche vor dem Einreichen — Doppler gar nicht erst entstehen lassen app.get('/api/wishes/suche', async (request) => { const q = String(request.query?.q ?? '').trim();