[deploy] add ticket sla, pipeline, automations, kb
All checks were successful
Deploy Discord Bot / deploy (push) Successful in 36s

This commit is contained in:
Pascal Prießnitz
2025-12-03 13:24:25 +01:00
parent ecb348efd0
commit 5bf42f4610
12 changed files with 561 additions and 123 deletions

View File

@@ -12,14 +12,15 @@ const command: SlashCommand = {
.setName('status')
.setDescription('Neuer Status')
.addChoices(
{ name: 'Offen', value: 'open' },
{ name: 'In Bearbeitung', value: 'in-progress' },
{ name: 'Geschlossen', value: 'closed' }
{ name: 'Neu', value: 'neu' },
{ name: 'In Bearbeitung', value: 'in_bearbeitung' },
{ name: 'Warten auf User', value: 'warten_auf_user' },
{ name: 'Erledigt', value: 'erledigt' }
)
.setRequired(true)
),
async execute(interaction: ChatInputCommandInteraction) {
const status = interaction.options.getString('status', true) as 'open' | 'in-progress' | 'closed';
const status = interaction.options.getString('status', true) as any;
const ticket = await prisma.ticket.findFirst({ where: { channelId: interaction.channelId } });
if (!ticket) {
await interaction.reply({ content: 'Kein Ticket in diesem Kanal.', ephemeral: true });

View File

@@ -12,6 +12,8 @@ import { StatuspageService } from '../services/statuspageService';
import { BirthdayService } from '../services/birthdayService';
import { ReactionRoleService } from '../services/reactionRoleService';
import { EventService } from '../services/eventService';
import { TicketAutomationService } from '../services/ticketAutomationService';
import { KnowledgeBaseService } from '../services/knowledgeBaseService';
export const context = {
client: null as Client | null,
@@ -27,7 +29,9 @@ export const context = {
statuspage: new StatuspageService(),
birthdays: new BirthdayService(),
reactionRoles: new ReactionRoleService(),
events: new EventService()
events: new EventService(),
ticketAutomation: new TicketAutomationService(),
knowledgeBase: new KnowledgeBaseService()
};
context.modules.setHooks({

View File

@@ -39,13 +39,41 @@ model Ticket {
guildId String
topic String?
priority String @default("normal")
status String
status String @default("neu")
claimedBy String?
transcript String?
firstClaimAt DateTime?
firstResponseAt DateTime?
kbSuggestionSentAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model TicketAutomationRule {
id String @id @default(cuid())
guildId String
name String
condition Json
action Json
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([guildId, active])
}
model KnowledgeBaseArticle {
id String @id @default(cuid())
guildId String
title String
keywords String[]
content String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([guildId])
}
model Level {
id String @id @default(cuid())
userId String

View File

@@ -10,6 +10,9 @@ const event: EventHandler = {
if (message.guildId) context.admin.trackEvent('message', message.guildId);
if (cfg?.automodEnabled === true) context.automod.checkMessage(message, cfg.automodConfig);
if (cfg?.levelingEnabled === true) context.leveling.handleMessage(message);
// Ticket SLA + KB
await context.tickets.trackFirstResponse(message);
await context.tickets.suggestKnowledgeBase(message);
}
};

View File

@@ -29,6 +29,7 @@ async function bootstrap() {
context.admin.setClient(client);
context.statuspage.setClient(client);
context.tickets.setClient(client);
context.ticketAutomation.startLoop();
context.birthdays.setClient(client);
context.reactionRoles.setClient(client);
context.events.setClient(client);

View File

@@ -0,0 +1,34 @@
import { prisma } from '../database';
export class KnowledgeBaseService {
public async list(guildId: string) {
return prisma.knowledgeBaseArticle.findMany({ where: { guildId }, orderBy: { createdAt: 'asc' } });
}
public async save(article: { id?: string; guildId: string; title: string; keywords: string[]; content: string }) {
const data = {
guildId: article.guildId,
title: article.title,
keywords: article.keywords,
content: article.content
};
if (article.id) {
return prisma.knowledgeBaseArticle.update({ where: { id: article.id }, data });
}
return prisma.knowledgeBaseArticle.create({ data });
}
public async remove(guildId: string, id: string) {
const found = await prisma.knowledgeBaseArticle.findFirst({ where: { id, guildId } });
if (!found) return false;
await prisma.knowledgeBaseArticle.delete({ where: { id } });
return true;
}
public async match(guildId: string, text: string) {
if (!text) return [];
const lowered = text.toLowerCase();
const articles = await prisma.knowledgeBaseArticle.findMany({ where: { guildId }, take: 50 });
return articles.filter((a) => (a.keywords || []).some((k) => lowered.includes(k.toLowerCase()))).slice(0, 3);
}
}

View File

@@ -0,0 +1,103 @@
import { prisma } from '../database';
import { context } from '../config/context';
import { TextChannel } from 'discord.js';
type AutomationCondition = {
category?: string;
status?: string;
minHours?: number;
};
type AutomationAction = {
type: 'pingRole' | 'reminder' | 'flag';
roleId?: string;
message?: string;
status?: string;
};
export class TicketAutomationService {
public async list(guildId: string) {
return prisma.ticketAutomationRule.findMany({ where: { guildId }, orderBy: { createdAt: 'asc' } });
}
public async save(rule: {
id?: string;
guildId: string;
name: string;
condition: AutomationCondition;
action: AutomationAction;
active?: boolean;
}) {
if (rule.id) {
return prisma.ticketAutomationRule.update({
where: { id: rule.id },
data: {
name: rule.name,
condition: rule.condition,
action: rule.action,
active: rule.active ?? true
}
});
}
return prisma.ticketAutomationRule.create({
data: {
guildId: rule.guildId,
name: rule.name,
condition: rule.condition,
action: rule.action,
active: rule.active ?? true
}
});
}
public async remove(guildId: string, id: string) {
const found = await prisma.ticketAutomationRule.findFirst({ where: { id, guildId } });
if (!found) return false;
await prisma.ticketAutomationRule.delete({ where: { id } });
return true;
}
public async checkTicket(ticket: any, isScheduled = false) {
const rules = await prisma.ticketAutomationRule.findMany({ where: { guildId: ticket.guildId, active: true }, take: 50 });
if (!rules.length) return;
const guild = context.client?.guilds.cache.get(ticket.guildId) ?? (await context.client?.guilds.fetch(ticket.guildId).catch(() => null));
if (!guild) return;
const channel = ticket.channelId ? await guild.channels.fetch(ticket.channelId).catch(() => null) : null;
for (const rule of rules) {
const cond = (rule.condition as any) || {};
const act = (rule.action as any) || {};
const matchesCategory =
!cond.category || (ticket.topic || '').toLowerCase().includes(String(cond.category).toLowerCase());
const matchesStatus = !cond.status || ticket.status === cond.status;
const matchesAge =
!cond.minHours ||
(ticket.createdAt &&
Date.now() - new Date(ticket.createdAt).getTime() >= Number(cond.minHours) * 3600 * 1000);
if (!matchesCategory || !matchesStatus || !matchesAge) continue;
if (act.type === 'pingRole' && channel?.isTextBased() && act.roleId) {
await (channel as TextChannel).send({ content: `<@&${act.roleId}> Bitte Ticket pruefen.` }).catch(() => undefined);
}
if (act.type === 'reminder' && channel?.isTextBased()) {
await (channel as TextChannel)
.send({ content: act.message || 'Reminder: Ticket ist noch offen.' })
.catch(() => undefined);
}
if (act.type === 'flag' && act.status && ticket.status !== act.status) {
await prisma.ticket.update({ where: { id: ticket.id }, data: { status: act.status } }).catch(() => undefined);
}
}
}
public startLoop() {
setInterval(() => {
const since = new Date(Date.now() - 24 * 60 * 60 * 1000);
prisma.ticket
.findMany({
where: { status: { notIn: ['erledigt', 'closed'] }, createdAt: { lte: since } },
take: 50
})
.then((tickets) => tickets.forEach((t) => this.checkTicket(t, true)))
.catch(() => undefined);
}, 60_000);
}
}

View File

@@ -11,15 +11,19 @@ import {
GuildMember,
PermissionsBitField,
TextChannel,
Client
Client,
Message
} from 'discord.js';
import { prisma } from '../database';
import fs from 'fs';
import path from 'path';
import { TicketRecord } from '../utils/types';
import { prisma } from '../database';
import { TicketRecord, TicketStatus } from '../utils/types';
import { logger } from '../utils/logger';
import { settingsStore } from '../config/state';
import { env } from '../config/env';
import { context } from '../config/context';
const PIPELINE_STATUS: TicketStatus[] = ['neu', 'in_bearbeitung', 'warten_auf_user', 'erledigt'];
export class TicketService {
private categoryName = 'Tickets';
@@ -40,15 +44,22 @@ export class TicketService {
return `#${String(n).padStart(4, '0')}`;
}
private normalizeStatus(status: string): TicketStatus {
if (PIPELINE_STATUS.includes(status as TicketStatus)) return status as TicketStatus;
if (status === 'open') return 'neu';
if (status === 'in-progress') return 'in_bearbeitung';
if (status === 'closed') return 'erledigt';
return 'neu';
}
public async createTicket(interaction: ChatInputCommandInteraction): Promise<TicketRecord | null> {
if (!interaction.guildId || !interaction.guild) return null;
// TODO: TICKETS: Modul-Check ueber BotModuleService kapseln (Dashboard-Toggle statt direktem Flag).
if (!this.isEnabled(interaction.guildId)) {
await interaction.reply({ content: 'Tickets sind für diese Guild deaktiviert.', ephemeral: true });
await interaction.reply({ content: 'Tickets sind für diese Guild deaktiviert.', ephemeral: true });
return null;
}
const existing = await prisma.ticket.findFirst({
where: { userId: interaction.user.id, guildId: interaction.guild.id, status: { not: 'closed' } }
where: { userId: interaction.user.id, guildId: interaction.guild.id, status: { notIn: ['closed', 'erledigt'] } }
});
if (existing) {
await interaction.reply({ content: 'Du hast bereits ein offenes Ticket.', ephemeral: true });
@@ -58,7 +69,6 @@ export class TicketService {
}
public async handleButton(interaction: ButtonInteraction) {
// TODO: TICKETS: Button-Handling modularisieren und Kategorien aus Dashboard-Konfig laden.
if (!interaction.guild) return;
if (interaction.customId === 'support:toggle') {
await this.toggleSupport(interaction);
@@ -66,28 +76,26 @@ export class TicketService {
}
if (interaction.customId.startsWith('ticket:create:')) {
if (!this.isEnabled(interaction.guild.id)) {
await interaction.reply({ content: 'Tickets sind für diese Guild deaktiviert.', ephemeral: true });
await interaction.reply({ content: 'Tickets sind für diese Guild deaktiviert.', ephemeral: true });
return;
}
const topic = interaction.customId.split(':')[2] || 'allgemein';
const existing = await prisma.ticket.findFirst({
where: { userId: interaction.user.id, guildId: interaction.guild.id, status: { not: 'closed' } }
where: { userId: interaction.user.id, guildId: interaction.guild.id, status: { notIn: ['closed', 'erledigt'] } }
});
if (existing) {
await interaction.reply({ content: 'Du hast bereits ein offenes Ticket. Bitte schließe es zuerst.', ephemeral: true });
await interaction.reply({ content: 'Du hast bereits ein offenes Ticket. Bitte schließe es zuerst.', ephemeral: true });
return;
}
const record = await this.openTicket(interaction.guild, interaction.member as GuildMember, topic);
if (record) {
await interaction.reply({ content: 'Ticket erstellt! Schau im neuen Kanal nach.', ephemeral: true });
} else {
await interaction.reply({ content: 'Ticket konnte nicht erstellt werden.', ephemeral: true });
}
await interaction.reply({
content: record ? 'Ticket erstellt! Schau im neuen Kanal nach.' : 'Ticket konnte nicht erstellt werden.',
ephemeral: true
});
return;
}
// TODO: TICKETS: Claim-Flow per Rollen/SLAs konfigurierbar machen und ins Dashboard syncen.
if (interaction.customId === 'ticket:claim') {
const ticket = await this.getTicketByChannel(interaction);
if (!ticket) {
@@ -98,19 +106,21 @@ export class TicketService {
await interaction.reply({ content: 'Du kannst dein eigenes Ticket nicht claimen.', ephemeral: true });
return;
}
await prisma.ticket.update({ where: { id: ticket.id }, data: { claimedBy: interaction.user.id, status: 'in-progress' } });
await interaction.reply({ content: `${interaction.user} hat das Ticket übernommen.` });
await prisma.ticket.update({
where: { id: ticket.id },
data: { claimedBy: interaction.user.id, status: 'in_bearbeitung', firstClaimAt: ticket.firstClaimAt ?? new Date() }
});
await interaction.reply({ content: `${interaction.user} hat das Ticket übernommen.` });
await this.runAutomations({ ...ticket, status: 'in_bearbeitung', guildId: interaction.guild.id });
return;
}
// TODO: TICKETS: Close-Flow modularisieren (Feedback, Transcript-Optionen) und Dashboard-Status sofort aktualisieren.
if (interaction.customId === 'ticket:close') {
const ok = await this.closeTicketButton(interaction);
if (!ok) await interaction.reply({ content: 'Ticket konnte nicht geschlossen werden.', ephemeral: true });
return;
}
// TODO: TICKETS: Transcript-Export in Storage/DB ablegen und Download-Link ins Dashboard liefern.
if (interaction.customId === 'ticket:transcript') {
const ticket = await this.getTicketByChannel(interaction);
if (!ticket) {
@@ -130,18 +140,21 @@ export class TicketService {
await interaction.reply({ content: 'Du kannst dein eigenes Ticket nicht claimen.', ephemeral: true });
return true;
}
await prisma.ticket.update({ where: { id: ticket.id }, data: { claimedBy: interaction.user.id, status: 'in-progress' } });
await channel.send({ content: `${interaction.user} hat das Ticket übernommen.` });
await prisma.ticket.update({
where: { id: ticket.id },
data: { claimedBy: interaction.user.id, status: 'in_bearbeitung', firstClaimAt: ticket.firstClaimAt ?? new Date() }
});
await channel.send({ content: `${interaction.user} hat das Ticket übernommen.` });
await this.runAutomations({ ...ticket, status: 'in_bearbeitung', guildId: channel.guildId });
return true;
}
public async closeTicket(interaction: ChatInputCommandInteraction, reason?: string) {
// TODO: TICKETS: Dashboard-Action zum Schliessen mit optionalen Vorlagen/Gründen anbinden und Bot/DB synchronisieren.
const channel = interaction.channel as TextChannel;
const ticket = await prisma.ticket.findFirst({ where: { channelId: channel.id } });
if (!ticket) return false;
const transcriptPath = await this.exportTranscript(channel, ticket.id);
await prisma.ticket.update({ where: { id: ticket.id }, data: { status: 'closed', transcript: transcriptPath } });
await prisma.ticket.update({ where: { id: ticket.id }, data: { status: 'erledigt', transcript: transcriptPath } });
await channel.send({ content: `Ticket geschlossen. Grund: ${reason ?? 'Kein Grund angegeben'}` }).catch(() => undefined);
await this.sendTranscriptToLog(channel.guild, transcriptPath, ticket);
await channel.delete('Ticket geschlossen');
@@ -161,10 +174,10 @@ export class TicketService {
);
const buttons = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().setCustomId('ticket:create:ban').setLabel('Ban Einspruch').setEmoji('🛡').setStyle(ButtonStyle.Primary),
new ButtonBuilder().setCustomId('ticket:create:help').setLabel('Discord-Hilfe').setEmoji('🛠').setStyle(ButtonStyle.Secondary),
new ButtonBuilder().setCustomId('ticket:create:feedback').setLabel('Feedback').setEmoji('💬').setStyle(ButtonStyle.Secondary),
new ButtonBuilder().setCustomId('ticket:create:other').setLabel('Allgemeine Frage').setEmoji('').setStyle(ButtonStyle.Secondary)
new ButtonBuilder().setCustomId('ticket:create:ban').setLabel('Ban Einspruch').setEmoji('🚫').setStyle(ButtonStyle.Primary),
new ButtonBuilder().setCustomId('ticket:create:help').setLabel('Discord-Hilfe').setEmoji('💬').setStyle(ButtonStyle.Secondary),
new ButtonBuilder().setCustomId('ticket:create:feedback').setLabel('Feedback').setEmoji('📝').setStyle(ButtonStyle.Secondary),
new ButtonBuilder().setCustomId('ticket:create:other').setLabel('Allgemeine Frage').setEmoji('').setStyle(ButtonStyle.Secondary)
);
return { embed, buttons };
@@ -179,17 +192,13 @@ export class TicketService {
.setTitle(config.title || 'Ticket Support')
.setDescription(
config.description ||
'Bitte wähle eine Kategorie, um ein Ticket zu erstellen. Sei respektvoll & sachlich. Missbrauch führt zu Konsequenzen.'
'Bitte wähle eine Kategorie, um ein Ticket zu erstellen. Sei respektvoll & sachlich. Missbrauch führt zu Konsequenzen.'
)
.setColor(0xf97316);
const buttons = new ActionRowBuilder<ButtonBuilder>();
// TODO: TICKETS: Panel-Definitionen versionieren und im Dashboard editierbar machen (Labels/Emojis/Permissions).
config.categories.slice(0, 5).forEach((cat) => {
const btn = new ButtonBuilder()
.setCustomId(`ticket:create:${cat.customId}`)
.setLabel(cat.label)
.setStyle(ButtonStyle.Secondary);
const btn = new ButtonBuilder().setCustomId(`ticket:create:${cat.customId}`).setLabel(cat.label).setStyle(ButtonStyle.Secondary);
if (cat.emoji) btn.setEmoji(cat.emoji);
buttons.addComponents(btn);
});
@@ -197,8 +206,6 @@ export class TicketService {
}
public async exportTranscript(channel: TextChannel, ticketId: string) {
// TODO: TICKETS: Transcript-Speicherung abstrahieren (z.B. S3/DB) und Status ans Dashboard senden.
// Discord API limit: max 100 per fetch
const messages = await channel.messages.fetch({ limit: 100 });
const lines = messages
.sort((a, b) => a.createdTimestamp - b.createdTimestamp)
@@ -254,11 +261,10 @@ export class TicketService {
const channel = await guild.channels.create({
name: channelName,
type: ChannelType.GuildText,
parent: category.id,
parent: category as any,
permissionOverwrites: overwrites
});
// TODO: TICKETS: Status/Metadaten beim Erstellen direkt ans Dashboard pushen (WebSocket) und SLA speichern.
const record = await prisma.ticket.create({
data: {
userId: member.id,
@@ -266,14 +272,15 @@ export class TicketService {
guildId: guild.id,
topic,
priority: 'normal',
status: 'open',
ticketNumber: nextNumber
status: 'neu',
ticketNumber: nextNumber,
createdAt: new Date()
}
});
const embed = new EmbedBuilder()
.setTitle(`Ticket: ${topic}`)
.setDescription('Ein Teammitglied wird sich gleich kuemmern. Nutze `/claim`, um den Fall zu uebernehmen.')
.setDescription('Ein Teammitglied wird sich gleich kümmern. Nutze `/claim`, um den Fall zu übernehmen.')
.setColor(0x7289da);
const controls = new ActionRowBuilder<ButtonBuilder>().addComponents(
@@ -290,7 +297,7 @@ export class TicketService {
allowedMentions: supportMention ? { roles: [supportRoleId as string], users: [member.id] } : { users: [member.id] }
});
await this.sendTicketCreatedLog(guild, channel, member, topic, nextNumber, supportRoleId);
await this.runAutomations({ ...record });
return record as TicketRecord;
}
@@ -301,20 +308,18 @@ export class TicketService {
}
private async closeTicketButton(interaction: ButtonInteraction) {
// TODO: TICKETS: Gemeinsamen Close-Pfad fuer Bot/Dashboard extrahieren, um Status-Divergenzen zu vermeiden.
const channel = interaction.channel as TextChannel | null;
if (!channel) return false;
const ticket = await prisma.ticket.findFirst({ where: { channelId: channel.id } });
if (!ticket) return false;
const transcriptPath = await this.exportTranscript(channel, ticket.id);
await prisma.ticket.update({ where: { id: ticket.id }, data: { status: 'closed', transcript: transcriptPath } });
await prisma.ticket.update({ where: { id: ticket.id }, data: { status: 'erledigt', transcript: transcriptPath } });
await interaction.reply({ content: 'Ticket geschlossen.', ephemeral: true }).catch(() => undefined);
await this.sendTranscriptToLog(channel.guild, transcriptPath, ticket);
await channel.delete('Ticket geschlossen');
return true;
}
// TODO: TICKETS: Log-Ziel und Storage ueber Dashboard konfigurierbar machen (z.B. Channel, Forum, Webhook).
private async sendTranscriptToLog(guild: Guild, transcriptPath: string, ticket: { id: string; channelId: string }) {
const { logChannelId, categories } = this.getLoggingConfig(guild.id);
if (!logChannelId || categories.ticketActions === false) return;
@@ -322,7 +327,7 @@ export class TicketService {
if (!logChannel || !logChannel.isTextBased()) return;
try {
await (logChannel as any).send({
content: `Transcript für Ticket ${ticket.id} (${ticket.channelId})`,
content: `Transcript für Ticket ${ticket.id} (${ticket.channelId})`,
files: [transcriptPath]
});
} catch (err) {
@@ -478,8 +483,53 @@ export class TicketService {
await (logChannel as any).send({ content: `[Support] ${message}` }).catch(() => undefined);
}
public async trackFirstResponse(message: Message) {
if (!message.guildId || !message.channelId || message.author?.bot) return;
const ticket = await prisma.ticket.findFirst({ where: { channelId: message.channelId } });
if (!ticket || ticket.firstResponseAt) return;
const cfg = settingsStore.get(message.guildId);
const supportRoleId = cfg?.supportRoleId || env.supportRoleId;
const member = message.member as GuildMember | null;
const isSupport =
(supportRoleId && member?.roles.cache.has(supportRoleId)) ||
member?.permissions.has(PermissionsBitField.Flags.ManageMessages) ||
ticket.userId !== message.author.id;
if (!isSupport) return;
await prisma.ticket.update({ where: { id: ticket.id }, data: { firstResponseAt: new Date() } });
}
public async suggestKnowledgeBase(message: Message) {
if (!message.guildId || !message.channelId || message.author?.bot) return;
const ticket = await prisma.ticket.findFirst({ where: { channelId: message.channelId } });
if (!ticket || ticket.kbSuggestionSentAt) return;
if (ticket.userId !== message.author.id) return;
const matches = await context.knowledgeBase.match(message.guildId, message.content || '');
if (!matches.length) return;
const channel = message.channel as TextChannel | null;
if (!channel || !channel.isTextBased()) return;
const lines = matches.map((m: any) => `• **${m.title}** ${m.content}`).join('\n');
await channel.send({ content: `Ich habe vielleicht passende Lösungen gefunden:\n${lines}` }).catch(() => undefined);
await prisma.ticket.update({ where: { id: ticket.id }, data: { kbSuggestionSentAt: new Date() } });
}
public async updateStatus(ticketId: string, status: TicketStatus) {
const normalized = this.normalizeStatus(status);
const ticket = await prisma.ticket.findFirst({ where: { id: ticketId } });
if (!ticket) return null;
const updated = await prisma.ticket.update({ where: { id: ticketId }, data: { status: normalized } });
await this.runAutomations(updated);
return updated;
}
private async runAutomations(ticket: any) {
try {
await context.ticketAutomation.checkTicket(ticket);
} catch (err) {
logger.warn('Automation check failed', err);
}
}
private isEnabled(guildId: string) {
// TODO: MODULE: Modul-Status ueber BotModuleService/SettingsStore vereinheitlichen.
const cfg = settingsStore.get(guildId);
return cfg?.ticketsEnabled === true;
}

View File

@@ -21,11 +21,18 @@ export interface TicketRecord {
guildId: string;
topic?: string;
priority?: 'low' | 'normal' | 'high';
status: 'open' | 'in-progress' | 'closed';
status: TicketStatus;
claimedBy?: string;
transcript?: string;
firstClaimAt?: Date | null;
firstResponseAt?: Date | null;
kbSuggestionSentAt?: Date | null;
createdAt?: Date;
updatedAt?: Date;
}
export type TicketStatus = 'neu' | 'in_bearbeitung' | 'warten_auf_user' | 'erledigt' | 'open' | 'in-progress' | 'closed';
export interface ForumUser {
discordId: string;
forumUserId: string;

View File

@@ -151,6 +151,89 @@ router.get('/tickets', requireAuth, async (req, res) => {
}
});
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<string, any[]>;
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 {
@@ -460,6 +543,106 @@ router.delete('/reactionroles/:id', requireAuth, async (req, res) => {
}
});
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' });