Compare commits

...

14 Commits

Author SHA1 Message Date
Pascal Prießnitz
ea9a387ee0 Merge branch 'main' of https://git.pepe44.dev/Pepe44DEV/Papo
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 13s
2025-12-03 22:11:31 +01:00
Pascal Prießnitz
96579f07e9 [deploy] Fix list parsing to escape newlines 2025-12-03 22:09:32 +01:00
Pascal Prießnitz
1d583462e0 [deploy] Fix list parsing regex 2025-12-03 22:03:03 +01:00
Pascal Prießnitz
dca8dda045 [deploy] Fix register newline literal
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 38s
2025-12-03 21:54:48 +01:00
Pascal Prießnitz
8639eafbf7 [deploy]
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 38s
2025-12-03 21:46:40 +01:00
Pascal Prießnitz
a9cc01dfaa [deploy] Fix dashboard syntax error by stripping bad icons
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 36s
2025-12-03 21:29:38 +01:00
Pascal Prießnitz
90bb12d054 [deploy] Fix dashboard nav encoding causing syntax error
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 38s
2025-12-03 21:19:31 +01:00
Pascal Prießnitz
c7136553c7 [deploy] Cleanup dashboard nav encoding and fix syntax error
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 37s
2025-12-03 19:47:27 +01:00
Pascal Prießnitz
5acd81f87d [deploy]
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 38s
2025-12-03 19:14:07 +01:00
Pascal Prießnitz
336708191b [deploy
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 36s
2025-12-03 19:01:36 +01:00
Pascal Prießnitz
e21d9e11b6 [deploy]
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 38s
2025-12-03 18:53:48 +01:00
Pascal Prießnitz
5681e14f8d [deploy] restore dashboard after register addition 2025-12-03 18:22:25 +01:00
Pascal Prießnitz
67643cb54d [deploy] fix register form relation
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 37s
2025-12-03 18:12:36 +01:00
Pascal Prießnitz
86282fbe07 [deploy] fix register schema sortOrder 2025-12-03 18:11:44 +01:00
12 changed files with 9294 additions and 264 deletions

79
node_modules/.prisma/client/edge.js generated vendored

File diff suppressed because one or more lines are too long

View File

@@ -141,6 +141,8 @@ exports.Prisma.GuildSettingsScalarFieldEnum = {
reactionRolesEnabled: 'reactionRolesEnabled',
reactionRolesConfig: 'reactionRolesConfig',
eventsEnabled: 'eventsEnabled',
registerEnabled: 'registerEnabled',
registerConfig: 'registerConfig',
supportRoleId: 'supportRoleId',
updatedAt: 'updatedAt',
createdAt: 'createdAt'
@@ -157,6 +159,30 @@ exports.Prisma.TicketScalarFieldEnum = {
status: 'status',
claimedBy: 'claimedBy',
transcript: 'transcript',
firstClaimAt: 'firstClaimAt',
firstResponseAt: 'firstResponseAt',
kbSuggestionSentAt: 'kbSuggestionSentAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.TicketAutomationRuleScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
name: 'name',
condition: 'condition',
action: 'action',
active: 'active',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.KnowledgeBaseArticleScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
title: 'title',
keywords: 'keywords',
content: 'content',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
@@ -228,6 +254,45 @@ exports.Prisma.EventSignupScalarFieldEnum = {
canceledAt: 'canceledAt'
};
exports.Prisma.RegisterFormScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
name: 'name',
description: 'description',
reviewChannelId: 'reviewChannelId',
notifyRoleIds: 'notifyRoleIds',
isActive: 'isActive',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.RegisterFormFieldScalarFieldEnum = {
id: 'id',
formId: 'formId',
label: 'label',
type: 'type',
required: 'required',
sortOrder: 'sortOrder'
};
exports.Prisma.RegisterApplicationScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
userId: 'userId',
formId: 'formId',
status: 'status',
reviewedBy: 'reviewedBy',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.RegisterApplicationAnswerScalarFieldEnum = {
id: 'id',
applicationId: 'applicationId',
fieldId: 'fieldId',
value: 'value'
};
exports.Prisma.SortOrder = {
asc: 'asc',
desc: 'desc'
@@ -262,12 +327,18 @@ exports.Prisma.NullsOrder = {
exports.Prisma.ModelName = {
GuildSettings: 'GuildSettings',
Ticket: 'Ticket',
TicketAutomationRule: 'TicketAutomationRule',
KnowledgeBaseArticle: 'KnowledgeBaseArticle',
Level: 'Level',
TicketSupportSession: 'TicketSupportSession',
Birthday: 'Birthday',
ReactionRoleSet: 'ReactionRoleSet',
Event: 'Event',
EventSignup: 'EventSignup'
EventSignup: 'EventSignup',
RegisterForm: 'RegisterForm',
RegisterFormField: 'RegisterFormField',
RegisterApplication: 'RegisterApplication',
RegisterApplicationAnswer: 'RegisterApplicationAnswer'
};
/**

8519
node_modules/.prisma/client/index.d.ts generated vendored

File diff suppressed because it is too large Load Diff

79
node_modules/.prisma/client/index.js generated vendored

File diff suppressed because one or more lines are too long

73
node_modules/.prisma/client/wasm.js generated vendored
View File

@@ -141,6 +141,8 @@ exports.Prisma.GuildSettingsScalarFieldEnum = {
reactionRolesEnabled: 'reactionRolesEnabled',
reactionRolesConfig: 'reactionRolesConfig',
eventsEnabled: 'eventsEnabled',
registerEnabled: 'registerEnabled',
registerConfig: 'registerConfig',
supportRoleId: 'supportRoleId',
updatedAt: 'updatedAt',
createdAt: 'createdAt'
@@ -157,6 +159,30 @@ exports.Prisma.TicketScalarFieldEnum = {
status: 'status',
claimedBy: 'claimedBy',
transcript: 'transcript',
firstClaimAt: 'firstClaimAt',
firstResponseAt: 'firstResponseAt',
kbSuggestionSentAt: 'kbSuggestionSentAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.TicketAutomationRuleScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
name: 'name',
condition: 'condition',
action: 'action',
active: 'active',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.KnowledgeBaseArticleScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
title: 'title',
keywords: 'keywords',
content: 'content',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
@@ -228,6 +254,45 @@ exports.Prisma.EventSignupScalarFieldEnum = {
canceledAt: 'canceledAt'
};
exports.Prisma.RegisterFormScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
name: 'name',
description: 'description',
reviewChannelId: 'reviewChannelId',
notifyRoleIds: 'notifyRoleIds',
isActive: 'isActive',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.RegisterFormFieldScalarFieldEnum = {
id: 'id',
formId: 'formId',
label: 'label',
type: 'type',
required: 'required',
sortOrder: 'sortOrder'
};
exports.Prisma.RegisterApplicationScalarFieldEnum = {
id: 'id',
guildId: 'guildId',
userId: 'userId',
formId: 'formId',
status: 'status',
reviewedBy: 'reviewedBy',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.RegisterApplicationAnswerScalarFieldEnum = {
id: 'id',
applicationId: 'applicationId',
fieldId: 'fieldId',
value: 'value'
};
exports.Prisma.SortOrder = {
asc: 'asc',
desc: 'desc'
@@ -262,12 +327,18 @@ exports.Prisma.NullsOrder = {
exports.Prisma.ModelName = {
GuildSettings: 'GuildSettings',
Ticket: 'Ticket',
TicketAutomationRule: 'TicketAutomationRule',
KnowledgeBaseArticle: 'KnowledgeBaseArticle',
Level: 'Level',
TicketSupportSession: 'TicketSupportSession',
Birthday: 'Birthday',
ReactionRoleSet: 'ReactionRoleSet',
Event: 'Event',
EventSignup: 'EventSignup'
EventSignup: 'EventSignup',
RegisterForm: 'RegisterForm',
RegisterFormField: 'RegisterFormField',
RegisterApplication: 'RegisterApplication',
RegisterApplicationAnswer: 'RegisterApplicationAnswer'
};
/**

View File

@@ -172,17 +172,18 @@ model RegisterForm {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
fields RegisterFormField[]
applications RegisterApplication[]
@@index([guildId, isActive])
}
model RegisterFormField {
id String @id @default(cuid())
formId String
label String
type String
required Boolean @default(false)
"order" Int @default(0)
id String @id @default(cuid())
formId String
label String
type String
required Boolean @default(false)
sortOrder Int @default(0)
form RegisterForm @relation(fields: [formId], references: [id], onDelete: Cascade)
}

View File

@@ -0,0 +1,69 @@
-- AlterTable
ALTER TABLE "GuildSettings" ADD COLUMN "registerConfig" JSONB,
ADD COLUMN "registerEnabled" BOOLEAN;
-- CreateTable
CREATE TABLE "RegisterForm" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"reviewChannelId" TEXT,
"notifyRoleIds" TEXT[],
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RegisterForm_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RegisterFormField" (
"id" TEXT NOT NULL,
"formId" TEXT NOT NULL,
"label" TEXT NOT NULL,
"type" TEXT NOT NULL,
"required" BOOLEAN NOT NULL DEFAULT false,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "RegisterFormField_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RegisterApplication" (
"id" TEXT NOT NULL,
"guildId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"formId" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"reviewedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RegisterApplication_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RegisterApplicationAnswer" (
"id" TEXT NOT NULL,
"applicationId" TEXT NOT NULL,
"fieldId" TEXT NOT NULL,
"value" TEXT NOT NULL,
CONSTRAINT "RegisterApplicationAnswer_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "RegisterForm_guildId_isActive_idx" ON "RegisterForm"("guildId", "isActive");
-- CreateIndex
CREATE INDEX "RegisterApplication_guildId_formId_status_idx" ON "RegisterApplication"("guildId", "formId", "status");
-- AddForeignKey
ALTER TABLE "RegisterFormField" ADD CONSTRAINT "RegisterFormField_formId_fkey" FOREIGN KEY ("formId") REFERENCES "RegisterForm"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RegisterApplication" ADD CONSTRAINT "RegisterApplication_formId_fkey" FOREIGN KEY ("formId") REFERENCES "RegisterForm"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RegisterApplicationAnswer" ADD CONSTRAINT "RegisterApplicationAnswer_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "RegisterApplication"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -174,17 +174,18 @@ model RegisterForm {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
fields RegisterFormField[]
applications RegisterApplication[]
@@index([guildId, isActive])
}
model RegisterFormField {
id String @id @default(cuid())
formId String
label String
type String
required Boolean @default(false)
"order" Int @default(0)
id String @id @default(cuid())
formId String
label String
type String
required Boolean @default(false)
sortOrder Int @default(0)
form RegisterForm @relation(fields: [formId], references: [id], onDelete: Cascade)
}

View File

@@ -1,6 +1,6 @@
import { AudioPlayer, AudioPlayerStatus, AudioResource, VoiceConnection, createAudioPlayer, createAudioResource, entersState, joinVoiceChannel, VoiceConnectionStatus, getVoiceConnection } from '@discordjs/voice';
import { AudioPlayer, AudioPlayerStatus, AudioResource, VoiceConnection, StreamType, createAudioPlayer, createAudioResource, entersState, joinVoiceChannel, VoiceConnectionStatus, getVoiceConnection } from '@discordjs/voice';
import { ChatInputCommandInteraction, GuildMember, TextChannel } from 'discord.js';
import play from 'play-dl';
import { Readable } from 'stream';
import { logger } from '../utils/logger';
import { settingsStore } from '../config/state';
@@ -8,7 +8,8 @@ export type LoopMode = 'off' | 'song' | 'queue';
interface QueueItem {
title: string;
url: string;
streamUrl: string;
displayUrl?: string;
requester: string;
originalQuery?: string;
}
@@ -24,6 +25,7 @@ interface QueueState {
export class MusicService {
private queues = new Map<string, QueueState>();
private spotifyToken: { value: string; expiresAt: number } | null = null;
private getQueue(guildId: string) {
const cfg = settingsStore.get(guildId);
@@ -82,7 +84,13 @@ export class MusicService {
await interaction.reply({ content: 'Der gefundene Link ist ungueltig.', ephemeral: true });
return;
}
const queueItem: QueueItem = { title: track.title ?? 'Unbekannt', url: track.url, requester: interaction.user.tag, originalQuery: trimmedQuery };
const queueItem: QueueItem = {
title: track.title ?? 'Unbekannt',
streamUrl: track.url,
displayUrl: track.url,
requester: interaction.user.tag,
originalQuery: trimmedQuery
};
const queue = this.getQueue(interaction.guildId);
if (!queue) {
const player = createAudioPlayer();
@@ -160,7 +168,7 @@ export class MusicService {
next = queue.current;
}
if (!next) break;
const streamUrlCheck = typeof next.url === 'string' ? next.url.trim() : '';
const streamUrlCheck = typeof next.streamUrl === 'string' ? next.streamUrl.trim() : '';
if (streamUrlCheck && streamUrlCheck !== 'undefined' && /^https?:\/\//i.test(streamUrlCheck)) {
break;
}
@@ -172,24 +180,16 @@ export class MusicService {
queue.channel.send({ content: 'Queue beendet.' }).catch(() => undefined);
return;
}
const streamUrl = (next.url || '').trim();
const streamUrl = (next.streamUrl || '').trim();
queue.current = next;
try {
const kind = await play.validate(streamUrl);
if (kind !== 'so_track') {
logger.error('Music stream error', { reason: 'unsupported_url', kind, item: next });
queue.channel.send({ content: `Nur SoundCloud wird unterstuetzt, ueberspringe: **${next.title ?? 'Unbekannt'}**.` }).catch(() => undefined);
queue.current = undefined;
this.processQueue(guildId);
return;
}
const finalUrl = streamUrl;
if (!finalUrl || !/^https?:\/\//i.test(finalUrl) || finalUrl === 'undefined') throw new Error('soundcloud_url_invalid');
const stream = await play.stream(finalUrl);
if (!stream?.stream) throw new Error('stream_invalid');
const resource: AudioResource = createAudioResource(stream.stream, {
inputType: stream.type
if (!/^https?:\/\//i.test(streamUrl)) throw new Error('spotify_stream_url_invalid');
const res = await fetch(streamUrl);
if (!res.ok || !res.body) throw new Error('spotify_stream_fetch_failed');
const body: any = typeof (res as any).body?.getReader === 'function' ? Readable.fromWeb(res.body as any) : (res as any).body;
const resource: AudioResource = createAudioResource(body, {
inputType: StreamType.Arbitrary
});
queue.player.play(resource);
queue.connection.subscribe(queue.player);
@@ -234,39 +234,79 @@ export class MusicService {
private async resolveTrack(query: string, opts?: { skipPlaylist?: boolean }): Promise<{ title: string; url: string } | null> {
const trimmed = query.trim();
if (!trimmed) return null;
try {
let validation: string | null = null;
try {
validation = await play.validate(trimmed);
} catch (err) {
logger.warn('Music validate error', err);
}
if (validation === 'so_track') {
return { title: trimmed, url: trimmed };
}
// nur SoundCloud erlaubt, alles andere ignorieren
} catch (err) {
logger.error('Music resolve error', err);
const token = await this.getSpotifyToken();
const trackId = this.extractSpotifyTrackId(trimmed);
if (trackId) {
const track = await this.fetchSpotifyTrack(trackId, token);
if (track) return track;
}
const search = await this.searchSpotifyTrack(trimmed, token);
return search;
}
const scSearch = await play.search(trimmed, { source: { soundcloud: 'tracks' }, limit: 1 }).catch((err) => {
logger.warn('SoundCloud search skipped', err?.message || err);
return [];
});
if (scSearch && scSearch.length) {
const sc = scSearch[0];
const url = sc.url || '';
if (url && /^https?:\/\//i.test(url)) return { title: sc.title ?? 'Unbekannt', url };
private async getSpotifyToken(): Promise<string> {
if (this.spotifyToken && this.spotifyToken.expiresAt > Date.now() + 30000) {
return this.spotifyToken.value;
}
const clientId = process.env.SPOTIFY_CLIENT_ID;
const clientSecret = process.env.SPOTIFY_CLIENT_SECRET;
if (!clientId || !clientSecret) throw new Error('missing_spotify_credentials');
const basic = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
const res = await fetch('https://accounts.spotify.com/api/token', {
method: 'POST',
headers: {
Authorization: `Basic ${basic}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: 'grant_type=client_credentials'
});
if (!res.ok) throw new Error('spotify_auth_failed');
const data = (await res.json()) as { access_token: string; expires_in: number };
const expiresInMs = Math.max(30_000, (data.expires_in || 3600) * 1000);
this.spotifyToken = { value: data.access_token, expiresAt: Date.now() + expiresInMs };
return data.access_token;
}
private extractSpotifyTrackId(query: string) {
const urlMatch = query.match(/spotify\.com\/track\/([A-Za-z0-9]+)/i);
if (urlMatch?.[1]) return urlMatch[1];
const uriMatch = query.match(/spotify:track:([A-Za-z0-9]+)/i);
if (uriMatch?.[1]) return uriMatch[1];
return null;
}
private buildVideoUrl(details: any): string | null {
if (!details) return null;
const url = details.url || details.permalink;
if (typeof url === 'string' && /^https?:\/\//i.test(url)) return url;
if (details.id) return `https://www.youtube.com/watch?v=${details.id}`;
if (details.videoId) return `https://www.youtube.com/watch?v=${details.videoId}`;
return null;
private async fetchSpotifyTrack(id: string, token: string): Promise<{ title: string; url: string } | null> {
const res = await fetch(`https://api.spotify.com/v1/tracks/${id}`, {
headers: { Authorization: `Bearer ${token}` }
});
if (!res.ok) {
logger.warn('Spotify track fetch failed', { status: res.status });
return null;
}
const data: any = await res.json();
if (!data?.preview_url) {
logger.warn('Spotify track has no preview_url', { id });
return null;
}
const artists = Array.isArray(data.artists) ? data.artists.map((a: any) => a.name).filter(Boolean).join(', ') : '';
const title = [data.name, artists].filter(Boolean).join(' - ');
return { title: title || data.name || 'Track', url: data.preview_url };
}
private async searchSpotifyTrack(query: string, token: string): Promise<{ title: string; url: string } | null> {
const market = process.env.SPOTIFY_MARKET || 'DE';
const res = await fetch(`https://api.spotify.com/v1/search?q=${encodeURIComponent(query)}&type=track&limit=1&market=${market}`, {
headers: { Authorization: `Bearer ${token}` }
});
if (!res.ok) {
logger.warn('Spotify search failed', { status: res.status });
return null;
}
const data: any = await res.json();
const track = data?.tracks?.items?.[0];
if (!track || !track.preview_url) return null;
const artists = Array.isArray(track.artists) ? track.artists.map((a: any) => a.name).filter(Boolean).join(', ') : '';
const title = [track.name, artists].filter(Boolean).join(' - ');
return { title: title || track.name || 'Track', url: track.preview_url };
}
}

View File

@@ -23,7 +23,7 @@ export class RegisterService {
}
public async listForms(guildId: string) {
return prisma.registerForm.findMany({ where: { guildId }, include: { fields: { orderBy: { order: 'asc' } } }, orderBy: { createdAt: 'desc' } });
return prisma.registerForm.findMany({ where: { guildId }, include: { fields: { orderBy: { sortOrder: 'asc' } } }, orderBy: { createdAt: 'desc' } });
}
public async saveForm(form: {
@@ -55,10 +55,10 @@ export class RegisterService {
label: f.label,
type: f.type,
required: f.required ?? false,
order: f.order ?? idx
sortOrder: f.order ?? idx
}))
});
return prisma.registerForm.findUnique({ where: { id: form.id }, include: { fields: { orderBy: { order: 'asc' } } } });
return prisma.registerForm.findUnique({ where: { id: form.id }, include: { fields: { orderBy: { sortOrder: 'asc' } } } });
}
const created = await prisma.registerForm.create({
data: {
@@ -73,11 +73,11 @@ export class RegisterService {
label: f.label,
type: f.type,
required: f.required ?? false,
order: f.order ?? idx
sortOrder: f.order ?? idx
}))
}
},
include: { fields: { orderBy: { order: 'asc' } } }
include: { fields: { orderBy: { sortOrder: 'asc' } } }
});
return created;
}
@@ -113,7 +113,7 @@ export class RegisterService {
public async handleButton(interaction: ButtonInteraction) {
if (interaction.customId.startsWith('register:form:')) {
const formId = interaction.customId.split(':')[2];
const form = await prisma.registerForm.findFirst({ where: { id: formId }, include: { fields: { orderBy: { order: 'asc' } } } });
const form = await prisma.registerForm.findFirst({ where: { id: formId }, include: { fields: { orderBy: { sortOrder: 'asc' } } } });
if (!form) return interaction.reply({ content: 'Formular nicht gefunden.', ephemeral: true });
const modal = new ModalBuilder().setTitle(form.name).setCustomId(`register:submit:${form.id}`);
const components: any[] = [];
@@ -164,7 +164,7 @@ export class RegisterService {
const formId = interaction.customId.split(':')[2];
const form = await prisma.registerForm.findFirst({
where: { id: formId },
include: { fields: { orderBy: { order: 'asc' } } }
include: { fields: { orderBy: { sortOrder: 'asc' } } }
});
if (!form) {
await interaction.reply({ content: 'Formular nicht gefunden.', ephemeral: true });
@@ -200,7 +200,7 @@ export class RegisterService {
const channel = await guild.channels.fetch(channelId).catch(() => null);
if (!channel || !channel.isTextBased()) return;
const member = await guild.members.fetch(userId).catch(() => null);
const fields = await prisma.registerFormField.findMany({ where: { formId: form.id }, orderBy: { order: 'asc' } });
const fields = await prisma.registerFormField.findMany({ where: { formId: form.id }, orderBy: { sortOrder: 'asc' } });
const answers = await prisma.registerApplicationAnswer.findMany({ where: { applicationId: app.id } });
const embed = new EmbedBuilder()
.setTitle(`Registrierung: ${form.name}`)
@@ -254,3 +254,5 @@ export class RegisterService {
});
}
}

View File

@@ -1,4 +1,4 @@
import { Router } from 'express';
import { Router } from 'express';
const router = Router();
@@ -43,20 +43,21 @@ router.get('/', (req, res) => {
<aside class="sidebar">
<div class="brand">Papo Control</div>
<div class="nav">
<a class="active" href="#overview" data-target="overview"><span class="icon">🏠</span> Uebersicht</a>
<a href="#tickets" data-target="tickets"><span class="icon">🎫</span> Ticketsystem</a>
<a href="#automod" data-target="automod" class="automod-link"><span class="icon">🛡️</span> Automod</a>
<a href="#welcome" data-target="welcome" class="welcome-link"><span class="icon"></span> Willkommen</a>
<a href="#dynamicvoice" data-target="dynamicvoice" class="dynamicvoice-link"><span class="icon">🎧</span> Dynamic Voice</a>
<a href="#birthday" data-target="birthday" class="birthday-link"><span class="icon">🎂</span> Birthday</a>
<a href="#reactionroles" data-target="reactionroles" class="reactionroles-link"><span class="icon">😎</span> Reaction Roles</a>
<a href="#statuspage" data-target="statuspage" class="statuspage-link"><span class="icon">🖥️</span> Statuspage</a>
<a href="#settings" data-target="settings"><span class="icon">⚙️</span> Einstellungen</a>
<a href="#modules" data-target="modules"><span class="icon">🧩</span> Module</a>
<a href="#events" data-target="events" class="events-link"><span class="icon">📅</span> Events</a>
<a href="#admin" data-target="admin" class="admin-link hidden"><span class="icon">🛡</span> Admin</a>
<a class="active" href="#overview" data-target="overview"><span class="icon">-</span> Uebersicht</a>
<a href="#tickets" data-target="tickets"><span class="icon">-</span> Ticketsystem</a>
<a href="#automod" data-target="automod" class="automod-link"><span class="icon">-</span> Automod</a>
<a href="#welcome" data-target="welcome" class="welcome-link"><span class="icon">-</span> Willkommen</a>
<a href="#dynamicvoice" data-target="dynamicvoice" class="dynamicvoice-link"><span class="icon">-</span> Dynamic Voice</a>
<a href="#birthday" data-target="birthday" class="birthday-link"><span class="icon">-</span> Birthday</a>
<a href="#reactionroles" data-target="reactionroles" class="reactionroles-link"><span class="icon">-</span> Reaction Roles</a>
<a href="#statuspage" data-target="statuspage" class="statuspage-link"><span class="icon">-</span> Statuspage</a>
<a href="#settings" data-target="settings"><span class="icon">-</span> Einstellungen</a>
<a href="#modules" data-target="modules"><span class="icon">-</span> Module</a>
<a href="#register" data-target="register" class="register-link"><span class="icon">-</span> Register</a>
<a href="#events" data-target="events" class="events-link"><span class="icon">-</span> Events</a>
<a href="#admin" data-target="admin" class="admin-link hidden"><span class="icon">-</span> Admin</a>
</div>
<div class="muted">Angemeldet als <span id="userInfo"></span></div>
<div class="muted"> Angemeldet als <span id="userInfo"></span></div>
<button id="logoutBtn" class="logout">Logout</button>
</aside>
`;
@@ -214,6 +215,11 @@ router.get('/', (req, res) => {
.module-meta { display:flex; flex-direction:column; gap:4px; }
.module-title { font-weight:700; color:var(--text); }
.module-desc { color:var(--muted); font-size:13px; }
.register-tabs { display:flex; gap:8px; flex-wrap:wrap; margin-top:6px; }
.register-tab { display:none; }
.register-tab.active { display:block; }
.register-meta { display:flex; gap:8px; flex-wrap:wrap; color:var(--muted); font-size:12px; }
.register-answer { padding:10px 12px; border-radius:12px; border:1px solid rgba(255,255,255,0.08); background:rgba(255,255,255,0.04); }
.toast { position:fixed; top:20px; right:24px; padding:12px 14px; border-radius:12px; background:rgba(249,115,22,0.18); border:1px solid rgba(249,115,22,0.45); color:#ffe6d0; font-weight:700; box-shadow:0 12px 32px rgba(0,0,0,0.34); opacity:0; transform:translateY(-10px); transition:opacity 150ms ease, transform 150ms ease; z-index:1100; backdrop-filter:blur(10px); }
.toast.error { background:rgba(239,68,68,0.18); border-color:rgba(239,68,68,0.4); color:#ffe4e6; }
.toast.show { opacity:1; transform:translateY(0); }
@@ -330,10 +336,10 @@ router.get('/', (req, res) => {
<div class="card" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center; justify-content:space-between;">
<div>
<p class="section-title">Tickets</p>
<p class="section-sub"><EFBFBD>bersicht, Pipeline, SLA, Automationen, Knowledge-Base.</p>
<p class="section-sub"> bersicht, Pipeline, SLA, Automationen, Knowledge-Base.</p>
</div>
<div class="row" style="gap:8px; flex-wrap:wrap;">
<button class="secondary-btn ticket-tab-btn active" data-tab="overview"><EFBFBD>bersicht</button>
<button class="secondary-btn ticket-tab-btn active" data-tab="overview"> bersicht</button>
<button class="secondary-btn ticket-tab-btn" data-tab="pipeline">Pipeline</button>
<button class="secondary-btn ticket-tab-btn" data-tab="sla">SLA</button>
<button class="secondary-btn ticket-tab-btn" data-tab="automations">Automationen</button>
@@ -346,7 +352,7 @@ router.get('/', (req, res) => {
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px;">
<div>
<p class="section-title">Ticketliste</p>
<p class="section-sub">Links ausw<EFBFBD>hlen, Details im Modal. Plus <EFBFBD>ffnet Panel-Erstellung.</p>
<p class="section-sub">Links ausw hlen, Details im Modal. Plus ffnet Panel-Erstellung.</p>
</div>
<div class="row" style="gap:10px;">
<button class="secondary-btn" id="openSupportLogin" type="button">Support-Login Einstellungen</button>
@@ -381,7 +387,7 @@ router.get('/', (req, res) => {
<div style="display:flex; justify-content:space-between; align-items:center; gap:12px;">
<div>
<p class="section-title">Status-Pipeline</p>
<p class="section-sub">Tickets nach Phase. Status per Dropdown <EFBFBD>ndern.</p>
<p class="section-sub">Tickets nach Phase. Status per Dropdown ndern.</p>
</div>
</div>
<div class="grid" style="grid-template-columns: repeat(auto-fit, minmax(240px,1fr)); gap:12px;" id="pipelineGrid">
@@ -430,7 +436,7 @@ router.get('/', (req, res) => {
<div style="display:flex; justify-content:space-between; gap:12px; flex-wrap:wrap;">
<div>
<p class="section-title">Automationen</p>
<p class="section-sub">Regeln f<EFBFBD>r Ticket-Aktionen.</p>
<p class="section-sub">Regeln f r Ticket-Aktionen.</p>
</div>
<button class="secondary-btn" id="addAutomation">Neue Regel</button>
</div>
@@ -467,7 +473,7 @@ router.get('/', (req, res) => {
<div style="display:flex; justify-content:space-between; gap:12px; flex-wrap:wrap;">
<div>
<p class="section-title">Knowledge-Base</p>
<p class="section-sub">Artikel f<EFBFBD>r Self-Service.</p>
<p class="section-sub">Artikel f r Self-Service.</p>
</div>
<button class="secondary-btn" id="addKb">Neuer Artikel</button>
</div>
@@ -586,7 +592,7 @@ router.get('/', (req, res) => {
</div>
<div class="form-field">
<label class="form-label">Embed Footer</label>
<input id="welcomeFooter" placeholder="Schön, dass du da bist!" />
<input id="welcomeFooter" placeholder="Sch n, dass du da bist!" />
</div>
</div>
<div class="form-field">
@@ -609,7 +615,7 @@ router.get('/', (req, res) => {
<div class="embed-color" id="welcomePreviewColor"></div>
<div class="embed-body">
<div class="embed-title" id="welcomePreviewTitle">Willkommen!</div>
<div class="embed-desc" id="welcomePreviewDesc">Schön, dass du da bist.</div>
<div class="embed-desc" id="welcomePreviewDesc">Sch n, dass du da bist.</div>
<div class="embed-footer" id="welcomePreviewFooter">Footer</div>
<img id="welcomePreviewImage" class="embed-image" style="display:none;" />
</div>
@@ -670,6 +676,79 @@ router.get('/', (req, res) => {
<div id="moduleList" class="module-list"></div>
</section>
</div>
<div class="section" data-section="register">
<section class="card">
<div class="row" style="justify-content:space-between; align-items:center; gap:12px; flex-wrap:wrap;">
<div>
<p class="section-title">Register Modul</p>
<p class="section-sub">Formulare verwalten und Antraege einsehen.</p>
</div>
<span class="badge" id="registerStatus">Aktiv</span>
</div>
<div class="register-tabs">
<button class="ticket-tab-btn register-tab-btn active" data-tab="forms">Formulare</button>
<button class="ticket-tab-btn register-tab-btn" data-tab="apps">Registrierungen</button>
</div>
</section>
<div class="register-tab ticket-tab active" data-tab="forms">
<section class="card">
<div class="row" style="justify-content:space-between; align-items:center; gap:12px; flex-wrap:wrap;">
<div>
<p class="section-title">Formulare</p>
<p class="section-sub">Formulare anlegen, bearbeiten und Panels senden.</p>
</div>
<div class="row" style="gap:8px; flex-wrap:wrap;">
<button class="secondary-btn" id="registerFormsReload" type="button">Reload</button>
<button class="secondary-btn" id="registerFormNew" type="button">Neues Formular</button>
</div>
</div>
<div id="registerFormList" class="module-list" style="margin-top:12px;"></div>
</section>
<section class="card">
<form id="registerFormForm" style="display:grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap:12px;">
<input type="hidden" id="registerFormId" />
<div class="form-field"><label class="form-label">Name</label><input id="registerFormName" placeholder="Bewerbung" /></div>
<div class="form-field"><label class="form-label">Review Channel ID</label><input id="registerFormChannel" placeholder="123456789012345678" /></div>
<div class="form-field"><label class="form-label">Notify Rollen (kommagetrennt)</label><input id="registerFormRoles" placeholder="123,456" /></div>
<div class="form-field"><label class="form-label">Aktiv</label><div id="registerFormActive" class="switch on"></div></div>
<div class="form-field" style="grid-column:1/-1;"><label class="form-label">Beschreibung</label><textarea id="registerFormDescription" rows="2" placeholder="Kurze Beschreibung"></textarea></div>
<div class="form-field" style="grid-column:1/-1;"><label class="form-label">Felder (Label | Typ | Pflicht)</label><textarea id="registerFormFields" rows="4" placeholder="Name | shortText | true&#10;Begruendung | longText | true"></textarea><p class="muted">Typ: shortText oder longText. Eine Zeile pro Feld.</p></div>
<div class="form-field" style="grid-column:1/-1; display:flex; gap:8px;">
<button type="submit">Speichern</button>
<button type="button" class="secondary-btn" id="registerFormReset">Reset</button>
</div>
</form>
<p class="muted" id="registerFormStatus"></p>
</section>
</div>
<div class="register-tab ticket-tab" data-tab="apps">
<section class="card">
<div class="row" style="justify-content:space-between; align-items:center; gap:12px; flex-wrap:wrap;">
<div>
<p class="section-title">Registrierungen</p>
<p class="section-sub">Antraege filtern und einsehen.</p>
</div>
<div class="row" style="gap:8px; flex-wrap:wrap; align-items:center;">
<select id="registerAppsFilter" class="muted" style="min-width:140px;">
<option value="">Alle Status</option>
<option value="pending">Pending</option>
<option value="accepted">Accepted</option>
<option value="invited">Invited</option>
<option value="rejected">Rejected</option>
</select>
<select id="registerAppsFormFilter" class="muted" style="min-width:160px;">
<option value="">Alle Formulare</option>
</select>
<button class="secondary-btn" id="registerAppsReload" type="button">Reload</button>
</div>
</div>
<div id="registerAppsList" class="ticket-list-pane"></div>
</section>
<section class="card">
<div id="registerAppDetail" class="muted">Waehle einen Antrag aus der Liste.</div>
</section>
</div>
</div>
<div class="section" data-section="birthday">
<section class="card">
<div class="row" style="justify-content:space-between; align-items:flex-start; gap:12px;">
@@ -736,7 +815,7 @@ router.get('/', (req, res) => {
</div>
<div class="form-field">
<label class="form-label">Eintraege (Emoji | Role ID | Label | Beschreibung)</label>
<textarea id="reactionRoleEntries" rows="4" placeholder="😀 | 123456789 | Freunde | Erhaelt Freunde Rolle"></textarea>
<textarea id="reactionRoleEntries" rows="4" placeholder=" | 123456789 | Freunde | Erhaelt Freunde Rolle"></textarea>
<p class="muted">Eine Zeile pro Zuordnung. Label/Beschreibung optional.</p>
</div>
<div style="display:flex; justify-content:flex-end; gap:10px;">
@@ -810,7 +889,7 @@ router.get('/', (req, res) => {
<section class="card">
<div class="row" style="justify-content:space-between;">
<div>
<p class="section-title">Aktivität (letzte 24h)</p>
<p class="section-title">Aktivit t (letzte 24h)</p>
<p class="section-sub">Events/Commands pro Stunde</p>
</div>
</div>
@@ -820,7 +899,7 @@ router.get('/', (req, res) => {
<div class="row" style="justify-content:space-between;">
<div>
<p class="section-title">Logs</p>
<p class="section-sub">Neueste Einträge</p>
<p class="section-sub">Neueste Eintr ge</p>
</div>
</div>
<ul class="log-list" id="adminLogs"></ul>
@@ -847,13 +926,13 @@ router.get('/', (req, res) => {
</div>
</div>
<div class="option-grid">
<div class="option-card"><span>👋 User Join / Leave</span><div id="logJoinLeave" class="switch on"></div></div>
<div class="option-card"><span>✏️ Message Edit</span><div id="logMsgEdit" class="switch on"></div></div>
<div class="option-card"><span>🗑️ Message Delete</span><div id="logMsgDelete" class="switch on"></div></div>
<div class="option-card"><span>🛡️ Automod Actions</span><div id="logAutomod" class="switch on"></div></div>
<div class="option-card"><span>🎫 Ticket Actions</span><div id="logTickets" class="switch on"></div></div>
<div class="option-card"><span>🎵 Musik-Events</span><div id="logMusic" class="switch on"></div></div>
<div class="option-card"><span>⚙️ System / Channels</span><div id="logSystem" class="switch on"></div></div>
<div class="option-card"><span> User Join / Leave</span><div id="logJoinLeave" class="switch on"></div></div>
<div class="option-card"><span> Message Edit</span><div id="logMsgEdit" class="switch on"></div></div>
<div class="option-card"><span> Message Delete</span><div id="logMsgDelete" class="switch on"></div></div>
<div class="option-card"><span> Automod Actions</span><div id="logAutomod" class="switch on"></div></div>
<div class="option-card"><span> Ticket Actions</span><div id="logTickets" class="switch on"></div></div>
<div class="option-card"><span> Musik-Events</span><div id="logMusic" class="switch on"></div></div>
<div class="option-card"><span> System / Channels</span><div id="logSystem" class="switch on"></div></div>
</div>
<div style="display:flex; justify-content:flex-end; gap:10px; margin-top:12px;">
<button id="loggingSave" type="button">Logging speichern</button>
@@ -1211,6 +1290,10 @@ router.get('/', (req, res) => {
let editingReactionRole = null;
let supportLoginCache = {};
let eventsCache = [];
let registerFormsCache = [];
let registerAppsCache = [];
let registerSelectedApp = null;
let registerConfigCache = {};
function activateSection(key) {
sections.forEach((s) => s.classList.toggle('active', s.dataset.section === key));
@@ -1243,6 +1326,7 @@ router.get('/', (req, res) => {
const birthdayEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'birthdayEnabled') ? modulesCache['birthdayEnabled'] : true;
const reactionRolesEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'reactionRolesEnabled') ? modulesCache['reactionRolesEnabled'] : true;
const eventsEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'eventsEnabled') ? modulesCache['eventsEnabled'] : true;
const registerEnabled = Object.prototype.hasOwnProperty.call(modulesCache, 'registerEnabled') ? modulesCache['registerEnabled'] : true;
if (automodNav) automodNav.classList.toggle('hidden', !autoEnabled);
if (welcomeNav) welcomeNav.classList.toggle('hidden', !welcomeEnabled);
if (dynamicVoiceNav) dynamicVoiceNav.classList.toggle('hidden', !dynamicVoiceEnabled);
@@ -1254,6 +1338,12 @@ router.get('/', (req, res) => {
if (reactionRolesNav) reactionRolesNav.classList.toggle('hidden', !reactionRolesEnabled);
const eventsNav = document.querySelector('.nav .events-link');
if (eventsNav) eventsNav.classList.toggle('hidden', !eventsEnabled);
if (registerNav) registerNav.classList.toggle('hidden', !registerEnabled);
if (registerSection) registerSection.classList.toggle('hidden', !registerEnabled);
if (registerStatus) {
registerStatus.textContent = registerEnabled ? 'Aktiv' : 'Deaktiviert';
registerStatus.className = 'badge' + (registerEnabled ? ' active' : '');
}
const adminNav = document.querySelector('.nav .admin-link');
if (adminNav) adminNav.classList.toggle('hidden', !isAdmin);
const current = location.hash.replace('#','') || 'overview';
@@ -1265,6 +1355,7 @@ router.get('/', (req, res) => {
(current === 'birthday' && !birthdayEnabled) ||
(current === 'reactionroles' && !reactionRolesEnabled) ||
(current === 'events' && !eventsEnabled) ||
(current === 'register' && !registerEnabled) ||
(current === 'admin' && !isAdmin)
) {
activateSection('overview');
@@ -1283,7 +1374,7 @@ router.get('/', (req, res) => {
function parseList(val) {
return (val || '')
.split(/[,\\n]/)
.split(/[,\n]/)
.map((s) => s.trim())
.filter(Boolean);
}
@@ -1419,7 +1510,7 @@ router.get('/', (req, res) => {
actions.className = 'row';
const del = document.createElement('button');
del.className = 'danger-btn';
del.textContent = 'Löschen';
del.textContent = 'L schen';
del.addEventListener('click', async () => {
await deleteService(svc.id);
});
@@ -1477,7 +1568,7 @@ router.get('/', (req, res) => {
if (res.ok) {
await loadStatuspage();
} else {
showToast('Service löschen fehlgeschlagen', true);
showToast('Service l schen fehlgeschlagen', true);
}
}
@@ -1755,7 +1846,7 @@ router.get('/', (req, res) => {
'</div>' +
'<div class=\"ticket-meta\">User: ' +
(t.userId || '-') +
(t.claimedBy ? ' <EFBFBD> Supporter: ' + t.claimedBy : '') +
(t.claimedBy ? ' Supporter: ' + t.claimedBy : '') +
'</div>';
const select = document.createElement('select');
select.innerHTML =
@@ -1869,14 +1960,14 @@ router.get('/', (req, res) => {
edit.addEventListener('click', () => fillAutomationForm(r));
const del = document.createElement('button');
del.className = 'danger-btn';
del.textContent = 'L<EFBFBD>schen';
del.textContent = 'L schen';
del.addEventListener('click', async () => {
const res = await fetch('/api/automations/' + r.id, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ guildId: currentGuild })
});
showToast(res.ok ? 'Regel gel<EFBFBD>scht' : 'L<EFBFBD>schen fehlgeschlagen', !res.ok);
showToast(res.ok ? 'Regel gel scht' : 'L schen fehlgeschlagen', !res.ok);
if (res.ok) loadAutomations();
});
actions.appendChild(edit);
@@ -1936,14 +2027,14 @@ router.get('/', (req, res) => {
edit.addEventListener('click', () => fillKbForm(a));
const del = document.createElement('button');
del.className = 'danger-btn';
del.textContent = 'L<EFBFBD>schen';
del.textContent = 'L schen';
del.addEventListener('click', async () => {
const res = await fetch('/api/kb/' + a.id, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ guildId: currentGuild })
});
showToast(res.ok ? 'Artikel gel<EFBFBD>scht' : 'L<EFBFBD>schen fehlgeschlagen', !res.ok);
showToast(res.ok ? 'Artikel gel scht' : 'L schen fehlgeschlagen', !res.ok);
if (res.ok) loadKb();
});
actions.appendChild(edit);
@@ -2116,7 +2207,7 @@ router.get('/', (req, res) => {
(s.userId || '-') +
'</strong></div><div class="muted">Ende: ' +
formatDate(s.endedAt || Date.now()) +
' · Dauer: ' +
' Dauer: ' +
dur +
'</div>';
supportRecentList.appendChild(div);
@@ -2174,9 +2265,9 @@ router.get('/', (req, res) => {
(ev.repeatType || 'none') +
'</span></div><div class="ticket-meta">Start: ' +
formatDate(ev.startTime) +
' · Channel: ' +
' Channel: ' +
(ev.channelId || '-') +
' · Anmeldungen: ' +
' Anmeldungen: ' +
(ev._count?.signups ?? 0) +
'</div>';
const actions = document.createElement('div');
@@ -2389,7 +2480,7 @@ router.get('/', (req, res) => {
meta.className = 'module-meta';
const descParts = ['Channel: ' + (set.channelId || '-')];
if (set.messageId) descParts.push('Message: ' + set.messageId);
meta.innerHTML = '<div class="module-title">' + (set.title || 'Reaction Role') + '</div><div class="module-desc">' + descParts.join(' ') + '</div>';
meta.innerHTML = '<div class="module-title">' + (set.title || 'Reaction Role') + '</div><div class="module-desc">' + descParts.join(' - ') + '</div>';
const actions = document.createElement('div');
actions.className = 'row';
const editBtn = document.createElement('button');
@@ -2445,6 +2536,262 @@ router.get('/', (req, res) => {
}
}
function parseRegisterFields(raw) {
return (raw || '')
.split('\\n')
.map((l, idx) => {
const parts = l.split('|').map((p) => p.trim());
const label = parts[0];
if (!label) return null;
const typeRaw = (parts[1] || 'shortText').toLowerCase();
const type = typeRaw.includes('long') ? 'longText' : 'shortText';
const requiredRaw = (parts[2] || '').toLowerCase();
const required = ['true', '1', 'yes', 'ja'].includes(requiredRaw);
return { label, type, required, order: idx };
})
.filter(Boolean);
}
function formatRegisterFields(fields) {
return (fields || [])
.slice()
.sort((a, b) => (a.sortOrder ?? a.order ?? 0) - (b.sortOrder ?? b.order ?? 0))
.map((f) => [f.label || 'Feld', f.type || 'shortText', f.required ? 'true' : 'false'].join(' | '))
.join('\\n');
}
function setRegisterFormDefaults() {
if (registerFormId) registerFormId.value = '';
if (registerFormName) registerFormName.value = '';
if (registerFormDescription) registerFormDescription.value = '';
if (registerFormChannel) registerFormChannel.value = registerConfigCache.reviewChannelId || '';
if (registerFormRoles) registerFormRoles.value = (registerConfigCache.notifyRoleIds || []).join(', ');
if (registerFormFields) registerFormFields.value = '';
setSwitch(registerFormActive, true);
if (registerFormStatus) registerFormStatus.textContent = '';
}
function fillRegisterForm(form) {
if (!form) return;
if (registerFormId) registerFormId.value = form.id || '';
if (registerFormName) registerFormName.value = form.name || '';
if (registerFormDescription) registerFormDescription.value = form.description || '';
if (registerFormChannel) registerFormChannel.value = form.reviewChannelId || registerConfigCache.reviewChannelId || '';
if (registerFormRoles) registerFormRoles.value = (form.notifyRoleIds || []).join(', ');
setSwitch(registerFormActive, form.isActive !== false);
if (registerFormFields) registerFormFields.value = formatRegisterFields(form.fields || []);
if (registerFormStatus) registerFormStatus.textContent = 'Bearbeitung aktiv';
}
function clearRegisterUi() {
if (registerFormList) registerFormList.innerHTML = '<div class="muted">Modul deaktiviert.</div>';
if (registerAppsList) registerAppsList.innerHTML = '<div class="muted">Modul deaktiviert.</div>';
if (registerAppDetail) registerAppDetail.innerHTML = '<div class="muted">Register deaktiviert.</div>';
setRegisterFormDefaults();
}
async function loadRegisterForms() {
if (!currentGuild) return;
if (modulesCache['registerEnabled'] === false) { clearRegisterUi(); return; }
const res = await fetch('/api/register/forms?guildId=' + encodeURIComponent(currentGuild));
if (!res.ok) return;
const data = await res.json();
registerFormsCache = data.forms || [];
renderRegisterForms();
}
function renderRegisterForms() {
if (!registerFormList) return;
registerFormList.innerHTML = '';
if (!registerFormsCache.length) {
registerFormList.innerHTML = '<div class="muted">Keine Formulare.</div>';
} else {
registerFormsCache.forEach((form) => {
const row = document.createElement('div');
row.className = 'module-item';
const meta = document.createElement('div');
meta.className = 'module-meta';
const descParts = [];
descParts.push('Felder: ' + ((form.fields || []).length));
if (form.reviewChannelId) descParts.push('Review: ' + form.reviewChannelId);
if (form.notifyRoleIds?.length) descParts.push('Notify: ' + form.notifyRoleIds.join(', '));
meta.innerHTML = '<div class="module-title">' + (form.name || 'Formular') + '</div><div class="module-desc">' + descParts.join(' - ') + '</div>';
const actions = document.createElement('div');
actions.className = 'row';
const activeBadge = document.createElement('span');
activeBadge.className = 'badge' + (form.isActive !== false ? ' active' : '');
activeBadge.textContent = form.isActive !== false ? 'Aktiv' : 'Inaktiv';
actions.appendChild(activeBadge);
const editBtn = document.createElement('button');
editBtn.className = 'secondary-btn';
editBtn.style.padding = '8px 10px';
editBtn.style.fontSize = '12px';
editBtn.textContent = 'Bearbeiten';
editBtn.addEventListener('click', () => fillRegisterForm(form));
const panelBtn = document.createElement('button');
panelBtn.className = 'secondary-btn';
panelBtn.style.padding = '8px 10px';
panelBtn.style.fontSize = '12px';
panelBtn.textContent = 'Panel senden';
panelBtn.addEventListener('click', () => sendRegisterPanel(form));
const delBtn = document.createElement('button');
delBtn.className = 'danger-btn';
delBtn.style.padding = '8px 10px';
delBtn.style.fontSize = '12px';
delBtn.textContent = 'Loeschen';
delBtn.addEventListener('click', () => deleteRegisterForm(form.id));
actions.appendChild(editBtn);
actions.appendChild(panelBtn);
actions.appendChild(delBtn);
row.appendChild(meta);
row.appendChild(actions);
registerFormList.appendChild(row);
});
}
populateRegisterFormFilter();
}
async function sendRegisterPanel(form) {
if (!currentGuild || !form?.id) return;
const channelId = prompt('Channel ID fuer Panel', form.reviewChannelId || registerConfigCache.reviewChannelId || '') || '';
if (!channelId.trim()) return;
const message = prompt('Nachricht im Panel (optional)', 'Klicke auf Registrieren, um das Formular zu oeffnen.');
const res = await fetch('/api/register/forms/' + form.id + '/panel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ guildId: currentGuild, channelId, message: message || undefined })
});
showToast(res.ok ? 'Panel gesendet' : 'Panel fehlgeschlagen', !res.ok);
}
async function deleteRegisterForm(id) {
if (!currentGuild || !id) return;
const res = await fetch('/api/register/forms/' + id, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ guildId: currentGuild }) });
showToast(res.ok ? 'Formular geloescht' : 'Loeschen fehlgeschlagen', !res.ok);
if (res.ok) await loadRegisterForms();
}
function parseRegisterRoles(raw) {
return (raw || '')
.split(/[,\n]/)
.map((s) => s.trim())
.filter(Boolean);
}
function populateRegisterFormFilter() {
if (!registerAppsFormFilter) return;
const current = registerAppsFormFilter.value;
registerAppsFormFilter.innerHTML = '<option value="">Alle Formulare</option>';
registerFormsCache.forEach((f) => {
const opt = document.createElement('option');
opt.value = f.id;
opt.textContent = f.name || 'Formular';
registerAppsFormFilter.appendChild(opt);
});
if (current) registerAppsFormFilter.value = current;
}
async function saveRegisterForm(e) {
if (e) e.preventDefault();
if (!currentGuild) return;
const fields = parseRegisterFields(registerFormFields?.value || '');
const payload = {
guildId: currentGuild,
name: registerFormName?.value || 'Formular',
description: registerFormDescription?.value || '',
reviewChannelId: registerFormChannel?.value || undefined,
notifyRoleIds: parseRegisterRoles(registerFormRoles?.value || ''),
isActive: getSwitch(registerFormActive),
fields
};
const id = registerFormId?.value;
const url = id ? '/api/register/forms/' + id : '/api/register/forms';
const method = id ? 'PUT' : 'POST';
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
if (registerFormStatus) registerFormStatus.textContent = res.ok ? 'Gespeichert' : 'Fehler';
showToast(res.ok ? 'Formular gespeichert' : 'Speichern fehlgeschlagen', !res.ok);
if (res.ok) {
setRegisterFormDefaults();
await loadRegisterForms();
}
}
async function loadRegisterApps() {
if (!currentGuild) return;
if (modulesCache['registerEnabled'] === false) { clearRegisterUi(); return; }
registerSelectedApp = null;
if (registerAppDetail) registerAppDetail.innerHTML = 'Waehle einen Antrag aus der Liste.';
const qs = new URLSearchParams({ guildId: currentGuild });
if (registerAppsFilter?.value) qs.set('status', registerAppsFilter.value);
if (registerAppsFormFilter?.value) qs.set('formId', registerAppsFormFilter.value);
const res = await fetch('/api/register/apps?' + qs.toString());
if (!res.ok) return;
const data = await res.json();
registerAppsCache = data.applications || [];
renderRegisterApps();
}
function registerStatusClass(status) {
const val = (status || '').toLowerCase();
if (val === 'accepted') return 'status-open';
if (val === 'invited') return 'status-in-progress';
if (val === 'rejected') return 'status-closed';
return 'status-open';
}
function renderRegisterApps() {
if (!registerAppsList) return;
registerAppsList.innerHTML = '';
if (!registerAppsCache.length) {
registerSelectedApp = null;
registerAppsList.innerHTML = '<div class="ticket-empty">Keine Antraege.</div>';
if (registerAppDetail) registerAppDetail.innerHTML = 'Waehle einen Antrag aus der Liste.';
return;
}
registerAppsCache.forEach((app) => {
const row = document.createElement('div');
row.className = 'ticket-list-item';
row.innerHTML = '<div class="ticket-item-top"><div class="ticket-title">' + (app.form?.name || 'Formular') + '</div><span class="ticket-status-badge ' + registerStatusClass(app.status) + '">' + (app.status || 'pending') + '</span></div>' +
'<div class="ticket-meta">User: ' + (app.userId || '-') + ' - Erstellt: ' + formatDate(app.createdAt) + '</div>';
row.addEventListener('click', () => loadRegisterApplication(app.id));
registerAppsList.appendChild(row);
});
}
async function loadRegisterApplication(id) {
if (!id) return;
const res = await fetch('/api/register/apps/' + id);
if (!res.ok) return;
const data = await res.json();
registerSelectedApp = data.application || null;
renderRegisterDetail();
}
function renderRegisterDetail() {
if (!registerAppDetail) return;
if (!registerSelectedApp) {
registerAppDetail.innerHTML = '<div class="muted">Waehle einen Antrag.</div>';
return;
}
const app = registerSelectedApp;
const formFields = registerFormsCache.find((f) => f.id === app.formId)?.fields || [];
const answers = app.answers || [];
const answersHtml = answers
.map((a) => {
const field = formFields.find((f) => f.id === a.fieldId);
const label = field?.label || 'Feld';
const value = (a.value || '').replace(/</g, '&lt;');
return '<div class="register-answer"><div class="form-label">' + label + '</div><div class="module-desc">' + value + '</div></div>';
})
.join('');
registerAppDetail.innerHTML =
'<div class="row" style="justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap;">' +
'<div><p class="section-title">' + (app.form?.name || 'Formular') + '</p><p class="section-sub">User: ' + (app.userId || '-') + '</p></div>' +
'<span class="ticket-status-badge ' + registerStatusClass(app.status) + '">' + (app.status || 'pending') + '</span>' +
'</div>' +
'<div class="module-list" style="margin-top:12px;">' + (answersHtml || '<div class="muted">Keine Antworten vorhanden.</div>') + '</div>';
}
// TODO: MODULE: Liste um Musik/Forum/Automod-Konfiguration ergaenzen.
// - Module-Status inkl. Direktlinks zu Detailseiten (Automod/Welcome/Musik) rendern.
// - Module-Flags aus BotModuleService spiegeln statt doppeltem Fetch.
@@ -2460,6 +2807,7 @@ router.get('/', (req, res) => {
let birthdayActive = false;
let reactionRolesActive = false;
let eventsActive = false;
let registerActive = false;
(data.modules || []).forEach((m) => {
modulesCache[m.key] = !!m.enabled;
const row = document.createElement('div');
@@ -2484,7 +2832,10 @@ router.get('/', (req, res) => {
if (m.key === 'birthdayEnabled') modulesCache['birthdayEnabled'] = willEnable;
if (m.key === 'reactionRolesEnabled') modulesCache['reactionRolesEnabled'] = willEnable;
if (m.key === 'eventsEnabled') modulesCache['eventsEnabled'] = willEnable;
if (m.key === 'registerEnabled') modulesCache['registerEnabled'] = willEnable;
applyNavVisibility();
if (m.key === 'registerEnabled' && willEnable) { await loadRegisterForms(); await loadRegisterApps(); }
if (m.key === 'registerEnabled' && !willEnable) { clearRegisterUi(); }
} else {
showToast('Speichern fehlgeschlagen', true);
}
@@ -2500,6 +2851,7 @@ router.get('/', (req, res) => {
if (m.key === 'birthdayEnabled') birthdayActive = !!m.enabled;
if (m.key === 'reactionRolesEnabled') reactionRolesActive = !!m.enabled;
if (m.key === 'eventsEnabled') eventsActive = !!m.enabled;
if (m.key === 'registerEnabled') registerActive = !!m.enabled;
});
applyNavVisibility();
applyTicketsVisibility(ticketsActive);
@@ -2507,12 +2859,13 @@ router.get('/', (req, res) => {
if (birthdayActive) loadBirthday();
if (reactionRolesActive) loadReactionRoles();
if (eventsActive) loadEvents();
if (registerActive) { loadRegisterForms(); loadRegisterApps(); }
}
async function saveModuleToggle(key, enabled) {
if (!currentGuild) return false;
const payload = { guildId: currentGuild };
['ticketsEnabled', 'automodEnabled', 'welcomeEnabled', 'musicEnabled', 'levelingEnabled', 'statuspageEnabled', 'birthdayEnabled', 'reactionRolesEnabled', 'eventsEnabled'].forEach((k) => {
['ticketsEnabled', 'automodEnabled', 'welcomeEnabled', 'musicEnabled', 'levelingEnabled', 'statuspageEnabled', 'birthdayEnabled', 'reactionRolesEnabled', 'eventsEnabled', 'registerEnabled'].forEach((k) => {
if (modulesCache[k] !== undefined) payload[k] = modulesCache[k];
});
payload['dynamicVoiceEnabled'] = modulesCache['dynamicVoiceEnabled'];
@@ -2623,6 +2976,26 @@ router.get('/', (req, res) => {
});
});
const registerTabs = Array.from(document.querySelectorAll('.register-tab'));
document.querySelectorAll('.register-tab-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
document.querySelectorAll('.register-tab-btn').forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
const tab = btn.dataset.tab || 'forms';
registerTabs.forEach((t) => t.classList.toggle('active', t.dataset.tab === tab));
if (tab === 'forms') await loadRegisterForms();
if (tab === 'apps') await loadRegisterApps();
});
});
if (registerFormForm) registerFormForm.addEventListener('submit', saveRegisterForm);
if (registerFormReset) registerFormReset.addEventListener('click', (e) => { e.preventDefault(); setRegisterFormDefaults(); });
if (registerFormNew) registerFormNew.addEventListener('click', () => setRegisterFormDefaults());
if (registerFormsReload) registerFormsReload.addEventListener('click', loadRegisterForms);
if (registerAppsReload) registerAppsReload.addEventListener('click', loadRegisterApps);
if (registerAppsFilter) registerAppsFilter.addEventListener('change', loadRegisterApps);
if (registerAppsFormFilter) registerAppsFormFilter.addEventListener('change', loadRegisterApps);
const slaRange = document.getElementById('slaRange');
if (slaRange) slaRange.addEventListener('change', loadSla);
@@ -2706,7 +3079,7 @@ router.get('/', (req, res) => {
document.getElementById('logoutBtn').addEventListener('click', () => window.location.href = BASE_AUTH + '/logout');
[automodToggle, badWordToggle, linkFilterToggle, spamFilterToggle, capsFilterToggle, logJoinLeave, logMsgEdit, logMsgDelete, logAutomod, logTickets, logMusic, dynamicVoiceToggle, supportLoginAuto].forEach((el) => {
[automodToggle, badWordToggle, linkFilterToggle, spamFilterToggle, capsFilterToggle, logJoinLeave, logMsgEdit, logMsgDelete, logAutomod, logTickets, logMusic, dynamicVoiceToggle, supportLoginAuto, registerFormActive].forEach((el) => {
if (el) el.addEventListener('click', () => el.classList.toggle('on'));
});
if (logSystem) logSystem.addEventListener('click', () => logSystem.classList.toggle('on'));
@@ -2883,3 +3256,24 @@ router.get('/settings', (_req, res) => {
export default router;

0
tmp_locate.log Normal file
View File