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:
@@ -29,7 +29,7 @@ import {
|
||||
runGiveawayDeleteJob,
|
||||
GiveawayError
|
||||
} from './modules/giveaways/index.js';
|
||||
import { closeInactiveTickets, runTicketPanelSyncJob } from './modules/tickets/index.js';
|
||||
import { closeInactiveTickets, runTicketDashboardActionJob, runTicketPanelSyncJob } from './modules/tickets/index.js';
|
||||
import { expireBirthdayRole, runBirthdayCheck } from './modules/birthdays/index.js';
|
||||
import { ensureStatsRecurringJobs, startStatsWorker } from './modules/stats/index.js';
|
||||
import { pollAllFeeds } from './modules/feeds/index.js';
|
||||
@@ -351,6 +351,19 @@ export function startWorkers(context: BotContext): Worker[] {
|
||||
if (job.name === 'ticketPanelSync') {
|
||||
return runTicketPanelSyncJob(context, job.data as { guildId: string });
|
||||
}
|
||||
if (job.name === 'ticketDashboardAction') {
|
||||
return runTicketDashboardActionJob(
|
||||
context,
|
||||
job.data as {
|
||||
ticketId: string;
|
||||
guildId: string;
|
||||
actorUserId: string;
|
||||
action: 'close' | 'claim' | 'priority';
|
||||
reason?: string;
|
||||
priority?: string;
|
||||
}
|
||||
);
|
||||
}
|
||||
if (job.name === 'selfRoleExpire') {
|
||||
const data = job.data as SelfRoleExpireJob;
|
||||
await expireSelfRole(context, data.guildId, data.userId, data.roleId);
|
||||
|
||||
@@ -4,6 +4,8 @@ export {
|
||||
closeInactiveTickets,
|
||||
checkInactiveTickets,
|
||||
runTicketPanelSyncJob,
|
||||
runTicketDashboardActionJob,
|
||||
TicketPanelError
|
||||
} from './service.js';
|
||||
export { registerTicketEvents } from './events.js';
|
||||
|
||||
|
||||
@@ -21,9 +21,11 @@ import {
|
||||
parseRateButton,
|
||||
rateTicket,
|
||||
removeUserFromTicket,
|
||||
setTicketPriority,
|
||||
TICKET_CTRL_ADD,
|
||||
TICKET_CTRL_CLAIM,
|
||||
TICKET_CTRL_CLOSE,
|
||||
TICKET_CTRL_PRIORITY,
|
||||
TICKET_CTRL_REMOVE,
|
||||
TICKET_MODAL_PREFIX,
|
||||
TICKET_OPEN_PREFIX,
|
||||
@@ -109,7 +111,7 @@ async function handleOpenTicketRequest(
|
||||
}
|
||||
|
||||
async function handleTicketControl(
|
||||
interaction: ButtonInteraction | UserSelectMenuInteraction,
|
||||
interaction: ButtonInteraction | UserSelectMenuInteraction | StringSelectMenuInteraction,
|
||||
context: BotContext
|
||||
): Promise<void> {
|
||||
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
||||
@@ -153,6 +155,22 @@ async function handleTicketControl(
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.isStringSelectMenu() && interaction.customId === TICKET_CTRL_PRIORITY) {
|
||||
const level = interaction.values[0];
|
||||
if (!level) {
|
||||
await interaction.reply({ content: t(locale, 'ticket.error.not_found'), ...ephemeral() });
|
||||
return;
|
||||
}
|
||||
await setTicketPriority(context, ticket, member, level, locale);
|
||||
await interaction.reply({
|
||||
content: tf(locale, 'ticket.priority.success', {
|
||||
priority: t(locale, `ticket.priority.${level}`)
|
||||
}),
|
||||
...ephemeral()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.isUserSelectMenu() && interaction.customId === TICKET_CTRL_ADD) {
|
||||
const userId = interaction.values[0];
|
||||
if (!userId) {
|
||||
@@ -213,7 +231,11 @@ export async function handleTicketInteraction(
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.isButton() || interaction.isUserSelectMenu()) {
|
||||
if (
|
||||
interaction.isButton() ||
|
||||
interaction.isUserSelectMenu() ||
|
||||
interaction.isStringSelectMenu()
|
||||
) {
|
||||
if (isTicketControlInteraction(interaction.customId)) {
|
||||
await handleTicketControl(interaction, context);
|
||||
return;
|
||||
|
||||
@@ -43,6 +43,7 @@ export const TICKET_CTRL_CLAIM = 'ticket:ctrl:claim';
|
||||
export const TICKET_CTRL_CLOSE = 'ticket:ctrl:close';
|
||||
export const TICKET_CTRL_ADD = 'ticket:ctrl:add';
|
||||
export const TICKET_CTRL_REMOVE = 'ticket:ctrl:remove';
|
||||
export const TICKET_CTRL_PRIORITY = 'ticket:ctrl:priority';
|
||||
|
||||
/** Cap private-thread staff adds to stay within Discord rate limits. */
|
||||
const MAX_THREAD_SUPPORT_MEMBERS = 50;
|
||||
@@ -160,13 +161,16 @@ export function isTicketControlInteraction(customId: string): boolean {
|
||||
customId === TICKET_CTRL_CLAIM ||
|
||||
customId === TICKET_CTRL_CLOSE ||
|
||||
customId === TICKET_CTRL_ADD ||
|
||||
customId === TICKET_CTRL_REMOVE
|
||||
customId === TICKET_CTRL_REMOVE ||
|
||||
customId === TICKET_CTRL_PRIORITY
|
||||
);
|
||||
}
|
||||
|
||||
export function buildTicketControlPanel(locale: 'de' | 'en'): {
|
||||
embeds: EmbedBuilder[];
|
||||
components: ActionRowBuilder<ButtonBuilder | UserSelectMenuBuilder>[];
|
||||
components: ActionRowBuilder<
|
||||
ButtonBuilder | UserSelectMenuBuilder | StringSelectMenuBuilder
|
||||
>[];
|
||||
} {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(t(locale, 'ticket.control.title'))
|
||||
@@ -184,6 +188,34 @@ export function buildTicketControlPanel(locale: 'de' | 'en'): {
|
||||
.setStyle(ButtonStyle.Danger)
|
||||
);
|
||||
|
||||
const priorityRow = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
|
||||
new StringSelectMenuBuilder()
|
||||
.setCustomId(TICKET_CTRL_PRIORITY)
|
||||
.setPlaceholder(t(locale, 'ticket.control.priorityPlaceholder'))
|
||||
.addOptions(
|
||||
{
|
||||
label: t(locale, 'ticket.priority.LOW'),
|
||||
value: 'LOW',
|
||||
description: t(locale, 'ticket.control.priorityLowHint')
|
||||
},
|
||||
{
|
||||
label: t(locale, 'ticket.priority.NORMAL'),
|
||||
value: 'NORMAL',
|
||||
description: t(locale, 'ticket.control.priorityNormalHint')
|
||||
},
|
||||
{
|
||||
label: t(locale, 'ticket.priority.HIGH'),
|
||||
value: 'HIGH',
|
||||
description: t(locale, 'ticket.control.priorityHighHint')
|
||||
},
|
||||
{
|
||||
label: t(locale, 'ticket.priority.URGENT'),
|
||||
value: 'URGENT',
|
||||
description: t(locale, 'ticket.control.priorityUrgentHint')
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const addRow = new ActionRowBuilder<UserSelectMenuBuilder>().addComponents(
|
||||
new UserSelectMenuBuilder()
|
||||
.setCustomId(TICKET_CTRL_ADD)
|
||||
@@ -202,7 +234,7 @@ export function buildTicketControlPanel(locale: 'de' | 'en'): {
|
||||
|
||||
return {
|
||||
embeds: [embed],
|
||||
components: [buttons, addRow, removeRow]
|
||||
components: [buttons, priorityRow, addRow, removeRow]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -467,6 +499,94 @@ export async function runTicketPanelSyncJob(
|
||||
return syncTicketPanel(context, data.guildId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard ticket actions (close / claim / priority). Staff checks are done by
|
||||
* the WebUI (`requireGuildAccess`); the bot applies Discord side effects.
|
||||
*/
|
||||
export async function runTicketDashboardActionJob(
|
||||
context: BotContext,
|
||||
data: {
|
||||
ticketId: string;
|
||||
guildId: string;
|
||||
actorUserId: string;
|
||||
action: 'close' | 'claim' | 'priority';
|
||||
reason?: string;
|
||||
priority?: string;
|
||||
}
|
||||
): Promise<{ id: string; status: string; priority: string; claimedById: string | null }> {
|
||||
const ticket = await context.prisma.ticket.findFirst({
|
||||
where: { id: data.ticketId, guildId: data.guildId },
|
||||
include: { category: true }
|
||||
});
|
||||
if (!ticket) {
|
||||
throw new TicketError('not_found');
|
||||
}
|
||||
if (ticket.status === 'CLOSED') {
|
||||
throw new TicketError('closed');
|
||||
}
|
||||
|
||||
const locale = await getGuildLocale(context.prisma, data.guildId);
|
||||
|
||||
if (data.action === 'close') {
|
||||
await closeTicket(context, ticket, data.actorUserId, locale, data.reason);
|
||||
const closed = await context.prisma.ticket.findUniqueOrThrow({ where: { id: ticket.id } });
|
||||
return {
|
||||
id: closed.id,
|
||||
status: closed.status,
|
||||
priority: closed.priority,
|
||||
claimedById: closed.claimedById
|
||||
};
|
||||
}
|
||||
|
||||
if (data.action === 'claim') {
|
||||
if (ticket.claimedById === data.actorUserId) {
|
||||
throw new TicketError('already_claimed');
|
||||
}
|
||||
const updated = await context.prisma.ticket.update({
|
||||
where: { id: ticket.id },
|
||||
data: {
|
||||
claimedById: data.actorUserId,
|
||||
status: 'CLAIMED',
|
||||
lastActivityAt: new Date()
|
||||
}
|
||||
});
|
||||
const channelId = ticket.channelId ?? ticket.threadId;
|
||||
if (channelId) {
|
||||
const channel = await fetchSendableChannel(context, channelId);
|
||||
if (channel) {
|
||||
await channel.send(tf(locale, 'ticket.claimed', { user: `<@${data.actorUserId}>` }));
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: updated.id,
|
||||
status: updated.status,
|
||||
priority: updated.priority,
|
||||
claimedById: updated.claimedById
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = TicketPrioritySchema.parse(data.priority);
|
||||
const updated = await context.prisma.ticket.update({
|
||||
where: { id: ticket.id },
|
||||
data: { priority: parsed, lastActivityAt: new Date() }
|
||||
});
|
||||
const channelId = ticket.channelId ?? ticket.threadId;
|
||||
if (channelId) {
|
||||
const channel = await fetchSendableChannel(context, channelId);
|
||||
if (channel) {
|
||||
await channel.send(
|
||||
tf(locale, 'ticket.prioritySet', { priority: t(locale, `ticket.priority.${parsed}`) })
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: updated.id,
|
||||
status: updated.status,
|
||||
priority: updated.priority,
|
||||
claimedById: updated.claimedById
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOpenModal(category: TicketCategory, locale: 'de' | 'en'): ModalBuilder {
|
||||
const fields = parseFormFields(category.formFields);
|
||||
const modal = new ModalBuilder()
|
||||
|
||||
Reference in New Issue
Block a user