Files
Nexumi/apps/webui/src/lib/module-configs/tickets.ts
TheOnlyMace b5bf214d7f Fix ticket category saving and synchronization error handling
- Updated the ticket category saving logic to handle empty description and emoji fields, ensuring they are set to null if trimmed.
- Enhanced error handling in the `maybeSyncTicketPanel` function to prevent UI errors when synchronization fails, allowing category CRUD operations to succeed.
- Updated the response handling to include support role IDs in the UI after saving, improving data consistency.
- Added validation for support role IDs in the dashboard schemas to ensure correct data types are used.
2026-07-25 16:24:18 +02:00

336 lines
10 KiB
TypeScript

import type {
TicketCategoryDashboard,
TicketCategoryDashboardCreate,
TicketCategoryDashboardUpdate,
TicketConfigDashboard,
TicketConfigDashboardPatch,
TicketDashboard,
TicketDashboardAction,
TicketPriority,
TicketStatus
} from '@nexumi/shared';
import type { Ticket, 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';
}
}
export class TicketActionSyncError extends Error {
constructor(message: string) {
super(message);
this.name = 'TicketActionSyncError';
}
}
async function ensureTicketConfig(guildId: string) {
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
return prisma.ticketConfig.upsert({ where: { guildId }, update: {}, create: { guildId } });
}
function toConfigDashboard(config: Awaited<ReturnType<typeof ensureTicketConfig>>): TicketConfigDashboard {
return {
enabled: config.enabled,
mode: config.mode as TicketConfigDashboard['mode'],
categoryId: config.categoryId ?? '',
logChannelId: config.logChannelId ?? '',
panelChannelId: config.panelChannelId ?? '',
transcriptDm: config.transcriptDm,
inactivityHours: config.inactivityHours,
ratingEnabled: config.ratingEnabled
};
}
export async function getTicketConfigDashboard(guildId: string): Promise<TicketConfigDashboard> {
const config = await ensureTicketConfig(guildId);
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;
}
// Category CRUD must succeed even if Discord panel sync fails — otherwise
// support roles / category fields appear "unsaved" in the UI while already
// persisted in Postgres.
try {
await syncTicketPanel(guildId);
} catch (error) {
console.warn('[tickets] panel sync after category change failed', { guildId, error });
}
}
export async function updateTicketConfigDashboard(
guildId: string,
patch: TicketConfigDashboardPatch
): Promise<TicketConfigDashboard> {
await ensureTicketConfig(guildId);
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 } : {}),
...(panelChannelId !== undefined
? { panelChannelId: panelChannelId === '' ? null : panelChannelId }
: {})
}
});
const dashboard = toConfigDashboard(updated);
if (dashboard.enabled && dashboard.panelChannelId) {
await syncTicketPanel(guildId);
}
return dashboard;
}
function toCategoryDashboard(category: TicketCategory): TicketCategoryDashboard {
return {
id: category.id,
name: category.name,
description: category.description,
emoji: category.emoji,
supportRoleIds: category.supportRoleIds
};
}
export async function listTicketCategories(guildId: string): Promise<TicketCategoryDashboard[]> {
const categories = await prisma.ticketCategory.findMany({
where: { guildId },
orderBy: { createdAt: 'asc' }
});
return categories.map(toCategoryDashboard);
}
export class TicketCategoryConflictError extends Error {
constructor() {
super('A ticket category with this name already exists');
}
}
export async function createTicketCategory(
guildId: string,
input: TicketCategoryDashboardCreate
): Promise<TicketCategoryDashboard> {
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
try {
const category = await prisma.ticketCategory.create({
data: {
guildId,
name: input.name,
description: input.description ?? null,
emoji: input.emoji ?? null,
supportRoleIds: input.supportRoleIds
}
});
await maybeSyncTicketPanel(guildId);
return toCategoryDashboard(category);
} catch (error) {
if (error && typeof error === 'object' && 'code' in error && error.code === 'P2002') {
throw new TicketCategoryConflictError();
}
throw error;
}
}
export async function updateTicketCategory(
guildId: string,
categoryId: string,
patch: TicketCategoryDashboardUpdate
): Promise<TicketCategoryDashboard | null> {
const existing = await prisma.ticketCategory.findFirst({ where: { id: categoryId, guildId } });
if (!existing) {
return null;
}
try {
const updated = await prisma.ticketCategory.update({
where: { id: categoryId },
data: {
...(patch.name !== undefined ? { name: patch.name } : {}),
...(patch.description !== undefined ? { description: patch.description } : {}),
...(patch.emoji !== undefined ? { emoji: patch.emoji } : {}),
...(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') {
throw new TicketCategoryConflictError();
}
throw error;
}
}
export async function deleteTicketCategory(guildId: string, categoryId: string): Promise<boolean> {
const result = await prisma.ticketCategory.deleteMany({ where: { id: categoryId, guildId } });
if (result.count === 0) {
return false;
}
const remaining = await prisma.ticketCategory.count({ where: { guildId } });
if (remaining > 0) {
await maybeSyncTicketPanel(guildId);
}
return true;
}
type TicketWithCategory = Ticket & { category: TicketCategory | null };
function toTicketDashboard(ticket: TicketWithCategory): TicketDashboard {
return {
id: ticket.id,
openerId: ticket.openerId,
claimedById: ticket.claimedById,
channelId: ticket.channelId,
threadId: ticket.threadId,
status: ticket.status as TicketStatus,
priority: ticket.priority as TicketPriority,
subject: ticket.subject,
categoryId: ticket.categoryId,
categoryName: ticket.category?.name ?? null,
lastActivityAt: ticket.lastActivityAt.toISOString(),
createdAt: ticket.createdAt.toISOString()
};
}
export async function listOpenTickets(guildId: string): Promise<TicketDashboard[]> {
const tickets = await prisma.ticket.findMany({
where: {
guildId,
status: { in: ['OPEN', 'CLAIMED'] }
},
include: { category: true },
orderBy: { lastActivityAt: 'desc' },
take: 100
});
return tickets.map(toTicketDashboard);
}
export async function getTicketDashboard(
guildId: string,
ticketId: string
): Promise<TicketDashboard | null> {
const ticket = await prisma.ticket.findFirst({
where: { id: ticketId, guildId },
include: { category: true }
});
return ticket ? toTicketDashboard(ticket) : null;
}
/**
* Close / claim / priority need Discord side effects → bot via BullMQ.
*/
export async function applyTicketDashboardAction(
guildId: string,
ticketId: string,
action: TicketDashboardAction,
actorUserId: string
): Promise<TicketDashboard | null> {
const existing = await prisma.ticket.findFirst({
where: { id: ticketId, guildId },
include: { category: true }
});
if (!existing) {
return null;
}
if (existing.status === 'CLOSED') {
throw new TicketActionSyncError('This ticket is already closed');
}
const { confirmed } = await addJobAndAwait(
getTicketsQueue(),
getTicketsQueueEvents(),
'ticketDashboardAction',
{
ticketId,
guildId,
actorUserId,
action: action.action,
reason: action.action === 'close' ? action.reason : undefined,
priority: action.action === 'priority' ? action.priority : undefined
},
{
jobId: `ticket-action-${ticketId}-${action.action}-${Date.now()}`,
removeOnComplete: true,
removeOnFail: 25
},
45_000
);
if (!confirmed) {
throw new TicketActionSyncError(
'The bot did not confirm the ticket action in time. Reload the list shortly to check the result.'
);
}
if (action.action === 'close') {
const closed = await getTicketDashboard(guildId, ticketId);
return closed;
}
const updated = await getTicketDashboard(guildId, ticketId);
if (!updated) {
throw new TicketActionSyncError('Ticket action finished but the ticket could not be reloaded');
}
return updated;
}