Implement ticket panel synchronization and configuration updates

- Added `panelChannelId` and `panelMessageId` fields to the `TicketConfig` model for managing ticket panel settings.
- Introduced `syncTicketPanel` function to handle posting and updating the ticket panel in Discord channels via BullMQ.
- Enhanced error handling for ticket panel synchronization, including specific error messages for missing categories and posting failures.
- Updated WebUI components to support panel channel selection and improved error messaging for synchronization issues.
- Added localization entries for new error messages related to ticket panel synchronization in both English and German.
- Refactored ticket category management to trigger panel synchronization upon category creation, update, or deletion.
This commit is contained in:
TheOnlyMace
2026-07-25 16:06:17 +02:00
parent dba7bb3cdc
commit 1eec10ba80
19 changed files with 476 additions and 47 deletions

View File

@@ -7,6 +7,17 @@ import type {
} from '@nexumi/shared';
import type { TicketCategory } from '@prisma/client';
import { prisma } from '../prisma';
import { addJobAndAwait, getTicketsQueue, getTicketsQueueEvents } from '../queues';
export class TicketPanelSyncError extends Error {
constructor(
message: string,
public readonly code: 'timeout' | 'not_posted' | 'no_categories' = 'not_posted'
) {
super(message);
this.name = 'TicketPanelSyncError';
}
}
async function ensureTicketConfig(guildId: string) {
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
@@ -19,6 +30,7 @@ function toConfigDashboard(config: Awaited<ReturnType<typeof ensureTicketConfig>
mode: config.mode as TicketConfigDashboard['mode'],
categoryId: config.categoryId ?? '',
logChannelId: config.logChannelId ?? '',
panelChannelId: config.panelChannelId ?? '',
transcriptDm: config.transcriptDm,
inactivityHours: config.inactivityHours,
ratingEnabled: config.ratingEnabled
@@ -30,22 +42,86 @@ export async function getTicketConfigDashboard(guildId: string): Promise<TicketC
return toConfigDashboard(config);
}
/**
* Dashboard has no discord.js Client, so panel posting is delegated to the bot
* via the `tickets` BullMQ queue (`ticketPanelSync`).
*/
async function syncTicketPanel(guildId: string): Promise<void> {
const categoryCount = await prisma.ticketCategory.count({ where: { guildId } });
if (categoryCount === 0) {
throw new TicketPanelSyncError(
'Create at least one ticket category before posting the panel',
'no_categories'
);
}
const { confirmed, result } = await addJobAndAwait<{
panelChannelId: string;
panelMessageId: string;
}>(
getTicketsQueue(),
getTicketsQueueEvents(),
'ticketPanelSync',
{ guildId },
{
jobId: `ticket-panel-${guildId}-${Date.now()}`,
removeOnComplete: true,
removeOnFail: 25
},
30_000
);
if (!confirmed || !result?.panelMessageId) {
throw new TicketPanelSyncError(
confirmed
? 'Ticket panel job finished without posting a Discord message'
: 'The bot did not confirm the ticket panel in time. It may still be posting — check the channel shortly.',
confirmed ? 'not_posted' : 'timeout'
);
}
const config = await prisma.ticketConfig.findUnique({ where: { guildId } });
if (!config?.panelMessageId) {
throw new TicketPanelSyncError(
'Ticket panel job finished without storing a Discord message id',
'not_posted'
);
}
}
async function maybeSyncTicketPanel(guildId: string): Promise<void> {
const config = await prisma.ticketConfig.findUnique({ where: { guildId } });
if (!config?.enabled || !config.panelChannelId) {
return;
}
await syncTicketPanel(guildId);
}
export async function updateTicketConfigDashboard(
guildId: string,
patch: TicketConfigDashboardPatch
): Promise<TicketConfigDashboard> {
await ensureTicketConfig(guildId);
const { categoryId, logChannelId, ...rest } = patch;
const { categoryId, logChannelId, panelChannelId, ...rest } = patch;
const updated = await prisma.ticketConfig.update({
where: { guildId },
data: {
...rest,
...(categoryId !== undefined ? { categoryId: categoryId === '' ? null : categoryId } : {}),
...(logChannelId !== undefined ? { logChannelId: logChannelId === '' ? null : logChannelId } : {})
...(logChannelId !== undefined ? { logChannelId: logChannelId === '' ? null : logChannelId } : {}),
...(panelChannelId !== undefined
? { panelChannelId: panelChannelId === '' ? null : panelChannelId }
: {})
}
});
return toConfigDashboard(updated);
const dashboard = toConfigDashboard(updated);
if (dashboard.enabled && dashboard.panelChannelId) {
await syncTicketPanel(guildId);
}
return dashboard;
}
function toCategoryDashboard(category: TicketCategory): TicketCategoryDashboard {
@@ -87,6 +163,7 @@ export async function createTicketCategory(
supportRoleIds: input.supportRoleIds
}
});
await maybeSyncTicketPanel(guildId);
return toCategoryDashboard(category);
} catch (error) {
if (error && typeof error === 'object' && 'code' in error && error.code === 'P2002') {
@@ -116,6 +193,7 @@ export async function updateTicketCategory(
...(patch.supportRoleIds !== undefined ? { supportRoleIds: patch.supportRoleIds } : {})
}
});
await maybeSyncTicketPanel(guildId);
return toCategoryDashboard(updated);
} catch (error) {
if (error && typeof error === 'object' && 'code' in error && error.code === 'P2002') {
@@ -127,5 +205,12 @@ export async function updateTicketCategory(
export async function deleteTicketCategory(guildId: string, categoryId: string): Promise<boolean> {
const result = await prisma.ticketCategory.deleteMany({ where: { id: categoryId, guildId } });
return result.count > 0;
if (result.count === 0) {
return false;
}
const remaining = await prisma.ticketCategory.count({ where: { guildId } });
if (remaining > 0) {
await maybeSyncTicketPanel(guildId);
}
return true;
}