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; 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 sessionGuilds = Array.isArray(req.session?.guilds) ? req.session.guilds : []; // Only allow guilds the user owns or can manage (manage_guild or admin) and where the bot is present const allowedIds = new Set( sessionGuilds .filter((g: any) => { if (!g) return false; if (g.owner) return true; const perms = g.permissions ? BigInt(g.permissions) : 0n; const hasAdmin = (perms & 0x8n) === 0x8n; const hasManageGuild = (perms & 0x20n) === 0x20n; return hasAdmin || hasManageGuild; }) .map((g: any) => g.id) ); const guilds = context.client?.guilds.cache .filter((g) => allowedIds.has(g.id)) .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, serverStatsEnabled: (modules as any).serverStatsEnabled === true || (modules as any).serverStatsConfig?.enabled === true } } }); }); 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', ...(guildId ? { guildId } : {}) } }), prisma.ticket.count({ where: { status: 'in-progress', ...(guildId ? { guildId } : {}) } }), prisma.ticket.count({ where: { status: 'closed', ...(guildId ? { guildId } : {}) } }) ]); // 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('/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 = {}; if (guildId) where.guildId = guildId; if (status) where.status = status; try { // 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('/tickets/pipeline', 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 tickets = await prisma.ticket.findMany({ where: { guildId }, orderBy: { createdAt: 'desc' }, take: 200 }); const grouped = { neu: [], in_bearbeitung: [], warten_auf_user: [], erledigt: [] } as Record; tickets.forEach((t) => { const statusVal = ['neu', 'in_bearbeitung', 'warten_auf_user', 'erledigt'].includes(t.status) ? t.status : 'neu'; (grouped as any)[statusVal].push(t); }); res.json({ pipeline: grouped }); }); router.post('/tickets/:id/status', requireAuth, async (req, res) => { const ticketId = req.params.id; const statusVal = typeof req.body.status === 'string' ? req.body.status : ''; if (!statusVal) return res.status(400).json({ error: 'status required' }); const updated = await context.tickets.updateStatus(ticketId, statusVal as any); if (!updated) return res.status(404).json({ error: 'not found' }); res.json({ ticket: updated }); }); router.get('/tickets/sla', 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 days = Math.min(Math.max(Number(req.query.days) || 30, 1), 180); const since = new Date(Date.now() - days * 24 * 3600 * 1000); const tickets = await prisma.ticket.findMany({ where: { guildId, createdAt: { gte: since } }, select: { createdAt: true, firstClaimAt: true, firstResponseAt: true, claimedBy: true } }); const supporterStats: Record< string, { supporterId: string; count: number; ttcSum: number; ttfrSum: number; ttfrCount: number; ttcCount: number } > = {}; const dayStats: Record< string, { date: string; count: number; ttcSum: number; ttfrSum: number; ttcCount: number; ttfrCount: number } > = {}; tickets.forEach((t) => { const dayKey = t.createdAt.toISOString().slice(0, 10); if (!dayStats[dayKey]) dayStats[dayKey] = { date: dayKey, count: 0, ttcSum: 0, ttfrSum: 0, ttcCount: 0, ttfrCount: 0 }; dayStats[dayKey].count += 1; if (t.claimedBy && t.firstClaimAt) { const diff = t.firstClaimAt.getTime() - t.createdAt.getTime(); const key = t.claimedBy; if (!supporterStats[key]) supporterStats[key] = { supporterId: key, count: 0, ttcSum: 0, ttfrSum: 0, ttcCount: 0, ttfrCount: 0 }; supporterStats[key].count += 1; supporterStats[key].ttcSum += diff; supporterStats[key].ttcCount += 1; dayStats[dayKey].ttcSum += diff; dayStats[dayKey].ttcCount += 1; } if (t.firstResponseAt) { const diff = t.firstResponseAt.getTime() - t.createdAt.getTime(); if (t.claimedBy) { const key = t.claimedBy; if (!supporterStats[key]) supporterStats[key] = { supporterId: key, count: 0, ttcSum: 0, ttfrSum: 0, ttcCount: 0, ttfrCount: 0 }; supporterStats[key].ttfrSum += diff; supporterStats[key].ttfrCount += 1; } dayStats[dayKey].ttfrSum += diff; dayStats[dayKey].ttfrCount += 1; } }); const supporters = Object.values(supporterStats).map((s) => ({ supporterId: s.supporterId, tickets: s.count, avgTTC: s.ttcCount ? Math.round(s.ttcSum / s.ttcCount) : null, avgTTFR: s.ttfrCount ? Math.round(s.ttfrSum / s.ttfrCount) : null })); const daysArr = Object.values(dayStats) .sort((a, b) => a.date.localeCompare(b.date)) .map((d) => ({ date: d.date, tickets: d.count, avgTTC: d.ttcCount ? Math.round(d.ttcSum / d.ttcCount) : null, avgTTFR: d.ttfrCount ? Math.round(d.ttfrSum / d.ttfrCount) : null })); res.json({ supporters, days: daysArr }); }); 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.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 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('/register/forms', 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 forms = await context.register.listForms(guildId); res.json({ forms }); }); router.post('/register/forms', 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 data = req.body || {}; const form = await context.register.saveForm({ guildId, name: data.name || 'Formular', description: data.description || '', reviewChannelId: data.reviewChannelId || undefined, notifyRoleIds: Array.isArray(data.notifyRoleIds) ? data.notifyRoleIds : typeof data.notifyRoleIds === 'string' ? data.notifyRoleIds.split(',').map((s: string) => s.trim()).filter(Boolean) : [], isActive: data.isActive !== false, fields: Array.isArray(data.fields) ? data.fields : [] }); res.json({ form }); }); router.put('/register/forms/: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' }); const data = req.body || {}; const form = await context.register.saveForm({ id, guildId, name: data.name || 'Formular', description: data.description || '', reviewChannelId: data.reviewChannelId || undefined, notifyRoleIds: Array.isArray(data.notifyRoleIds) ? data.notifyRoleIds : typeof data.notifyRoleIds === 'string' ? data.notifyRoleIds.split(',').map((s: string) => s.trim()).filter(Boolean) : [], isActive: data.isActive !== false, fields: Array.isArray(data.fields) ? data.fields : [] }); res.json({ form }); }); router.delete('/register/forms/: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' }); const ok = await context.register.deleteForm(guildId, id); if (!ok) return res.status(404).json({ error: 'not found' }); res.json({ ok: true }); }); router.post('/register/forms/:id/panel', 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' }); const channelId = typeof req.body.channelId === 'string' ? req.body.channelId : undefined; const message = typeof req.body.message === 'string' ? req.body.message : undefined; const msgId = await context.register.sendPanel(guildId, id, channelId, message); if (!msgId) return res.status(400).json({ error: 'panel failed' }); res.json({ ok: true, messageId: msgId }); }); router.get('/register/apps', 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 formId = typeof req.query.formId === 'string' ? req.query.formId : undefined; if (!guildId) return res.status(400).json({ error: 'guildId required' }); const apps = await context.register.listApplications(guildId, status, formId); res.json({ applications: apps }); }); router.get('/register/apps/:id', requireAuth, async (req, res) => { const app = await context.register.getApplication(req.params.id); if (!app) return res.status(404).json({ error: 'not found' }); res.json({ application: app }); }); router.get('/automations', 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 rules = await context.ticketAutomation.list(guildId); res.json({ rules }); }); router.post('/automations', 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 rule = await context.ticketAutomation.save({ guildId, name: req.body.name || 'Automation', condition: req.body.condition || {}, action: req.body.action || {}, active: req.body.active !== false }); res.json({ rule }); }); router.put('/automations/: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; const rule = await context.ticketAutomation.save({ id, guildId, name: req.body.name || 'Automation', condition: req.body.condition || {}, action: req.body.action || {}, active: req.body.active !== false }); res.json({ rule }); }); router.delete('/automations/: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' }); const ok = await context.ticketAutomation.remove(guildId, id); if (!ok) return res.status(404).json({ error: 'not found' }); res.json({ ok: true }); }); router.get('/kb', 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 articles = await context.knowledgeBase.list(guildId); res.json({ articles }); }); router.post('/kb', 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 keywords = typeof req.body.keywords === 'string' ? req.body.keywords .split(',') .map((s: string) => s.trim()) .filter(Boolean) : req.body.keywords || []; const article = await context.knowledgeBase.save({ guildId, title: req.body.title || 'Artikel', keywords, content: req.body.content || '' }); res.json({ article }); }); router.put('/kb/: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; const keywords = typeof req.body.keywords === 'string' ? req.body.keywords .split(',') .map((s: string) => s.trim()) .filter(Boolean) : req.body.keywords || []; const article = await context.knowledgeBase.save({ id, guildId, title: req.body.title || 'Artikel', keywords, content: req.body.content || '' }); res.json({ article }); }); router.delete('/kb/: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' }); const ok = await context.knowledgeBase.remove(guildId, id); if (!ok) return res.status(404).json({ error: 'not found' }); res.json({ ok: true }); }); 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.get('/server-stats', 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.stats.getConfig(guildId); res.json({ config: cfg }); }); router.post('/server-stats', 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 cfg = await context.stats.saveConfig(guildId, req.body.config || {}); res.json({ config: cfg }); }); router.post('/server-stats/refresh', 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.stats.refreshGuild(guildId); 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, registerEnabled, registerConfig, serverStatsEnabled, serverStatsConfig } = 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 parsedRegister = { enabled: registerConfig?.enabled ?? (typeof registerEnabled === 'string' ? registerEnabled === 'true' : registerEnabled ?? (current as any).registerEnabled), reviewChannelId: registerConfig?.reviewChannelId ?? (current as any).registerConfig?.reviewChannelId, notifyRoleIds: Array.isArray(registerConfig?.notifyRoleIds) && registerConfig?.notifyRoleIds.length ? registerConfig.notifyRoleIds : typeof registerConfig?.notifyRoleIds === 'string' ? registerConfig.notifyRoleIds .split(',') .map((s: string) => s.trim()) .filter(Boolean) : (current as any).registerConfig?.notifyRoleIds || [] }; 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, registerEnabled: parsedRegister.enabled, registerConfig: parsedRegister, serverStatsEnabled: typeof serverStatsEnabled === 'string' ? serverStatsEnabled === 'true' : serverStatsEnabled, serverStatsConfig: serverStatsConfig }); // 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;