Add events module with dashboard UI, scheduling, signups, and settings updates; extend env/readme.
This commit is contained in:
@@ -1,47 +1,630 @@
|
||||
import { Router } from 'express';
|
||||
import { prisma } from '../../database/index.js';
|
||||
import { settings } from '../../config/state.js';
|
||||
import { NextFunction, Request, Response, Router } from 'express';
|
||||
import { prisma } from '../../database/index';
|
||||
import { settingsStore } from '../../config/state';
|
||||
import { context } from '../../config/context';
|
||||
import { LoggingService } from '../../services/loggingService';
|
||||
import { env } from '../../config/env';
|
||||
|
||||
const router = Router();
|
||||
const moduleService = context.modules;
|
||||
|
||||
router.get('/overview', async (_req, res) => {
|
||||
function requireAuth(req: Request, res: Response, next: NextFunction) {
|
||||
if (!req.session?.user) {
|
||||
return res.status(401).json({ error: 'unauthorized', login: '/auth/discord' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
function requireAdmin(req: Request, res: Response, next: NextFunction) {
|
||||
const userId = req.session?.user?.id;
|
||||
const allowed = Array.isArray(env.ownerIds) ? env.ownerIds.filter(Boolean) : [];
|
||||
if (!userId || !allowed.includes(userId)) {
|
||||
return res.status(403).json({ error: 'forbidden' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
router.get('/me', requireAuth, (req, res) => {
|
||||
const allowed = Array.isArray(env.ownerIds) ? env.ownerIds.filter(Boolean) : [];
|
||||
const isAdmin = !!req.session.user && allowed.includes(req.session.user.id);
|
||||
res.json({ user: { ...req.session.user, isAdmin } });
|
||||
});
|
||||
|
||||
router.get('/guilds', requireAuth, (_req, res) => {
|
||||
const guilds =
|
||||
context.client?.guilds.cache.map((g) => ({
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
icon: g.icon
|
||||
})) ?? [];
|
||||
res.json({ guilds });
|
||||
});
|
||||
|
||||
router.get('/guild/info', requireAuth, async (req, res) => {
|
||||
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
|
||||
if (!guildId) return res.status(400).json({ error: 'guildId required' });
|
||||
const guild =
|
||||
context.client?.guilds.cache.get(guildId) || (await context.client?.guilds.fetch(guildId).catch(() => null));
|
||||
if (!guild) return res.status(404).json({ error: 'guild not found' });
|
||||
const owner = await guild.fetchOwner().catch(() => null);
|
||||
const channels = guild.channels.cache;
|
||||
const textCount = channels.filter((c) => c.isTextBased()).size;
|
||||
const voiceCount = channels.filter((c) => c.isVoiceBased()).size;
|
||||
const modules = settingsStore.get(guildId) || {};
|
||||
res.json({
|
||||
guild: {
|
||||
id: guild.id,
|
||||
name: guild.name,
|
||||
icon: guild.icon,
|
||||
memberCount: guild.memberCount,
|
||||
createdAt: guild.createdAt?.getTime?.() || null,
|
||||
owner: owner ? { id: owner.id, tag: owner.user?.tag } : null,
|
||||
textCount,
|
||||
voiceCount,
|
||||
modules: {
|
||||
ticketsEnabled: modules.ticketsEnabled !== false,
|
||||
automodEnabled: modules.automodEnabled !== false,
|
||||
musicEnabled: modules.musicEnabled !== false,
|
||||
welcomeEnabled: modules.welcomeConfig?.enabled !== false,
|
||||
dynamicVoiceEnabled: modules.dynamicVoiceEnabled !== false,
|
||||
statuspageEnabled: (modules as any).statuspageEnabled !== false,
|
||||
birthdayEnabled: (modules as any).birthdayEnabled !== false,
|
||||
reactionRolesEnabled: (modules as any).reactionRolesEnabled !== false
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/guild/activity', requireAuth, (req, res) => {
|
||||
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
|
||||
if (!guildId) return res.status(400).json({ error: 'guildId required' });
|
||||
const activity = context.admin.getGuildActivity(guildId);
|
||||
res.json({ activity });
|
||||
});
|
||||
|
||||
router.get('/guild/logs', requireAuth, (req, res) => {
|
||||
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
|
||||
if (!guildId) return res.status(400).json({ error: 'guildId required' });
|
||||
const logs = context.admin.getGuildLogs(guildId).slice(0, 100);
|
||||
res.json({ logs });
|
||||
});
|
||||
|
||||
router.get('/overview', requireAuth, async (req, res) => {
|
||||
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
|
||||
try {
|
||||
const [open, inProgress, closed] = await Promise.all([
|
||||
prisma.ticket.count({ where: { status: 'open' } }),
|
||||
prisma.ticket.count({ where: { status: 'in-progress' } }),
|
||||
prisma.ticket.count({ where: { status: 'closed' } })
|
||||
prisma.ticket.count({ where: { status: 'open', ...(guildId ? { guildId } : {}) } }),
|
||||
prisma.ticket.count({ where: { status: 'in-progress', ...(guildId ? { guildId } : {}) } }),
|
||||
prisma.ticket.count({ where: { status: 'closed', ...(guildId ? { guildId } : {}) } })
|
||||
]);
|
||||
res.json({ tickets: { open, inProgress, closed } });
|
||||
// TODO: MODULE: Musik-Status mit Modul-Flag und pro Guild Sessions anreichern (aktiv/inaktiv, aktueller Track).
|
||||
const music = context.music.getStatus();
|
||||
res.json({ tickets: { open, inProgress, closed }, music });
|
||||
} catch (err) {
|
||||
res.json({ tickets: { open: 0, inProgress: 0, closed: 0 }, error: 'DB unavailable' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/tickets', async (_req, res) => {
|
||||
router.get('/admin/overview', requireAuth, requireAdmin, (_req, res) => {
|
||||
const overview = context.admin.getOverview();
|
||||
res.json({ overview });
|
||||
});
|
||||
|
||||
router.get('/admin/activity', requireAuth, requireAdmin, (_req, res) => {
|
||||
const activity = context.admin.getActivity();
|
||||
res.json(activity);
|
||||
});
|
||||
|
||||
router.get('/admin/logs', requireAuth, requireAdmin, (_req, res) => {
|
||||
res.json({ logs: context.admin.getLogs() });
|
||||
});
|
||||
|
||||
router.get('/tickets', requireAuth, async (req, res) => {
|
||||
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
|
||||
const status = typeof req.query.status === 'string' ? req.query.status : undefined;
|
||||
const take = Math.min(Number(req.query.take ?? 20) || 20, 100);
|
||||
const where: Record<string, any> = {};
|
||||
if (guildId) where.guildId = guildId;
|
||||
if (status) where.status = status;
|
||||
try {
|
||||
const tickets = await prisma.ticket.findMany({ orderBy: { createdAt: 'desc' }, take: 20 });
|
||||
// TODO: TICKETS: Filter/Suche/Sortierung per Query-Params erweitern (Status, Claim, Priorität, Zeitfenster) für Dashboard.
|
||||
const tickets = await prisma.ticket.findMany({ where, orderBy: { createdAt: 'desc' }, take });
|
||||
res.json({ tickets });
|
||||
} catch (err) {
|
||||
res.json({ tickets: [], error: 'DB unavailable' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/settings', (_req, res) => {
|
||||
res.json({ guilds: Array.from(settings.entries()) });
|
||||
router.get('/tickets/:id/transcript', requireAuth, async (req, res) => {
|
||||
const id = req.params.id;
|
||||
try {
|
||||
const ticket = await prisma.ticket.findFirst({ where: { id } });
|
||||
if (!ticket || !ticket.transcript) return res.status(404).send('Transcript not found');
|
||||
return res.sendFile(ticket.transcript);
|
||||
} catch {
|
||||
return res.status(500).send('Transcript error');
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/settings', (req, res) => {
|
||||
const { guildId, welcomeChannelId, logChannelId, automodEnabled, levelingEnabled } = req.body;
|
||||
router.get('/tickets/:id/messages', requireAuth, async (req, res) => {
|
||||
const id = req.params.id;
|
||||
try {
|
||||
const ticket = await prisma.ticket.findFirst({ where: { id } });
|
||||
if (!ticket) return res.status(404).json({ error: 'not found' });
|
||||
const channel = ticket.channelId ? await context.client?.channels.fetch(ticket.channelId) : null;
|
||||
if (!channel || !channel.isTextBased()) return res.status(404).json({ error: 'channel missing' });
|
||||
// TODO: TICKETS: Live-Messages per WebSocket/Server-Sent-Events streamen statt polling, inkl. Author-Rich-Info.
|
||||
const msgs = await (channel as any).messages.fetch({ limit: 50 });
|
||||
const data = msgs
|
||||
.sort((a, b) => a.createdTimestamp - b.createdTimestamp)
|
||||
.map((m: any) => ({
|
||||
id: m.id,
|
||||
author: { tag: m.author?.tag ?? 'Unknown', avatar: m.author?.displayAvatarURL?.() ?? null },
|
||||
content: m.content,
|
||||
createdAt: m.createdTimestamp
|
||||
}));
|
||||
res.json({ messages: data });
|
||||
} catch {
|
||||
res.status(500).json({ error: 'message fetch failed' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/tickets/:id/close', requireAuth, async (req, res) => {
|
||||
const id = req.params.id;
|
||||
try {
|
||||
const ticket = await prisma.ticket.findFirst({ where: { id } });
|
||||
if (!ticket) return res.status(404).json({ error: 'not found' });
|
||||
if (!context.client) return res.status(500).json({ error: 'client unavailable' });
|
||||
const channel = await context.client.channels.fetch(ticket.channelId).catch(() => null);
|
||||
if (channel && channel.isTextBased()) {
|
||||
const transcriptPath = await context.tickets.exportTranscript(channel as any, ticket.id);
|
||||
await prisma.ticket.update({ where: { id: ticket.id }, data: { status: 'closed', transcript: transcriptPath } });
|
||||
await context.tickets['sendTranscriptToLog'](channel.guild, transcriptPath, ticket as any).catch(() => undefined);
|
||||
await (channel as any).delete('Ticket geschlossen');
|
||||
} else {
|
||||
await prisma.ticket.update({ where: { id: ticket.id }, data: { status: 'closed' } });
|
||||
}
|
||||
res.json({ ok: true });
|
||||
} catch {
|
||||
res.status(500).json({ error: 'close failed' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/tickets/panel', requireAuth, async (req, res) => {
|
||||
const { guildId, channelId, title, description, categories } = req.body as {
|
||||
guildId?: string;
|
||||
channelId?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
categories?: { label: string; emoji?: string; customId: string }[];
|
||||
};
|
||||
if (!guildId || !channelId) return res.status(400).json({ error: 'guildId and channelId required' });
|
||||
try {
|
||||
// TODO: TICKETS: Panel-Template als Dashboard-Entwurf speichern (DB) statt ad-hoc zu senden; Mehrsprachigkeit berücksichtigen.
|
||||
const cfg = settingsStore.get(guildId);
|
||||
if (cfg?.ticketsEnabled === false) return res.status(403).json({ error: 'tickets disabled' });
|
||||
if (!context.client) return res.status(503).json({ error: 'bot client unavailable' });
|
||||
const guild = await context.client.guilds.fetch(guildId);
|
||||
const channel = await guild.channels.fetch(channelId);
|
||||
if (!channel || !channel.isTextBased()) return res.status(400).json({ error: 'channel not text' });
|
||||
const cats = (categories ?? []).filter((c) => c?.label && c?.customId).slice(0, 5);
|
||||
const panel =
|
||||
cats.length > 0
|
||||
? context.tickets.buildCustomPanel({ title, description, categories: cats })
|
||||
: context.tickets.buildPanelEmbed();
|
||||
const { embed, buttons } = panel;
|
||||
await (channel as any).send({ embeds: [embed], components: [buttons] });
|
||||
return res.json({ ok: true });
|
||||
} catch (err: any) {
|
||||
const code = err?.code || err?.status || err?.name;
|
||||
const message = err?.message || 'failed to send panel';
|
||||
if (code === 50001 || code === 'Missing Access') return res.status(403).json({ error: 'missing access' });
|
||||
if (code === 50013 || /Missing Permissions/i.test(message)) return res.status(403).json({ error: 'missing permissions', detail: message });
|
||||
if (code === 10003 || code === 'Unknown Channel') return res.status(404).json({ error: 'channel not found' });
|
||||
console.error('tickets/panel error', code, message);
|
||||
return res.status(500).json({ error: 'failed to send panel', detail: message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/tickets/support-login', requireAuth, async (req, res) => {
|
||||
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
|
||||
if (!guildId) return res.status(400).json({ error: 'guildId required' });
|
||||
const current = settings.get(guildId) ?? {};
|
||||
settings.set(guildId, {
|
||||
...current,
|
||||
welcomeChannelId: welcomeChannelId ?? current.welcomeChannelId,
|
||||
logChannelId: logChannelId ?? current.logChannelId,
|
||||
automodEnabled: automodEnabled ?? current.automodEnabled,
|
||||
levelingEnabled: levelingEnabled ?? current.levelingEnabled
|
||||
});
|
||||
const cfg = settingsStore.get(guildId) || {};
|
||||
const config = {
|
||||
panelChannelId: cfg.supportLoginConfig?.panelChannelId || '',
|
||||
panelMessageId: cfg.supportLoginConfig?.panelMessageId || '',
|
||||
title: cfg.supportLoginConfig?.title || 'Support Login',
|
||||
description: cfg.supportLoginConfig?.description || 'Melde dich als Support an/ab.',
|
||||
loginLabel: cfg.supportLoginConfig?.loginLabel || 'Ich bin jetzt im Support',
|
||||
logoutLabel: cfg.supportLoginConfig?.logoutLabel || 'Ich bin nicht mehr im Support',
|
||||
autoRefresh: cfg.supportLoginConfig?.autoRefresh !== false
|
||||
};
|
||||
const status = await context.tickets.getSupportStatus(guildId);
|
||||
res.json({ config, status, supportRoleId: cfg.supportRoleId || null });
|
||||
});
|
||||
|
||||
router.post('/tickets/support-login', requireAuth, async (req, res) => {
|
||||
const guildId = typeof req.body.guildId === 'string' ? req.body.guildId : undefined;
|
||||
if (!guildId) return res.status(400).json({ error: 'guildId required' });
|
||||
const config = {
|
||||
panelChannelId: typeof req.body.panelChannelId === 'string' ? req.body.panelChannelId : undefined,
|
||||
title: typeof req.body.title === 'string' ? req.body.title : undefined,
|
||||
description: typeof req.body.description === 'string' ? req.body.description : undefined,
|
||||
loginLabel: typeof req.body.loginLabel === 'string' ? req.body.loginLabel : undefined,
|
||||
logoutLabel: typeof req.body.logoutLabel === 'string' ? req.body.logoutLabel : undefined,
|
||||
autoRefresh: req.body.autoRefresh !== undefined ? !!req.body.autoRefresh : undefined
|
||||
};
|
||||
await settingsStore.set(guildId, { supportLoginConfig: config } as any);
|
||||
const msgId = await context.tickets.publishSupportPanel(guildId, config).catch(() => null);
|
||||
if (msgId) await settingsStore.set(guildId, { supportLoginConfig: { ...config, panelMessageId: msgId } } as any);
|
||||
res.json({ ok: true, config: { ...config, panelMessageId: msgId || config['panelMessageId'] } });
|
||||
});
|
||||
|
||||
router.get('/events', requireAuth, async (req, res) => {
|
||||
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
|
||||
if (!guildId) return res.status(400).json({ error: 'guildId required' });
|
||||
const events = await prisma.event.findMany({
|
||||
where: { guildId },
|
||||
orderBy: { startTime: 'asc' },
|
||||
include: { _count: { select: { signups: { where: { canceledAt: null } } as any } as any } as any } as any
|
||||
} as any);
|
||||
res.json({ events });
|
||||
});
|
||||
|
||||
router.post('/events', requireAuth, async (req, res) => {
|
||||
const {
|
||||
id,
|
||||
guildId,
|
||||
title,
|
||||
description,
|
||||
channelId,
|
||||
startTime,
|
||||
repeatType,
|
||||
repeatConfig,
|
||||
reminderOffsetMinutes,
|
||||
roleId,
|
||||
isActive
|
||||
} = req.body || {};
|
||||
if (!guildId) return res.status(400).json({ error: 'guildId required' });
|
||||
try {
|
||||
const saved = await context.events.saveEvent({
|
||||
id,
|
||||
guildId,
|
||||
title,
|
||||
description,
|
||||
channelId,
|
||||
startTime,
|
||||
repeatType,
|
||||
repeatConfig,
|
||||
reminderOffsetMinutes,
|
||||
roleId,
|
||||
isActive
|
||||
});
|
||||
res.json({ ok: true, event: saved });
|
||||
} catch {
|
||||
res.status(500).json({ error: 'save failed' });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/events/:id', requireAuth, async (req, res) => {
|
||||
const guildId = typeof req.body.guildId === 'string' ? req.body.guildId : undefined;
|
||||
const id = req.params.id;
|
||||
if (!guildId) return res.status(400).json({ error: 'guildId required' });
|
||||
await context.events.deleteEvent(guildId, id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get('/settings', requireAuth, (req, res) => {
|
||||
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
|
||||
if (guildId) {
|
||||
return res.json({ guildId, settings: settingsStore.get(guildId) ?? {} });
|
||||
}
|
||||
res.json({ guilds: Array.from(settingsStore.all().entries()) });
|
||||
});
|
||||
|
||||
router.get('/modules', requireAuth, async (req, res) => {
|
||||
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
|
||||
if (!guildId) return res.status(400).json({ error: 'guildId required' });
|
||||
const modules = await moduleService.getModulesForGuild(guildId);
|
||||
res.json({ modules });
|
||||
});
|
||||
|
||||
router.get('/birthday', requireAuth, async (req, res) => {
|
||||
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
|
||||
if (!guildId) return res.status(400).json({ error: 'guildId required' });
|
||||
const cfg = settingsStore.get(guildId) || {};
|
||||
const config = {
|
||||
enabled: cfg.birthdayEnabled ?? cfg.birthdayConfig?.enabled ?? true,
|
||||
channelId: cfg.birthdayConfig?.channelId ?? '',
|
||||
sendHour: cfg.birthdayConfig?.sendHour ?? 9,
|
||||
messageTemplate: cfg.birthdayConfig?.messageTemplate ?? 'Alles Gute zum Geburtstag, {user}!'
|
||||
};
|
||||
const birthdays = await prisma.birthday.findMany({ where: { guildId }, orderBy: { birthDate: 'asc' }, take: 200 });
|
||||
res.json({ config, birthdays });
|
||||
});
|
||||
|
||||
router.post('/birthday', requireAuth, async (req, res) => {
|
||||
const guildId = typeof req.body.guildId === 'string' ? req.body.guildId : undefined;
|
||||
if (!guildId) return res.status(400).json({ error: 'guildId required' });
|
||||
const enabledRaw = req.body.enabled;
|
||||
const channelId = typeof req.body.channelId === 'string' ? req.body.channelId.trim() : undefined;
|
||||
const template = typeof req.body.messageTemplate === 'string' ? req.body.messageTemplate.slice(0, 500) : undefined;
|
||||
const hour = Number(req.body.sendHour);
|
||||
const sendHour = Number.isFinite(hour) ? Math.min(23, Math.max(0, Math.round(hour))) : undefined;
|
||||
const enabled = typeof enabledRaw === 'string' ? enabledRaw === 'true' : enabledRaw;
|
||||
const config = {
|
||||
enabled,
|
||||
channelId: channelId || undefined,
|
||||
sendHour,
|
||||
messageTemplate: template
|
||||
};
|
||||
await settingsStore.set(guildId, { birthdayEnabled: enabled, birthdayConfig: config } as any);
|
||||
context.birthdays.invalidate(guildId);
|
||||
res.json({ ok: true, config });
|
||||
});
|
||||
|
||||
router.get('/reactionroles', requireAuth, async (req, res) => {
|
||||
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
|
||||
if (!guildId) return res.status(400).json({ error: 'guildId required' });
|
||||
const sets = await context.reactionRoles.listSets(guildId);
|
||||
res.json({ sets });
|
||||
});
|
||||
|
||||
router.post('/reactionroles', requireAuth, async (req, res) => {
|
||||
const guildId = typeof req.body.guildId === 'string' ? req.body.guildId : undefined;
|
||||
if (!guildId) return res.status(400).json({ error: 'guildId required' });
|
||||
if (!req.body.channelId) return res.status(400).json({ error: 'channelId required' });
|
||||
const entries = Array.isArray(req.body.entries)
|
||||
? req.body.entries
|
||||
.map((e: any) => ({
|
||||
emoji: typeof e.emoji === 'string' ? e.emoji.trim() : '',
|
||||
roleId: typeof e.roleId === 'string' ? e.roleId.trim() : '',
|
||||
label: typeof e.label === 'string' ? e.label.trim() : undefined,
|
||||
description: typeof e.description === 'string' ? e.description.trim() : undefined
|
||||
}))
|
||||
.filter((e: any) => e.emoji && e.roleId)
|
||||
: [];
|
||||
if (!entries.length) return res.status(400).json({ error: 'entries required' });
|
||||
try {
|
||||
const set = await context.reactionRoles.saveSet({
|
||||
guildId,
|
||||
channelId: typeof req.body.channelId === 'string' ? req.body.channelId : '',
|
||||
messageId: typeof req.body.messageId === 'string' ? req.body.messageId : undefined,
|
||||
title: typeof req.body.title === 'string' ? req.body.title : undefined,
|
||||
description: typeof req.body.description === 'string' ? req.body.description : undefined,
|
||||
entries
|
||||
});
|
||||
res.json({ set });
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: 'save failed' });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/reactionroles/:id', requireAuth, async (req, res) => {
|
||||
const guildId = typeof req.body.guildId === 'string' ? req.body.guildId : undefined;
|
||||
if (!guildId) return res.status(400).json({ error: 'guildId required' });
|
||||
const id = req.params.id;
|
||||
if (!req.body.channelId) return res.status(400).json({ error: 'channelId required' });
|
||||
const entries = Array.isArray(req.body.entries)
|
||||
? req.body.entries
|
||||
.map((e: any) => ({
|
||||
emoji: typeof e.emoji === 'string' ? e.emoji.trim() : '',
|
||||
roleId: typeof e.roleId === 'string' ? e.roleId.trim() : '',
|
||||
label: typeof e.label === 'string' ? e.label.trim() : undefined,
|
||||
description: typeof e.description === 'string' ? e.description.trim() : undefined
|
||||
}))
|
||||
.filter((e: any) => e.emoji && e.roleId)
|
||||
: [];
|
||||
if (!entries.length) return res.status(400).json({ error: 'entries required' });
|
||||
try {
|
||||
const set = await context.reactionRoles.saveSet({
|
||||
id,
|
||||
guildId,
|
||||
channelId: typeof req.body.channelId === 'string' ? req.body.channelId : '',
|
||||
messageId: typeof req.body.messageId === 'string' ? req.body.messageId : undefined,
|
||||
title: typeof req.body.title === 'string' ? req.body.title : undefined,
|
||||
description: typeof req.body.description === 'string' ? req.body.description : undefined,
|
||||
entries
|
||||
});
|
||||
res.json({ set });
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: 'save failed' });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/reactionroles/:id', requireAuth, async (req, res) => {
|
||||
const guildId = typeof req.body.guildId === 'string' ? req.body.guildId : undefined;
|
||||
if (!guildId) return res.status(400).json({ error: 'guildId required' });
|
||||
const id = req.params.id;
|
||||
try {
|
||||
await context.reactionRoles.deleteSet(guildId, id);
|
||||
res.json({ ok: true });
|
||||
} catch {
|
||||
res.status(400).json({ error: 'delete failed' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/statuspage', requireAuth, async (req, res) => {
|
||||
const guildId = typeof req.query.guildId === 'string' ? req.query.guildId : undefined;
|
||||
if (!guildId) return res.status(400).json({ error: 'guildId required' });
|
||||
const cfg = await context.statuspage.getConfig(guildId);
|
||||
res.json({ config: cfg });
|
||||
});
|
||||
|
||||
router.post('/statuspage', requireAuth, async (req, res) => {
|
||||
const guildId = typeof req.body.guildId === 'string' ? req.body.guildId : undefined;
|
||||
if (!guildId) return res.status(400).json({ error: 'guildId required' });
|
||||
await context.statuspage.saveConfig(guildId, req.body.config || {});
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post('/statuspage/service', requireAuth, async (req, res) => {
|
||||
const guildId = typeof req.body.guildId === 'string' ? req.body.guildId : undefined;
|
||||
if (!guildId) return res.status(400).json({ error: 'guildId required' });
|
||||
const svc = await context.statuspage.addService(guildId, req.body.service || {});
|
||||
res.json({ service: svc });
|
||||
});
|
||||
|
||||
router.put('/statuspage/service/:id', requireAuth, async (req, res) => {
|
||||
const guildId = typeof req.body.guildId === 'string' ? req.body.guildId : undefined;
|
||||
if (!guildId) return res.status(400).json({ error: 'guildId required' });
|
||||
await context.statuspage.updateService(guildId, req.params.id, req.body.service || {});
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.delete('/statuspage/service/:id', requireAuth, async (req, res) => {
|
||||
const guildId = typeof req.body.guildId === 'string' ? req.body.guildId : undefined;
|
||||
if (!guildId) return res.status(400).json({ error: 'guildId required' });
|
||||
await context.statuspage.deleteService(guildId, req.params.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post('/settings', requireAuth, async (req, res) => {
|
||||
const current = req.body.guildId ? settingsStore.get(req.body.guildId) ?? {} : {};
|
||||
const {
|
||||
guildId,
|
||||
welcomeChannelId,
|
||||
logChannelId,
|
||||
automodEnabled,
|
||||
automodConfig,
|
||||
loggingConfig,
|
||||
welcomeConfig,
|
||||
welcomeEnabled,
|
||||
levelingEnabled,
|
||||
ticketsEnabled,
|
||||
musicEnabled,
|
||||
dynamicVoiceEnabled,
|
||||
dynamicVoiceConfig,
|
||||
supportRoleId,
|
||||
statuspageEnabled,
|
||||
statuspageConfig,
|
||||
eventsEnabled,
|
||||
birthdayEnabled,
|
||||
birthdayConfig,
|
||||
reactionRolesEnabled,
|
||||
reactionRolesConfig
|
||||
} = req.body;
|
||||
if (!guildId) return res.status(400).json({ error: 'guildId required' });
|
||||
const normalizeArray = (val: any) =>
|
||||
Array.isArray(val)
|
||||
? val.map((s) => String(s).trim()).filter(Boolean)
|
||||
: typeof val === 'string'
|
||||
? val
|
||||
.split(/[,\\n]/)
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
const existingLogging = (current as any).loggingConfig || (current as any).automodConfig?.loggingConfig || {};
|
||||
const parsedLogging = {
|
||||
logChannelId: loggingConfig?.logChannelId ?? logChannelId ?? existingLogging.logChannelId ?? undefined,
|
||||
categories: {
|
||||
joinLeave: loggingConfig?.categories?.joinLeave ?? loggingConfig?.joinLeave ?? existingLogging.categories?.joinLeave ?? true,
|
||||
messageEdit: loggingConfig?.categories?.messageEdit ?? loggingConfig?.messageEdit ?? existingLogging.categories?.messageEdit ?? true,
|
||||
messageDelete: loggingConfig?.categories?.messageDelete ?? loggingConfig?.messageDelete ?? existingLogging.categories?.messageDelete ?? true,
|
||||
automodActions: loggingConfig?.categories?.automodActions ?? loggingConfig?.automodActions ?? existingLogging.categories?.automodActions ?? true,
|
||||
ticketActions: loggingConfig?.categories?.ticketActions ?? loggingConfig?.ticketActions ?? existingLogging.categories?.ticketActions ?? true,
|
||||
musicEvents: loggingConfig?.categories?.musicEvents ?? loggingConfig?.musicEvents ?? existingLogging.categories?.musicEvents ?? true,
|
||||
system: loggingConfig?.categories?.system ?? existingLogging.categories?.system ?? true
|
||||
}
|
||||
};
|
||||
|
||||
let parsedAutomod =
|
||||
typeof automodConfig === 'object'
|
||||
? { ...(current as any).automodConfig, ...(automodConfig ?? {}) }
|
||||
: automodConfig === undefined
|
||||
? { ...(current as any).automodConfig }
|
||||
: {
|
||||
...(current as any).automodConfig,
|
||||
spamThreshold: automodConfig?.spamThreshold ? Number(automodConfig.spamThreshold) : undefined,
|
||||
windowMs: automodConfig?.windowMs ? Number(automodConfig.windowMs) : undefined,
|
||||
linkWhitelist:
|
||||
typeof automodConfig?.linkWhitelist === 'string'
|
||||
? automodConfig.linkWhitelist
|
||||
.split(',')
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean)
|
||||
: undefined,
|
||||
spamTimeoutMinutes: automodConfig?.spamTimeoutMinutes ? Number(automodConfig.spamTimeoutMinutes) : undefined,
|
||||
deleteLinks: automodConfig?.deleteLinks !== undefined ? automodConfig.deleteLinks === 'true' || automodConfig.deleteLinks === true : undefined
|
||||
};
|
||||
parsedAutomod.customBadwords = normalizeArray(automodConfig?.customBadwords);
|
||||
parsedAutomod.whitelistRoles = normalizeArray(automodConfig?.whitelistRoles);
|
||||
parsedAutomod.logChannelId = automodConfig?.logChannelId ?? logChannelId ?? parsedLogging.logChannelId;
|
||||
parsedAutomod.loggingConfig = parsedLogging;
|
||||
parsedAutomod.statuspageEnabled =
|
||||
typeof statuspageEnabled === 'string' ? statuspageEnabled === 'true' : statuspageEnabled ?? (current as any).statuspageEnabled;
|
||||
parsedAutomod.statuspageConfig = statuspageConfig ?? (current as any).statuspageConfig;
|
||||
const normalizedWelcomeEnabled = typeof welcomeEnabled === 'string' ? welcomeEnabled === 'true' : welcomeEnabled;
|
||||
const parsedWelcome = {
|
||||
enabled: welcomeConfig?.enabled ?? normalizedWelcomeEnabled ?? (current as any).welcomeConfig?.enabled ?? false,
|
||||
channelId: welcomeConfig?.channelId ?? (current as any).welcomeConfig?.channelId ?? (current as any).welcomeChannelId ?? undefined,
|
||||
embedTitle: welcomeConfig?.embedTitle ?? (current as any).welcomeConfig?.embedTitle,
|
||||
embedDescription: welcomeConfig?.embedDescription ?? (current as any).welcomeConfig?.embedDescription,
|
||||
embedColor: welcomeConfig?.embedColor ?? (current as any).welcomeConfig?.embedColor,
|
||||
embedFooter: welcomeConfig?.embedFooter ?? (current as any).welcomeConfig?.embedFooter,
|
||||
embedThumbnail: welcomeConfig?.embedThumbnail ?? (current as any).welcomeConfig?.embedThumbnail,
|
||||
embedImage: welcomeConfig?.embedImage ?? (current as any).welcomeConfig?.embedImage,
|
||||
embedThumbnailData: welcomeConfig?.embedThumbnailData ?? (current as any).welcomeConfig?.embedThumbnailData,
|
||||
embedImageData: welcomeConfig?.embedImageData ?? (current as any).welcomeConfig?.embedImageData
|
||||
};
|
||||
parsedAutomod.welcomeConfig = parsedWelcome;
|
||||
|
||||
const parsedDynamicVoice = {
|
||||
lobbyChannelId: dynamicVoiceConfig?.lobbyChannelId ?? (current as any).dynamicVoiceConfig?.lobbyChannelId,
|
||||
categoryId: dynamicVoiceConfig?.categoryId ?? (current as any).dynamicVoiceConfig?.categoryId,
|
||||
template: dynamicVoiceConfig?.template ?? (current as any).dynamicVoiceConfig?.template,
|
||||
userLimit: dynamicVoiceConfig?.userLimit ?? (current as any).dynamicVoiceConfig?.userLimit,
|
||||
bitrate: dynamicVoiceConfig?.bitrate ?? (current as any).dynamicVoiceConfig?.bitrate
|
||||
};
|
||||
|
||||
const existingBirthday = (current as any).birthdayConfig || {};
|
||||
const sendHourVal = birthdayConfig?.sendHour ?? existingBirthday.sendHour;
|
||||
const parsedBirthday = {
|
||||
enabled:
|
||||
birthdayConfig?.enabled ??
|
||||
(typeof birthdayEnabled === 'string' ? birthdayEnabled === 'true' : birthdayEnabled ?? (current as any).birthdayEnabled),
|
||||
channelId: birthdayConfig?.channelId ?? existingBirthday.channelId,
|
||||
sendHour: Number.isFinite(Number(sendHourVal)) ? Math.min(23, Math.max(0, Number(sendHourVal))) : existingBirthday.sendHour,
|
||||
messageTemplate: birthdayConfig?.messageTemplate ?? existingBirthday.messageTemplate
|
||||
};
|
||||
|
||||
const existingReactionRoles = (current as any).reactionRolesConfig || {};
|
||||
const parsedReactionRoles = {
|
||||
enabled:
|
||||
reactionRolesConfig?.enabled ??
|
||||
(typeof reactionRolesEnabled === 'string'
|
||||
? reactionRolesEnabled === 'true'
|
||||
: reactionRolesEnabled ?? (current as any).reactionRolesEnabled),
|
||||
channelId: reactionRolesConfig?.channelId ?? existingReactionRoles.channelId
|
||||
};
|
||||
|
||||
const updated = await settingsStore.set(guildId, {
|
||||
welcomeChannelId: welcomeChannelId ?? undefined,
|
||||
logChannelId: logChannelId ?? undefined,
|
||||
automodEnabled: typeof automodEnabled === 'string' ? automodEnabled === 'true' : automodEnabled,
|
||||
automodConfig: parsedAutomod,
|
||||
welcomeConfig: parsedWelcome,
|
||||
loggingConfig: parsedLogging,
|
||||
levelingEnabled: typeof levelingEnabled === 'string' ? levelingEnabled === 'true' : levelingEnabled,
|
||||
ticketsEnabled: typeof ticketsEnabled === 'string' ? ticketsEnabled === 'true' : ticketsEnabled,
|
||||
musicEnabled: typeof musicEnabled === 'string' ? musicEnabled === 'true' : musicEnabled,
|
||||
dynamicVoiceEnabled: typeof dynamicVoiceEnabled === 'string' ? dynamicVoiceEnabled === 'true' : dynamicVoiceEnabled,
|
||||
dynamicVoiceConfig: parsedDynamicVoice,
|
||||
supportRoleId: supportRoleId ?? undefined,
|
||||
statuspageEnabled: typeof statuspageEnabled === 'string' ? statuspageEnabled === 'true' : statuspageEnabled,
|
||||
statuspageConfig: parsedAutomod.statuspageConfig,
|
||||
eventsEnabled: typeof eventsEnabled === 'string' ? eventsEnabled === 'true' : eventsEnabled,
|
||||
birthdayEnabled: parsedBirthday.enabled,
|
||||
birthdayConfig: parsedBirthday,
|
||||
reactionRolesEnabled: parsedReactionRoles.enabled,
|
||||
reactionRolesConfig: parsedReactionRoles
|
||||
});
|
||||
// Live update logging target
|
||||
context.logging = new LoggingService(updated.logChannelId);
|
||||
const { setLoggingAdmin } = await import('../../services/loggingService');
|
||||
setLoggingAdmin(context.admin);
|
||||
res.json({ ok: true, settings: updated });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,21 +1,75 @@
|
||||
import { Router } from 'express';
|
||||
import { env } from '../../config/env.js';
|
||||
import { env } from '../../config/env';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const baseUrl = () => {
|
||||
const origin = env.publicBaseUrl || `http://localhost:${env.port}`;
|
||||
const path = env.webBasePath || '/ucp';
|
||||
return `${origin}${path}`;
|
||||
};
|
||||
|
||||
router.get('/discord', (_req, res) => {
|
||||
const redirect = encodeURIComponent('http://localhost:' + env.port + '/auth/callback');
|
||||
const redirect = encodeURIComponent(`${baseUrl()}/auth/callback`);
|
||||
const url =
|
||||
`https://discord.com/api/oauth2/authorize?client_id=${env.clientId}` +
|
||||
`&redirect_uri=${redirect}&response_type=code&scope=identify%20guilds`;
|
||||
res.redirect(url);
|
||||
});
|
||||
|
||||
router.get('/callback', (req, res) => {
|
||||
router.get('/callback', async (req, res) => {
|
||||
const code = req.query.code;
|
||||
if (!code) return res.status(400).send('No code provided');
|
||||
// TODO: exchange code via Discord OAuth2 token endpoint
|
||||
res.send('OAuth2 Callback erhalten (Stub).');
|
||||
if (!env.clientSecret) return res.status(500).send('Missing DISCORD_CLIENT_SECRET');
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
client_id: env.clientId,
|
||||
client_secret: env.clientSecret,
|
||||
grant_type: 'authorization_code',
|
||||
code: String(code),
|
||||
redirect_uri: `${baseUrl()}/auth/callback`
|
||||
});
|
||||
|
||||
const tokenRes = await fetch('https://discord.com/api/oauth2/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params
|
||||
});
|
||||
|
||||
if (!tokenRes.ok) {
|
||||
const errText = await tokenRes.text();
|
||||
return res.status(500).send(`Token exchange failed: ${errText}`);
|
||||
}
|
||||
|
||||
const token = await tokenRes.json();
|
||||
const authHeader = { Authorization: `Bearer ${token.access_token}` };
|
||||
const [userRes, guildRes] = await Promise.all([
|
||||
fetch('https://discord.com/api/users/@me', { headers: authHeader }),
|
||||
fetch('https://discord.com/api/users/@me/guilds', { headers: authHeader })
|
||||
]);
|
||||
|
||||
const user = await userRes.json();
|
||||
const guilds = await guildRes.json();
|
||||
|
||||
req.session.user = user;
|
||||
req.session.guilds = guilds;
|
||||
req.session.token = {
|
||||
access_token: token.access_token,
|
||||
refresh_token: token.refresh_token,
|
||||
expires_at: Date.now() + (token.expires_in ?? 0) * 1000
|
||||
};
|
||||
|
||||
const basePath = env.webBasePath || '/ucp';
|
||||
req.session.save(() => res.redirect(`${basePath}/dashboard`));
|
||||
} catch (err) {
|
||||
res.status(500).send('OAuth failed');
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/logout', (req, res) => {
|
||||
const basePath = env.webBasePath || '/ucp';
|
||||
req.session?.destroy(() => res.redirect(`${basePath || '/'}`));
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,14 +2,17 @@ import express from 'express';
|
||||
import session from 'express-session';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import path from 'path';
|
||||
import authRouter from './routes/auth.js';
|
||||
import dashboardRouter from './routes/dashboard.js';
|
||||
import apiRouter from './routes/api.js';
|
||||
import { env } from '../config/env.js';
|
||||
import authRouter from './routes/auth';
|
||||
import dashboardRouter from './routes/dashboard';
|
||||
import apiRouter from './routes/api';
|
||||
import { env } from '../config/env';
|
||||
|
||||
export function createWebServer() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
const basePath = env.webBasePath || '/ucp';
|
||||
const dashboardPath = `${basePath}/dashboard`;
|
||||
const apiPath = `${basePath}/api`;
|
||||
app.use(express.json({ limit: '5mb' }));
|
||||
app.use(cookieParser());
|
||||
app.use(
|
||||
session({
|
||||
@@ -19,14 +22,52 @@ export function createWebServer() {
|
||||
})
|
||||
);
|
||||
|
||||
app.use('/auth', authRouter);
|
||||
app.use('/dashboard', dashboardRouter);
|
||||
app.use('/api', apiRouter);
|
||||
const mount = (suffix: string) => (basePath ? `${basePath}${suffix}` : suffix);
|
||||
app.use(mount('/auth'), authRouter);
|
||||
app.use(dashboardPath, dashboardRouter);
|
||||
app.use(mount('/api'), apiRouter);
|
||||
// fallback mounts if proxy strips base path
|
||||
if (basePath) {
|
||||
app.use('/api', apiRouter);
|
||||
app.use('/dashboard', dashboardRouter);
|
||||
}
|
||||
|
||||
app.get('/', (_req, res) => {
|
||||
res.send(`<h1>Papo Dashboard</h1><p><a href="/dashboard">Zum Dashboard</a></p>`);
|
||||
// Redirect bare auth calls to the prefixed path when a base path is set
|
||||
if (basePath) {
|
||||
app.use('/auth', (_req, res) => res.redirect(`${basePath}${_req.originalUrl}`));
|
||||
}
|
||||
|
||||
// Landing pages
|
||||
app.get('/', (_req, res) => res.redirect(dashboardPath));
|
||||
app.get(basePath || '/', (_req, res) => {
|
||||
res.send(`
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Papo Dashboard</title>
|
||||
<style>
|
||||
:root { --bg:#0b0f17; --card:rgba(18,20,30,0.72); --text:#f8fafc; --muted:#a5b4c3; --accent:#f97316; --border:rgba(255,255,255,0.06); }
|
||||
body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center; background:radial-gradient(circle at 18% 20%, rgba(249,115,22,0.16), transparent 32%), radial-gradient(circle at 82% -8%, rgba(255,166,99,0.12), transparent 28%), linear-gradient(140deg, #080c15 0%, #0c1220 48%, #080c15 100%); font-family:'Inter', system-ui, sans-serif; color:var(--text); }
|
||||
.shell { padding:32px 36px; border-radius:18px; background:var(--card); border:1px solid var(--border); box-shadow:0 20px 50px rgba(0,0,0,0.45); backdrop-filter:blur(12px); max-width:520px; width:calc(100% - 32px); text-align:center; }
|
||||
h1 { margin:0 0 10px; font-size:28px; letter-spacing:0.4px; }
|
||||
p { margin:0 0 18px; color:var(--muted); }
|
||||
a { display:inline-flex; align-items:center; gap:10px; padding:12px 18px; border-radius:14px; text-decoration:none; font-weight:800; color:white; background:linear-gradient(130deg, #ff9b3d, #f97316); border:1px solid rgba(249,115,22,0.45); box-shadow:0 14px 34px rgba(249,115,22,0.35); transition:transform 140ms ease, box-shadow 140ms ease; }
|
||||
a:hover { transform:translateY(-1px); box-shadow:0 16px 40px rgba(249,115,22,0.4); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<h1>Papo Dashboard</h1>
|
||||
<p>Verwalte Tickets, Module und Automod.</p>
|
||||
<a href="${dashboardPath}">Zum Dashboard</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
});
|
||||
|
||||
app.use('/static', express.static(path.join(process.cwd(), 'static')));
|
||||
app.use(mount('/static'), express.static(path.join(process.cwd(), 'static')));
|
||||
return app;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user