- 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.
336 lines
10 KiB
TypeScript
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;
|
|
}
|