Enhance ticket management with dashboard actions and priority settings

- Introduced a new `runTicketDashboardActionJob` to handle ticket actions (close, claim, set priority) with Discord side effects.
- Added a priority dropdown in the ticket control panel, allowing users to set ticket urgency levels.
- Updated WebUI to display open tickets with options to claim, close, or adjust priority, enhancing user interaction.
- Improved localization for new ticket management features in both English and German.
- Documented changes in phase tracking to reflect the new ticket dashboard functionalities.
This commit is contained in:
TheOnlyMace
2026-07-25 16:21:51 +02:00
parent ac5ecc9a1c
commit 5c11b68fc0
15 changed files with 710 additions and 14 deletions

View File

@@ -3,9 +3,13 @@ import type {
TicketCategoryDashboardCreate,
TicketCategoryDashboardUpdate,
TicketConfigDashboard,
TicketConfigDashboardPatch
TicketConfigDashboardPatch,
TicketDashboard,
TicketDashboardAction,
TicketPriority,
TicketStatus
} from '@nexumi/shared';
import type { TicketCategory } from '@prisma/client';
import type { Ticket, TicketCategory } from '@prisma/client';
import { prisma } from '../prisma';
import { addJobAndAwait, getTicketsQueue, getTicketsQueueEvents } from '../queues';
@@ -19,6 +23,13 @@ export class TicketPanelSyncError extends Error {
}
}
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 } });
@@ -214,3 +225,104 @@ export async function deleteTicketCategory(guildId: string, categoryId: string):
}
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;
}