[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

@@ -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' });