Wuensche bekommen einen Ausgang statt nur einer Punktzahl
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:
@@ -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 |
|
| 📈 **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 |
|
| ⭐ **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) |
|
| 📸 **Screenshot-Galerie** | Bilder aus dem Screenshot-Kanal → öffentliche Galerie (lokal gespeichert, weil Discord-CDN-Links ablaufen) |
|
||||||
| 💡 **Feature-Voting** | `/wunsch` öffnet ein Eingabefenster (Idee + „warum wäre das gut?") → Voting-Post mit 👍; Top-Wünsche öffentlich auf der Roadmap |
|
| 💡 **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 |
|
||||||
| 🗳️ **Umfragen** | `/umfrage` erzeugt eine **native Discord-Poll** — keine Reaktions-Bastelei, Discord zählt selbst |
|
| 🗳️ **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 |
|
| 🎉 **Giveaways** | `/giveaway` (Admin): Teilnahme-Button, automatische Ziehung; am Gewinner-Post 🔁 Neu auslosen + 👥 Teilnehmerliste |
|
||||||
| 🎂 **Geburtstage** | `/geburtstag` zum Eintragen; morgens Gratulation im Kanal + Tages-Rolle |
|
| 🎂 **Geburtstage** | `/geburtstag` zum Eintragen; morgens Gratulation im Kanal + Tages-Rolle |
|
||||||
@@ -453,6 +453,7 @@ urllib.request.urlopen(req)
|
|||||||
| `GET /brand.css` · `/brand-nav.js` | — | Design-Tokens + Navigation für andere Apps (CORS offen) |
|
| `GET /brand.css` · `/brand-nav.js` | — | Design-Tokens + Navigation für andere Apps (CORS offen) |
|
||||||
| `GET /api/legal` · `DELETE /api/profile` | — / Member | Impressums-Angaben · DSGVO-Löschung |
|
| `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 |
|
| `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 /linked-roles` · `/linked-roles/callback` | Member | Discords Verknüpfungs-Ablauf für Linked Roles |
|
| `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) |
|
| `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 |
|
| `POST/GET /api/v1/*` | API-Key (Bearer) | Externe Skripte — siehe „API v1" oben |
|
||||||
|
|||||||
@@ -120,6 +120,14 @@ export default {
|
|||||||
'roadmap.activity': '// Aktivität — %s Commits in 12 Monaten',
|
'roadmap.activity': '// Aktivität — %s Commits in 12 Monaten',
|
||||||
'roadmap.commitsOn': '%s: %s Commits',
|
'roadmap.commitsOn': '%s: %s Commits',
|
||||||
'roadmap.commitOn': '%s: 1 Commit',
|
'roadmap.commitOn': '%s: 1 Commit',
|
||||||
|
'wish.alle': 'Alle',
|
||||||
|
'wish.offen': 'Wird geprüft',
|
||||||
|
'wish.geplant': 'Geplant',
|
||||||
|
'wish.inArbeit': 'In Arbeit',
|
||||||
|
'wish.umgesetzt': 'Umgesetzt',
|
||||||
|
'wish.abgelehnt': 'Nicht geplant',
|
||||||
|
'roadmap.noneInFilter': 'Hier steht gerade nichts.',
|
||||||
|
'roadmap.alreadyThere': 'Gibt es schon — stimm lieber dafür:',
|
||||||
'roadmap.wishes': '// Community-Wünsche',
|
'roadmap.wishes': '// Community-Wünsche',
|
||||||
'roadmap.unvote': 'Vote zurücknehmen',
|
'roadmap.unvote': 'Vote zurücknehmen',
|
||||||
'roadmap.vote': 'Dafür stimmen',
|
'roadmap.vote': 'Dafür stimmen',
|
||||||
|
|||||||
@@ -119,6 +119,14 @@ export default {
|
|||||||
'roadmap.activity': '// Activity — %s commits in 12 months',
|
'roadmap.activity': '// Activity — %s commits in 12 months',
|
||||||
'roadmap.commitsOn': '%s: %s commits',
|
'roadmap.commitsOn': '%s: %s commits',
|
||||||
'roadmap.commitOn': '%s: 1 commit',
|
'roadmap.commitOn': '%s: 1 commit',
|
||||||
|
'wish.alle': 'All',
|
||||||
|
'wish.offen': 'Under review',
|
||||||
|
'wish.geplant': 'Planned',
|
||||||
|
'wish.inArbeit': 'In progress',
|
||||||
|
'wish.umgesetzt': 'Shipped',
|
||||||
|
'wish.abgelehnt': 'Not planned',
|
||||||
|
'roadmap.noneInFilter': 'Nothing here right now.',
|
||||||
|
'roadmap.alreadyThere': 'Already suggested — vote for it instead:',
|
||||||
'roadmap.wishes': '// Community wishes',
|
'roadmap.wishes': '// Community wishes',
|
||||||
'roadmap.unvote': 'Take back your vote',
|
'roadmap.unvote': 'Take back your vote',
|
||||||
'roadmap.vote': 'Vote for this',
|
'roadmap.vote': 'Vote for this',
|
||||||
|
|||||||
@@ -19,6 +19,23 @@ const GROUPS = [
|
|||||||
{ id: 'done', tag: 'roadmap.done', empty: null },
|
{ id: 'done', tag: 'roadmap.done', empty: null },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Stand eines Wunsches. „offen" braucht kein Schild — das ist der Normalfall.
|
||||||
|
const WISH_STATUS = {
|
||||||
|
offen: 'wish.offen',
|
||||||
|
geplant: 'wish.geplant',
|
||||||
|
in_arbeit: 'wish.inArbeit',
|
||||||
|
umgesetzt: 'wish.umgesetzt',
|
||||||
|
abgelehnt: 'wish.abgelehnt',
|
||||||
|
};
|
||||||
|
const WISH_FILTER = [
|
||||||
|
{ id: 'alle', key: 'wish.alle' },
|
||||||
|
{ id: 'offen', key: 'wish.offen' },
|
||||||
|
{ id: 'geplant', key: 'wish.geplant' },
|
||||||
|
{ id: 'in_arbeit', key: 'wish.inArbeit' },
|
||||||
|
{ id: 'umgesetzt', key: 'wish.umgesetzt' },
|
||||||
|
{ id: 'abgelehnt', key: 'wish.abgelehnt' },
|
||||||
|
];
|
||||||
|
|
||||||
function Milestone({ m }) {
|
function Milestone({ m }) {
|
||||||
const { t } = useT();
|
const { t } = useT();
|
||||||
const { datum } = useFmt();
|
const { datum } = useFmt();
|
||||||
@@ -95,6 +112,8 @@ export default function Roadmap() {
|
|||||||
const [submitMsg, setSubmitMsg] = useState('');
|
const [submitMsg, setSubmitMsg] = useState('');
|
||||||
const [heatmap, setHeatmap] = useState(null);
|
const [heatmap, setHeatmap] = useState(null);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
|
const [filter, setFilter] = useState('alle');
|
||||||
|
const [aehnliche, setAehnliche] = useState([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiGet('/api/roadmap').then(setData).catch(() => setError(true));
|
apiGet('/api/roadmap').then(setData).catch(() => setError(true));
|
||||||
@@ -103,12 +122,30 @@ export default function Roadmap() {
|
|||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function vote(messageId) {
|
// Während des Tippens nachschlagen, ob es das schon gibt. Kurz warten,
|
||||||
|
// damit nicht jeder Tastendruck eine Anfrage auslöst.
|
||||||
|
useEffect(() => {
|
||||||
|
const text = idea.trim();
|
||||||
|
if (text.length < 3) { setAehnliche([]); return undefined; }
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
apiGet(`/api/wishes/suche?q=${encodeURIComponent(text)}`)
|
||||||
|
.then((d) => setAehnliche(d.treffer ?? []))
|
||||||
|
.catch(() => setAehnliche([]));
|
||||||
|
}, 350);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [idea]);
|
||||||
|
|
||||||
|
const sichtbareWuensche = filter === 'alle'
|
||||||
|
? wishes
|
||||||
|
: wishes.filter((w) => (w.status ?? 'offen') === filter);
|
||||||
|
|
||||||
|
async function vote(id) {
|
||||||
try {
|
try {
|
||||||
const res = await apiPost(`/api/wishes/${messageId}/vote`);
|
const res = await apiPost(`/api/wishes/${id}/vote`);
|
||||||
setWishes((old) => old.map((w) => w.message_id !== messageId
|
setWishes((old) => old.map((w) => w.id !== id
|
||||||
? w
|
? w
|
||||||
: { ...w, voted: res.voted, score: w.score + (res.voted ? 1 : -1) }));
|
: { ...w, voted: res.voted, score: w.score + (res.voted ? 1 : -1) }));
|
||||||
|
setAehnliche((old) => old.map((w) => (w.id === id ? { ...w, score: w.score + 1 } : w)));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.status === 403) setSubmitMsg(t('roadmap.voteMembersOnly'));
|
if (e.status === 403) setSubmitMsg(t('roadmap.voteMembersOnly'));
|
||||||
}
|
}
|
||||||
@@ -151,24 +188,63 @@ export default function Roadmap() {
|
|||||||
{(wishes.length > 0 || canVote) && (
|
{(wishes.length > 0 || canVote) && (
|
||||||
<div className="roadmap-group">
|
<div className="roadmap-group">
|
||||||
<h2 className="settings-title">{t('roadmap.wishes')}</h2>
|
<h2 className="settings-title">{t('roadmap.wishes')}</h2>
|
||||||
{wishes.map((w, i) => (
|
|
||||||
<div className="wish-row" key={w.message_id ?? `${w.created_at}-${i}`}>
|
{/* Ein Wunsch ohne sichtbaren Ausgang ist ein Wunsch, den
|
||||||
{canVote && w.message_id ? (
|
niemand mehr einreicht — deshalb steht der Stand vorne
|
||||||
|
dran und lässt sich filtern. */}
|
||||||
|
<div className="wish-filter">
|
||||||
|
{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 (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
className={`wish-chip${filter === id ? ' aktiv' : ''}`}
|
||||||
|
onClick={() => setFilter(id)}
|
||||||
|
>
|
||||||
|
{t(key)} <span className="wish-chip-n">{n}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sichtbareWuensche.length === 0 && (
|
||||||
|
<p className="notice">{t('roadmap.noneInFilter')}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sichtbareWuensche.map((w, i) => {
|
||||||
|
const status = w.status ?? 'offen';
|
||||||
|
return (
|
||||||
|
<div className={`wish-row st-${status}`} key={w.id ?? `${w.created_at}-${i}`}>
|
||||||
|
{canVote && w.id ? (
|
||||||
<button
|
<button
|
||||||
className={`wish-vote with-icon${w.voted ? ' voted' : ''}`}
|
className={`wish-vote with-icon${w.voted ? ' voted' : ''}`}
|
||||||
title={w.voted ? t('roadmap.unvote') : t('roadmap.vote')}
|
title={w.voted ? t('roadmap.unvote') : t('roadmap.vote')}
|
||||||
onClick={() => vote(w.message_id)}
|
onClick={() => vote(w.id)}
|
||||||
>
|
>
|
||||||
<IconThumbsUp size={14} /> {w.score}
|
<IconThumbsUp size={14} /> {w.score}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<span className="wish-score with-icon"><IconThumbsUp size={14} /> {w.score}</span>
|
<span className="wish-score with-icon"><IconThumbsUp size={14} /> {w.score}</span>
|
||||||
)}
|
)}
|
||||||
<span className="wish-idea">{w.idea}</span>
|
<span className="wish-idea">
|
||||||
|
{w.idea}
|
||||||
|
{w.status_grund && (
|
||||||
|
<span className="wish-grund">{w.status_grund}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{status !== 'offen' && (
|
||||||
|
<span className={`wish-status ${status}`}>{t(WISH_STATUS[status])}</span>
|
||||||
|
)}
|
||||||
<span className="wish-author">{w.author}</span>
|
<span className="wish-author">{w.author}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{canVote ? (
|
{canVote ? (
|
||||||
|
<div className="wish-form-wrap">
|
||||||
<div className="wish-form">
|
<div className="wish-form">
|
||||||
<input
|
<input
|
||||||
type="text" maxLength={500} placeholder={t('roadmap.ideaPlaceholder')}
|
type="text" maxLength={500} placeholder={t('roadmap.ideaPlaceholder')}
|
||||||
@@ -178,6 +254,24 @@ export default function Roadmap() {
|
|||||||
/>
|
/>
|
||||||
<button className="btn btn-save" onClick={submitWish}>{t('roadmap.submit')}</button>
|
<button className="btn btn-save" onClick={submitWish}>{t('roadmap.submit')}</button>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Beim Tippen zeigen, was es schon gibt — ein Klick
|
||||||
|
auf einen Treffer ist besser als ein Doppler */}
|
||||||
|
{aehnliche.length > 0 && (
|
||||||
|
<div className="wish-hits">
|
||||||
|
<span className="wish-hits-label">{t('roadmap.alreadyThere')}</span>
|
||||||
|
{aehnliche.map((w) => (
|
||||||
|
<button
|
||||||
|
key={w.id}
|
||||||
|
className="wish-hit"
|
||||||
|
onClick={() => { setIdea(''); vote(w.id); }}
|
||||||
|
title={t('roadmap.vote')}
|
||||||
|
>
|
||||||
|
<IconThumbsUp size={12} /> {w.score} · {w.idea}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="notice" style={{ padding: '.6rem 0 0' }}>
|
<p className="notice" style={{ padding: '.6rem 0 0' }}>
|
||||||
{t('roadmap.hint').split('%s')[0]}<code>/wunsch</code>{t('roadmap.hint').split('%s')[1]}
|
{t('roadmap.hint').split('%s')[0]}<code>/wunsch</code>{t('roadmap.hint').split('%s')[1]}
|
||||||
|
|||||||
@@ -76,6 +76,16 @@ const TABS = [
|
|||||||
find: 'mitarbeiter rechte bereiche protokoll audit zugriff' },
|
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.
|
// Tabs, die nur aus Formular-Blöcken bestehen — die dürfen zweispaltig laufen.
|
||||||
// Alles mit Editor, Vorschau oder Liste bleibt einspaltig.
|
// Alles mit Editor, Vorschau oder Liste bleibt einspaltig.
|
||||||
const DENSE_TABS = new Set(['feeds', 'community', 'support', 'system']);
|
const DENSE_TABS = new Set(['feeds', 'community', 'support', 'system']);
|
||||||
@@ -351,6 +361,10 @@ export default function Settings({ me }) {
|
|||||||
const [watchdog, setWatchdog] = useState(null);
|
const [watchdog, setWatchdog] = useState(null);
|
||||||
const [automod, setAutomod] = useState(null);
|
const [automod, setAutomod] = useState(null);
|
||||||
const [anliegen, setAnliegen] = useState([]);
|
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 [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 [dienstDraft, setDienstDraft] = useState({ id: null, name: '', url: '', gruppe: 'Dienste', oeffentlich: false });
|
||||||
const [owner, setOwner] = useState(null);
|
const [owner, setOwner] = useState(null);
|
||||||
@@ -481,6 +495,7 @@ export default function Settings({ me }) {
|
|||||||
apiGet('/api/watchdog').then(setWatchdog).catch(() => {});
|
apiGet('/api/watchdog').then(setWatchdog).catch(() => {});
|
||||||
apiGet('/api/automod').then(setAutomod).catch(() => {});
|
apiGet('/api/automod').then(setAutomod).catch(() => {});
|
||||||
apiGet('/api/ticket-categories').then((d) => setAnliegen(d.kategorien ?? [])).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/ssoapps').then((d) => setSsoApps(d.apps)).catch(() => {});
|
||||||
apiGet('/api/services').then((d) => setServices(d.services)).catch(() => {});
|
apiGet('/api/services').then((d) => setServices(d.services)).catch(() => {});
|
||||||
apiGet('/api/pages/all/list').then((d) => setPages(d.pages)).catch(() => {});
|
apiGet('/api/pages/all/list').then((d) => setPages(d.pages)).catch(() => {});
|
||||||
@@ -1754,6 +1769,102 @@ export default function Settings({ me }) {
|
|||||||
|
|
||||||
const tabCommunity = (
|
const tabCommunity = (
|
||||||
<>
|
<>
|
||||||
|
<div className="settings-section">
|
||||||
|
<h2 className="settings-title">// Wünsche</h2>
|
||||||
|
<p className="section-intro">
|
||||||
|
Ein Wunsch ohne sichtbaren Ausgang ist ein Wunsch, den beim nächsten Mal
|
||||||
|
niemand mehr einreicht. Der Stand steht auf der Roadmap und im Discord-Post;
|
||||||
|
beim Sprung auf <strong>umgesetzt</strong> bekommt jeder eine Nachricht, der
|
||||||
|
dafür gestimmt hat.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{wuensche.length === 0 ? (
|
||||||
|
<p className="notice">Noch keine Wünsche eingegangen.</p>
|
||||||
|
) : (
|
||||||
|
<div className="key-list">
|
||||||
|
{wuensche.map((w) => (
|
||||||
|
<div className="wu-row card" key={w.id}>
|
||||||
|
<div className="wu-head">
|
||||||
|
<span className="wish-score with-icon">👍 {w.score}</span>
|
||||||
|
<span className="wu-idee">{w.idea}</span>
|
||||||
|
<span className="key-used">{w.author}</span>
|
||||||
|
</div>
|
||||||
|
<div className="wu-aktionen">
|
||||||
|
{WUNSCH_STATUS.map(([id, label]) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
className={`wish-chip${(w.status ?? 'offen') === id ? ' aktiv' : ''}`}
|
||||||
|
onClick={() => wunschStatus(w, id)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
className="btn btn-mini"
|
||||||
|
style={{ marginLeft: 'auto' }}
|
||||||
|
onClick={() => setMergeVon(w)}
|
||||||
|
title="Als Doppler auf einen anderen Wunsch zeigen lassen"
|
||||||
|
>
|
||||||
|
Doppler
|
||||||
|
</button>
|
||||||
|
<button className="card-delete" title="Wunsch löschen" onClick={() => wunschLoeschen(w)}>✕</button>
|
||||||
|
</div>
|
||||||
|
{w.status_grund && <p className="wu-grund">{w.status_grund}</p>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{statusDialog && (
|
||||||
|
<Dialog
|
||||||
|
title={`${statusDialog.label} — kurz begründen`}
|
||||||
|
onClose={() => setStatusDialog(null)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button className="btn btn-ghost" onClick={() => setStatusDialog(null)}>Abbrechen</button>
|
||||||
|
<button className="btn btn-save" onClick={statusSpeichern}>Übernehmen</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="section-intro">„{statusDialog.wunsch.idea}"</p>
|
||||||
|
<div className="field">
|
||||||
|
<label>
|
||||||
|
Begründung <span className="field-hint">
|
||||||
|
steht öffentlich unter dem Wunsch und im Discord-Post — leer lassen geht auch
|
||||||
|
{statusDialog.status === 'umgesetzt' && ', kommt zusätzlich in die Nachricht an alle Stimmen-Geber'}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
maxLength={500}
|
||||||
|
placeholder={statusDialog.status === 'abgelehnt'
|
||||||
|
? 'Warum das nicht kommt — das ist die Antwort, die sonst fehlt.'
|
||||||
|
: 'Ein, zwei Sätze reichen.'}
|
||||||
|
value={statusGrund}
|
||||||
|
onChange={(e) => setStatusGrund(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mergeVon && (
|
||||||
|
<Dialog title="Doppler zusammenführen" onClose={() => setMergeVon(null)}>
|
||||||
|
<p className="section-intro">
|
||||||
|
„{mergeVon.idea}" verschwindet aus der Liste; die Stimmen wandern zum
|
||||||
|
gewählten Wunsch. Wer für beide gestimmt hat, zählt dort weiterhin einmal.
|
||||||
|
</p>
|
||||||
|
<div className="dlg-pick">
|
||||||
|
{wuensche.filter((w) => w.id !== mergeVon.id).map((w) => (
|
||||||
|
<button key={w.id} onClick={() => zusammenfuehren(mergeVon, w)}>
|
||||||
|
{w.idea}
|
||||||
|
<span className="pick-hint">👍 {w.score}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<h2 className="settings-title">// Geburtstage</h2>
|
<h2 className="settings-title">// Geburtstage</h2>
|
||||||
<div className="field" data-setting="birthday_channel_id">
|
<div className="field" data-setting="birthday_channel_id">
|
||||||
@@ -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() {
|
async function anliegenSpeichern() {
|
||||||
const body = { ...anliegenDraft };
|
const body = { ...anliegenDraft };
|
||||||
try {
|
try {
|
||||||
|
|||||||
+52
-1
@@ -466,9 +466,50 @@ body::after {
|
|||||||
font-family: var(--mono); font-size: .78rem;
|
font-family: var(--mono); font-size: .78rem;
|
||||||
color: var(--neon); white-space: nowrap; min-width: 3.5rem;
|
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; }
|
.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 ───────────────────────────────────────── */
|
/* ── GALERIE ───────────────────────────────────────── */
|
||||||
.gallery-grid {
|
.gallery-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -1888,6 +1929,16 @@ label.toggle {
|
|||||||
.pt-key { font-family: var(--mono); font-size: .74rem; color: var(--neon); }
|
.pt-key { font-family: var(--mono); font-size: .74rem; color: var(--neon); }
|
||||||
.pt-wartet { font-family: var(--mono); font-size: .66rem; color: var(--muted2); }
|
.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 ────────────────────────────────────────── */
|
/* ── DIALOG ────────────────────────────────────────── */
|
||||||
.dlg-backdrop {
|
.dlg-backdrop {
|
||||||
position: fixed; inset: 0; z-index: 1200;
|
position: fixed; inset: 0; z-index: 1200;
|
||||||
|
|||||||
+12
-6
@@ -22,7 +22,7 @@ import {
|
|||||||
import { handleRoleMenuButton } from './role-menus.js';
|
import { handleRoleMenuButton } from './role-menus.js';
|
||||||
import { handleGiveawayAdminButton } from './giveaways.js';
|
import { handleGiveawayAdminButton } from './giveaways.js';
|
||||||
import {
|
import {
|
||||||
removePlaytester, playtesterForm, isWish, bumpWish,
|
removePlaytester, playtesterForm, wishByMessage, addWishVote, removeWishVote,
|
||||||
getGiveaway, toggleGiveawayEntry, giveawayEntries,
|
getGiveaway, toggleGiveawayEntry, giveawayEntries,
|
||||||
} from '../db.js';
|
} from '../db.js';
|
||||||
import * as ping from './commands/ping.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)
|
// 👍-Reaktionen auf Feature-Wünsche zählen — jetzt mit Namen dran. Früher
|
||||||
const trackWishReaction = (delta) => async (reaction) => {
|
// 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 {
|
try {
|
||||||
if (reaction.emoji.name !== '👍') return;
|
if (reaction.emoji.name !== '👍') return;
|
||||||
|
if (user?.bot || !user?.id) return;
|
||||||
if (reaction.partial) await reaction.fetch().catch(() => {});
|
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) {
|
} catch (error) {
|
||||||
console.error('[voting] Reaction-Tracking fehlgeschlagen:', error);
|
console.error('[voting] Reaction-Tracking fehlgeschlagen:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
client.on(Events.MessageReactionAdd, trackWishReaction(1));
|
client.on(Events.MessageReactionAdd, trackWishReaction(true));
|
||||||
client.on(Events.MessageReactionRemove, trackWishReaction(-1));
|
client.on(Events.MessageReactionRemove, trackWishReaction(false));
|
||||||
|
|
||||||
// In Discord gelöscht = im Archiv gelöscht (inkl. lokaler Bilder)
|
// In Discord gelöscht = im Archiv gelöscht (inkl. lokaler Bilder)
|
||||||
client.on(Events.MessageDelete, async (message) => {
|
client.on(Events.MessageDelete, async (message) => {
|
||||||
|
|||||||
@@ -182,13 +182,6 @@ export const listGallery = (limit, offset) => ({
|
|||||||
|
|
||||||
// Feature-Voting (/wunsch) + Giveaways
|
// Feature-Voting (/wunsch) + Giveaways
|
||||||
db.exec(`
|
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 (
|
CREATE TABLE IF NOT EXISTS giveaways (
|
||||||
message_id TEXT PRIMARY KEY,
|
message_id TEXT PRIMARY KEY,
|
||||||
channel_id TEXT NOT NULL,
|
channel_id TEXT NOT NULL,
|
||||||
@@ -204,41 +197,186 @@ db.exec(`
|
|||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const insertWish = db.prepare('INSERT INTO wishes (message_id, author, idea) VALUES (?, ?, ?)');
|
// Wünsche haben eine eigene ID. Früher war die Discord-Nachrichten-ID der
|
||||||
const wishScoreStmt = db.prepare('UPDATE wishes SET score = MAX(0, score + ?) WHERE message_id = ?');
|
// Schlüssel — damit konnte ein Wunsch nur existieren, solange seine Nachricht
|
||||||
const hasWishStmt = db.prepare('SELECT 1 FROM wishes WHERE message_id = ?');
|
// existiert, das Team konnte keinen von Hand eintragen, und zwei Doppler ließen
|
||||||
const topWishesStmt = db.prepare(
|
// sich nicht zusammenführen.
|
||||||
'SELECT message_id, idea, author, score, created_at FROM wishes ORDER BY score DESC, created_at DESC LIMIT ?'
|
//
|
||||||
);
|
// Der Zuspruch wird nicht mehr hochgezählt, sondern gezählt: eine Zeile je
|
||||||
export const saveWish = (messageId, author, idea) => insertWish.run(messageId, author, idea);
|
// Person in wish_votes, egal ob der Klick im Web oder als 👍 in Discord kam.
|
||||||
export const isWish = (messageId) => Boolean(hasWishStmt.get(messageId));
|
// Der zusammengesetzte Schlüssel schließt Doppelstimmen damit aus, statt sie
|
||||||
export const bumpWish = (messageId, delta) => wishScoreStmt.run(delta, messageId);
|
// nachträglich zu korrigieren.
|
||||||
export const topWishes = (limit = 20) => topWishesStmt.all(limit);
|
|
||||||
|
|
||||||
// Web-Votes auf Wünsche (Discord-👍 laufen separat über Reactions)
|
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS wish_votes (
|
CREATE TABLE IF NOT EXISTS wishes (
|
||||||
message_id TEXT NOT NULL,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id TEXT NOT NULL,
|
message_id TEXT UNIQUE,
|
||||||
PRIMARY KEY (message_id, user_id)
|
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 (?, ?)');
|
// Einmalige Übernahme aus dem alten Aufbau (message_id als Schlüssel, Zuspruch
|
||||||
const deleteWishVote = db.prepare('DELETE FROM wish_votes WHERE message_id = ? AND user_id = ?');
|
// als hochgezählte Spalte). Läuft nur, solange die alte Form noch dasteht.
|
||||||
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 */
|
const spalten = db.prepare('PRAGMA table_info(wishes)').all().map((c) => c.name);
|
||||||
export function toggleWishVote(messageId, userId) {
|
if (spalten.includes('score')) {
|
||||||
if (hasWishVoteStmt.get(messageId, userId)) {
|
const umziehen = db.transaction(() => {
|
||||||
deleteWishVote.run(messageId, userId);
|
db.exec('ALTER TABLE wishes RENAME TO wishes_alt');
|
||||||
wishScoreStmt.run(-1, messageId);
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
insertWishVote.run(messageId, userId);
|
insertWishVote.run(wishId, userId, quelle);
|
||||||
wishScoreStmt.run(1, messageId);
|
|
||||||
return true;
|
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(`
|
const insertGiveaway = db.prepare(`
|
||||||
INSERT INTO giveaways (message_id, channel_id, prize, winners, ends_at)
|
INSERT INTO giveaways (message_id, channel_id, prize, winners, ends_at)
|
||||||
|
|||||||
+107
-9
@@ -15,6 +15,7 @@ import {
|
|||||||
saveWebAdmin, webAdminScopes, listWebAdmins, deleteWebAdmin,
|
saveWebAdmin, webAdminScopes, listWebAdmins, deleteWebAdmin,
|
||||||
saveTemplate, listTemplates, deleteTemplate,
|
saveTemplate, listTemplates, deleteTemplate,
|
||||||
getLevelRow, isPlaytester, saveWish, toggleWishVote, myWishVotes,
|
getLevelRow, isPlaytester, saveWish, toggleWishVote, myWishVotes,
|
||||||
|
getWish, searchWishes, setWishStatus, mergeWish, deleteWish, wishVoters,
|
||||||
logAudit, listAudit, playerHistory, uptimeBuckets, watchdogBuckets, watchdogDays,
|
logAudit, listAudit, playerHistory, uptimeBuckets, watchdogBuckets, watchdogDays,
|
||||||
heartbeatBuckets, heartbeatFirst,
|
heartbeatBuckets, heartbeatFirst,
|
||||||
freeAlphaKeys, deleteFreeAlphaKey, removePlaytester,
|
freeAlphaKeys, deleteFreeAlphaKey, removePlaytester,
|
||||||
@@ -186,7 +187,7 @@ export function registerApiRoutes(app, client) {
|
|||||||
const user = getSessionUser(request);
|
const user = getSessionUser(request);
|
||||||
const mine = user ? new Set(myWishVotes(user.id)) : new Set();
|
const mine = user ? new Set(myWishVotes(user.id)) : new Set();
|
||||||
return {
|
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),
|
canVote: Boolean(user),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -1947,19 +1948,116 @@ ${rssItems}
|
|||||||
});
|
});
|
||||||
const message = await channel.send({ embeds: [embed] });
|
const message = await channel.send({ embeds: [embed] });
|
||||||
await message.react('👍').catch(() => {});
|
await message.react('👍').catch(() => {});
|
||||||
saveWish(message.id, user.username, idea);
|
const id = saveWish(message.id, user.username, idea, user.id);
|
||||||
return { ok: true };
|
return { ok: true, id };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Web-Vote togglen (unabhängig von Discord-👍)
|
// Eine Stimme je Person — ob hier geklickt oder als 👍 in Discord, ist
|
||||||
app.post('/api/wishes/:messageId/vote', async (request, reply) => {
|
// dieselbe Zeile in wish_votes
|
||||||
|
app.post('/api/wishes/:id/vote', async (request, reply) => {
|
||||||
if (await requireMember(request, reply)) return;
|
if (await requireMember(request, reply)) return;
|
||||||
const user = getSessionUser(request);
|
const user = getSessionUser(request);
|
||||||
const messageId = String(request.params.messageId);
|
const id = Number(request.params.id);
|
||||||
if (!topWishes(1000).some((w) => w.message_id === messageId)) {
|
const wunsch = getWish(id);
|
||||||
return reply.code(404).send({ error: 'Wunsch nicht gefunden' });
|
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(() => {});
|
||||||
}
|
}
|
||||||
return { voted: toggleWishVote(messageId, user.id) };
|
|
||||||
|
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++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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) ---
|
// --- Team-Verwaltung (nur Owner) ---
|
||||||
|
|||||||
Reference in New Issue
Block a user