Playtester bekommen einen eigenen Bereich, Module ziehen um
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled

Playtester und Alpha-Keys standen als zwei Kaesten mitten im Community-Bereich
zwischen Geburtstagen und Starboard. Die Liste war reine Anzeige: Namen und
Datum, sonst nichts. Wer einem Einzelnen einen Schluessel geben wollte, konnte
nur "an alle verteilen" druecken, und wer jemanden wieder rausnehmen wollte,
musste an die Datenbank.

Jetzt ist es ein eigener Bereich mit drei Zahlen oben und einer Liste, in der
neben jedem Namen sein Schluessel steht. Daneben, je nach Zustand, "Schluessel
geben" oder "Zurueckziehen", und ein ✕ zum Entfernen — die Rolle geht dabei
mit, denn nur aus der Liste zu streichen und die Rolle stehen zu lassen waere
eine Halbwahrheit.

Der Vorrat ist jetzt sichtbar, nicht nur seine Groesse: einen vertippten
Schluessel findet man sonst nie wieder. Nachlegen und "wer bekommt einen?"
laufen ueber Dialoge, damit der Bereich nicht zumuellt — dafuer gibt es jetzt
eine kleine Dialog-Komponente, gebaut wie die Lightbox (Portal, Escape,
Hintergrund festhalten).

Zurueckziehen legt den Schluessel zurueck in den Vorrat. Die Person hat ihn per
DM natuerlich weiterhin — das ist eine Buchhaltung, keine Sperre. Loeschen geht
absichtlich nur bei freien Schluesseln.

## Module dorthin, wo man sie sucht

Ein paar Module standen im falschen Bereich, teils seit Langem:

- Willkommens-Karte: Support → Brand. Sie ist Aussehen, kein Support-Fall, und
  im Brand-Bereich steht der Rest vom Auftritt. Der Block ist woertlich
  umgezogen, nur in eine eigene Konstante — so kann sich beim Umzug nichts am
  Inhalt geaendert haben.
- Twitch & YouTube: System → Feeds. Es ist ein Feed.
- Events: System → Community.
- Auto-Rolle und Rollen merken: Ihre Felder standen laengst im Rollen-Bereich,
  nur das Register sagte "Community" — dadurch sprang "Einrichten" woanders hin
  als die Einstellung liegt.
- Playtester und Alpha-Keys: Community → eigener Bereich.

Damit ist System kein Sammelbecken mehr, sondern nur noch Technik: Waechter,
Sprachkanaele, Erinnerungen, Sicherungen, Member-Gate.

Geprueft: Routen (alle vier neuen bewacht), SQL gegen das Schema, und im
Browser beide Dialoge (Escape schliesst, Hintergrund wird wieder freigegeben,
Hinzufuegen bleibt bei leerem Feld aus), die Liste in allen drei Zustaenden,
und die Suche — "willkommen" fuehrt jetzt nach Brand, "twitch" zu Feeds,
"sticky" zu Rollen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 09:49:27 +02:00
co-authored by Claude Opus 5
parent bb2aee7818
commit ddd7610d6c
8 changed files with 559 additions and 208 deletions
+8
View File
@@ -299,6 +299,12 @@ const reserveKeyStmt = db.prepare(`
RETURNING key
`);
const unreserveKeyStmt = db.prepare('UPDATE alpha_keys SET assigned_to = NULL, assigned_at = NULL WHERE key = ?');
const freeKeysStmt = db.prepare(
'SELECT key FROM alpha_keys WHERE assigned_to IS NULL ORDER BY key'
);
// Nur freie Schlüssel löschen: einen vergebenen zu entfernen würde die Spur
// verwischen, wer ihn hat — zurückziehen geht über unreserveAlphaKey.
const deleteFreeKeyStmt = db.prepare('DELETE FROM alpha_keys WHERE key = ? AND assigned_to IS NULL');
export const addAlphaKeys = db.transaction((keys) => {
let added = 0;
for (const k of keys) added += insertAlphaKey.run(k).changes;
@@ -309,6 +315,8 @@ export const assignedAlphaKeys = () => assignedKeysStmt.all();
export const alphaKeyOf = (userId) => hasKeyForUserStmt.get(userId)?.key ?? null;
export const reserveAlphaKey = (userId) => reserveKeyStmt.get(userId)?.key ?? null;
export const unreserveAlphaKey = (key) => unreserveKeyStmt.run(key);
export const freeAlphaKeys = () => freeKeysStmt.all().map((r) => r.key);
export const deleteFreeAlphaKey = (key) => deleteFreeKeyStmt.run(key).changes > 0;
// Bewerbungs-Formulare (Modals) + eingereichte Bewerbungen
db.exec(`
+9 -9
View File
@@ -59,7 +59,7 @@ export const MODULES = [
{
id: 'social', group: 'inhalte', name: 'Twitch & YouTube',
desc: 'Meldet Livestreams und neue Videos.',
tab: 'system', default: true,
tab: 'feeds', default: true,
requires: [{ setting: 'social_announce_channel_id', label: 'Social-Kanal', create: { name: 'streams', topic: 'Twitch- und YouTube-Meldungen' } }],
fields: [
{ setting: 'twitch_channel', label: 'Twitch-Kanal', art: 'text', hint: 'Login-Name, z. B. d4rkst3r' },
@@ -120,21 +120,21 @@ export const MODULES = [
{
id: 'events', group: 'community', name: 'Event-Ankündigungen',
desc: 'Neue Discord-Events werden angekündigt und erscheinen auf der Events-Seite.',
tab: 'system', default: true,
tab: 'community', default: true,
requires: [{ setting: 'events_announce_channel_id', label: 'Event-Kanal', create: { name: 'events', topic: 'Playtests, Streams, Runden' } }],
},
{
id: 'playtester', group: 'community', name: 'Playtester-Programm',
desc: 'Bewerbungs-Knopf, Rolle und Teilnehmerliste.',
tab: 'community', default: true,
tab: 'playtester', default: true,
requires: [{ setting: 'playtester_role_id', label: 'Playtester-Rolle', create: { rolle: true, name: 'Playtester', farbe: '#23a55a' } }],
tool: { tab: 'community', label: 'Playtester-Liste' },
tool: { tab: 'playtester', label: 'Playtester-Liste' },
},
{
id: 'alpha_keys', group: 'community', name: 'Alpha-Keys',
desc: 'Schlüssel-Pool mit Verteilung per Direktnachricht.',
tab: 'community', default: true,
tool: { tab: 'community', label: 'Schlüssel-Pool' },
tab: 'playtester', default: true,
tool: { tab: 'playtester', label: 'Schlüssel-Vorrat' },
},
{
id: 'role_menus', group: 'community', name: 'Rollen-Menüs',
@@ -188,7 +188,7 @@ export const MODULES = [
{
id: 'welcome', group: 'moderation', name: 'Willkommens-Karten',
desc: 'Begrüßung neuer Mitglieder mit gerendertem Bild.',
tab: 'support', default: true,
tab: 'brand', default: true,
requires: [{ setting: 'welcome_channel_id', label: 'Willkommens-Kanal', create: { name: 'willkommen', topic: 'Begruessung neuer Mitglieder' } }],
fields: [
{ setting: 'welcome_card_enabled', label: 'Karte rendern', art: 'schalter', hint: 'aus = nur das Text-Embed' },
@@ -206,7 +206,7 @@ export const MODULES = [
{
id: 'autorole', group: 'moderation', name: 'Auto-Rolle',
desc: 'Neue Mitglieder bekommen automatisch eine Startrolle.',
tab: 'community', default: true,
tab: 'rollen', default: true,
requires: [{ setting: 'autorole_id', label: 'Auto-Rolle', create: { rolle: true, name: 'Member' } }],
},
{
@@ -217,7 +217,7 @@ export const MODULES = [
{
id: 'sticky_roles', group: 'moderation', name: 'Rollen merken',
desc: 'Wer den Server verlässt und zurückkommt, bekommt seine Rollen wieder.',
tab: 'community', setting: 'sticky_roles_enabled', defaultOn: false,
tab: 'rollen', setting: 'sticky_roles_enabled', defaultOn: false,
},
{
id: 'bug_reports', group: 'moderation', name: 'Fehlermeldungen',
+98 -18
View File
@@ -17,6 +17,7 @@ import {
getLevelRow, isPlaytester, saveWish, toggleWishVote, myWishVotes,
logAudit, listAudit, playerHistory, uptimeBuckets, watchdogBuckets, watchdogDays,
heartbeatBuckets, heartbeatFirst,
freeAlphaKeys, deleteFreeAlphaKey, removePlaytester,
listTicketCategories, createTicketCategory, updateTicketCategory, deleteTicketCategory,
listIncidents, listMonitored, createMonitored, updateMonitored, deleteMonitored,
deleteUserData,
@@ -38,7 +39,7 @@ import { publishRoleMenu, unpublishRoleMenu, MAX_ENTRIES } from '../bot/role-men
import { computeNextRun } from './scheduled-posts.js';
import { config } from '../config.js';
import { removeDevlog } from '../bot/devlog-archive.js';
import { commitChannelId, devlogChannelId, releaseChannelId, devlogPingRoleId, publicUrl, roadmapRepo, brandColor, brandFooter, brandName, giteaApiToken, votingChannelId, discordGuildId, discordInviteUrl, legalInfo, hubUrl, botUrl, welcomeCard } from '../runtime-settings.js';
import { commitChannelId, devlogChannelId, releaseChannelId, devlogPingRoleId, publicUrl, roadmapRepo, brandColor, brandFooter, brandName, giteaApiToken, votingChannelId, discordGuildId, discordInviteUrl, legalInfo, hubUrl, botUrl, welcomeCard, playtesterRoleId } from '../runtime-settings.js';
import { xpForLevel } from '../bot/levels.js';
import { getMilestones } from '../gitea-api.js';
import { getSessionUser, isAdmin, isGuildMember } from './auth.js';
@@ -158,6 +159,28 @@ export function registerApiRoutes(app, client) {
return { playtesters: listPlaytesters() };
});
// Playtester entfernen. Die Rolle geht mit — nur aus der Liste zu streichen
// und die Rolle stehen zu lassen wäre eine Halbwahrheit, die spätestens
// beim nächsten Verteilen auffällt.
app.delete('/api/playtesters/:id', async (request, reply) => {
if (requireScope(request, reply, 'community')) return;
const userId = String(request.params.id);
const weg = removePlaytester(userId);
let rolleWeg = false;
const roleId = playtesterRoleId();
if (roleId) {
try {
const gid = discordGuildId();
const guild = gid ? client.guilds.cache.get(gid) : client.guilds.cache.first();
const member = await guild?.members.fetch(userId);
await member?.roles.remove(roleId);
rolleWeg = true;
} catch { /* nicht mehr auf dem Server, oder Rolle steht über dem Bot */ }
}
logAudit(getSessionUser(request), 'playtester entfernt', userId);
return { ok: weg, rolleWeg, playtesters: listPlaytesters() };
});
// Community-Wünsche (Top 20) + Commit-Heatmap — öffentlich
app.get('/api/wishes', async (request) => {
const user = getSessionUser(request);
@@ -1286,11 +1309,42 @@ ${rssItems}
// --- Alpha-Keys (Admin) ---
const keyStatus = () => ({
free: freeAlphaKeyCount(),
assigned: assignedAlphaKeys(),
playtestersWithout: listPlaytesters().filter((p) => !alphaKeyOf(p.user_id)),
});
/** Wortlaut der Schlüssel-DM — an zwei Stellen gebraucht */
const keyText = (key) =>
`🎟️ **Dein EcoGame-Alpha-Key:**\n\`\`\`\n${key}\n\`\`\`\nViel Spaß beim Testen — Feedback gern per \`/bug\` oder \`/wunsch\`! 💛`;
const keyStatus = () => {
const belegt = new Map(assignedAlphaKeys().map((k) => [k.assigned_to, k]));
return {
free: freeAlphaKeyCount(),
// Der freie Vorrat selbst, nicht nur seine Größe: einen vertippten
// Schlüssel findet man sonst nie wieder
freieKeys: freeAlphaKeys(),
assigned: assignedAlphaKeys(),
// Jeder Playtester mit seinem Schlüssel daneben — die Liste ist die
// eine Stelle, an der man beides zusammen sieht
playtesters: listPlaytesters().map((t) => ({
...t,
key: belegt.get(t.user_id)?.key ?? null,
keySeit: belegt.get(t.user_id)?.assigned_at ?? null,
})),
playtestersWithout: listPlaytesters().filter((t) => !alphaKeyOf(t.user_id)),
};
};
/** Einen Schlüssel per DM zustellen. Klappt es nicht, bleibt er frei. */
async function keySenden(userId) {
const key = reserveAlphaKey(userId);
if (!key) return { ok: false, grund: 'Vorrat leer' };
try {
const user = await client.users.fetch(userId);
await user.send(keyText(key));
return { ok: true, key };
} catch {
unreserveAlphaKey(key);
return { ok: false, grund: 'Direktnachricht nicht zustellbar' };
}
}
app.get('/api/alphakeys', async (request, reply) => {
if (requireScope(request, reply, 'community')) return;
@@ -1315,23 +1369,49 @@ ${rssItems}
let sent = 0;
const failed = [];
for (const p of keyStatus().playtestersWithout) {
const key = reserveAlphaKey(p.user_id);
if (!key) break; // Pool leer
try {
const user = await client.users.fetch(p.user_id);
await user.send(
`🎟️ **Dein EcoGame-Alpha-Key:**\n\`\`\`\n${key}\n\`\`\`\nViel Spaß beim Testen — Feedback gern per \`/bug\` oder \`/wunsch\`! 💛`
);
sent++;
} catch {
unreserveAlphaKey(key);
failed.push(p.username ?? p.user_id);
}
const res = await keySenden(p.user_id);
if (!res.ok && res.grund === 'Vorrat leer') break;
if (res.ok) sent++;
else failed.push(p.username ?? p.user_id);
}
request.log.info(`Alpha-Keys verteilt: ${sent} gesendet, ${failed.length} fehlgeschlagen`);
logAudit(getSessionUser(request), 'alpha-keys verteilt', `${sent} verschickt`);
return { ok: true, sent, failed, ...keyStatus() };
});
// Gezielt an eine Person — häufiger gebraucht als „alle auf einmal"
app.post('/api/alphakeys/assign', async (request, reply) => {
if (requireScope(request, reply, 'community')) return;
const userId = String(request.body?.user_id ?? '').trim();
if (!userId) return reply.code(400).send({ error: 'user_id fehlt.' });
if (alphaKeyOf(userId)) return reply.code(400).send({ error: 'Hat schon einen Schlüssel.' });
const res = await keySenden(userId);
if (!res.ok) return reply.code(400).send({ error: res.grund });
logAudit(getSessionUser(request), 'alpha-key vergeben', userId);
return { ok: true, ...keyStatus() };
});
// Zurückziehen: der Schlüssel landet wieder im Vorrat. Die Person hat ihn
// per DM natürlich weiterhin — das ist eine Buchhaltung, keine Sperre.
app.post('/api/alphakeys/revoke', async (request, reply) => {
if (requireScope(request, reply, 'community')) return;
const key = String(request.body?.key ?? '').trim();
if (!key) return reply.code(400).send({ error: 'key fehlt.' });
unreserveAlphaKey(key);
logAudit(getSessionUser(request), 'alpha-key zurückgezogen', key);
return { ok: true, ...keyStatus() };
});
app.delete('/api/alphakeys/:key', async (request, reply) => {
if (requireScope(request, reply, 'community')) return;
const key = decodeURIComponent(String(request.params.key));
if (!deleteFreeAlphaKey(key)) {
return reply.code(400).send({ error: 'Nur freie Schlüssel lassen sich löschen.' });
}
logAudit(getSessionUser(request), 'alpha-key gelöscht', key);
return { ok: true, ...keyStatus() };
});
// --- Bewerbungs-Formulare (Admin) ---
const formToJson = (f) => ({ ...f, questions: JSON.parse(f.questions || '[]') });