deploy #2
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "TicketConfig" ADD COLUMN "panelChannelId" TEXT;
|
||||||
|
ALTER TABLE "TicketConfig" ADD COLUMN "panelMessageId" TEXT;
|
||||||
@@ -513,6 +513,8 @@ model TicketConfig {
|
|||||||
mode String @default("CHANNEL")
|
mode String @default("CHANNEL")
|
||||||
categoryId String?
|
categoryId String?
|
||||||
logChannelId String?
|
logChannelId String?
|
||||||
|
panelChannelId String?
|
||||||
|
panelMessageId String?
|
||||||
transcriptDm Boolean @default(false)
|
transcriptDm Boolean @default(false)
|
||||||
inactivityHours Int @default(72)
|
inactivityHours Int @default(72)
|
||||||
ratingEnabled Boolean @default(true)
|
ratingEnabled Boolean @default(true)
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
runGiveawayDeleteJob,
|
runGiveawayDeleteJob,
|
||||||
GiveawayError
|
GiveawayError
|
||||||
} from './modules/giveaways/index.js';
|
} from './modules/giveaways/index.js';
|
||||||
import { closeInactiveTickets } from './modules/tickets/index.js';
|
import { closeInactiveTickets, runTicketPanelSyncJob } from './modules/tickets/index.js';
|
||||||
import { expireBirthdayRole, runBirthdayCheck } from './modules/birthdays/index.js';
|
import { expireBirthdayRole, runBirthdayCheck } from './modules/birthdays/index.js';
|
||||||
import { ensureStatsRecurringJobs, startStatsWorker } from './modules/stats/index.js';
|
import { ensureStatsRecurringJobs, startStatsWorker } from './modules/stats/index.js';
|
||||||
import { pollAllFeeds } from './modules/feeds/index.js';
|
import { pollAllFeeds } from './modules/feeds/index.js';
|
||||||
@@ -348,6 +348,9 @@ export function startWorkers(context: BotContext): Worker[] {
|
|||||||
const ticketWorker = new Worker(
|
const ticketWorker = new Worker(
|
||||||
ticketQueueName,
|
ticketQueueName,
|
||||||
async (job) => {
|
async (job) => {
|
||||||
|
if (job.name === 'ticketPanelSync') {
|
||||||
|
return runTicketPanelSyncJob(context, job.data as { guildId: string });
|
||||||
|
}
|
||||||
if (job.name === 'selfRoleExpire') {
|
if (job.name === 'selfRoleExpire') {
|
||||||
const data = job.data as SelfRoleExpireJob;
|
const data = job.data as SelfRoleExpireJob;
|
||||||
await expireSelfRole(context, data.guildId, data.userId, data.roleId);
|
await expireSelfRole(context, data.guildId, data.userId, data.roleId);
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import { ticketCommandData } from './command-definitions.js';
|
|||||||
import {
|
import {
|
||||||
addUserToTicket,
|
addUserToTicket,
|
||||||
assertTicketStaff,
|
assertTicketStaff,
|
||||||
buildPanelComponents,
|
|
||||||
buildPanelEmbed,
|
|
||||||
claimTicket,
|
claimTicket,
|
||||||
closeTicket,
|
closeTicket,
|
||||||
createCategoryWithRole,
|
createCategoryWithRole,
|
||||||
@@ -17,7 +15,9 @@ import {
|
|||||||
removeUserFromTicket,
|
removeUserFromTicket,
|
||||||
renameTicket,
|
renameTicket,
|
||||||
setTicketPriority,
|
setTicketPriority,
|
||||||
|
syncTicketPanel,
|
||||||
TicketError,
|
TicketError,
|
||||||
|
TicketPanelError,
|
||||||
upsertCategoryByName
|
upsertCategoryByName
|
||||||
} from './service.js';
|
} from './service.js';
|
||||||
|
|
||||||
@@ -67,10 +67,26 @@ const ticketCommand: SlashCommand = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const embed = buildPanelEmbed(locale, name, description);
|
try {
|
||||||
const components = buildPanelComponents(categories);
|
await syncTicketPanel(context, interaction.guildId!, {
|
||||||
await channel.send({ embeds: [embed], components });
|
channelId: channel.id,
|
||||||
|
title: name,
|
||||||
|
description
|
||||||
|
});
|
||||||
await interaction.reply({ content: t(locale, 'ticket.panel.created'), ephemeral: true });
|
await interaction.reply({ content: t(locale, 'ticket.panel.created'), ephemeral: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof TicketPanelError) {
|
||||||
|
const key =
|
||||||
|
error.code === 'no_categories'
|
||||||
|
? 'ticket.error.no_categories'
|
||||||
|
: error.code === 'not_configured'
|
||||||
|
? 'ticket.error.invalid_channel'
|
||||||
|
: 'ticket.error.missing_permission';
|
||||||
|
await interaction.reply({ content: t(locale, key), ephemeral: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
export { ticketsCommands } from './commands.js';
|
export { ticketsCommands } from './commands.js';
|
||||||
export { isTicketInteraction, handleTicketInteraction } from './interactions.js';
|
export { isTicketInteraction, handleTicketInteraction } from './interactions.js';
|
||||||
export { closeInactiveTickets, checkInactiveTickets } from './service.js';
|
export {
|
||||||
|
closeInactiveTickets,
|
||||||
|
checkInactiveTickets,
|
||||||
|
runTicketPanelSyncJob,
|
||||||
|
TicketPanelError
|
||||||
|
} from './service.js';
|
||||||
export { registerTicketEvents } from './events.js';
|
export { registerTicketEvents } from './events.js';
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ import {
|
|||||||
StringSelectMenuBuilder,
|
StringSelectMenuBuilder,
|
||||||
TextInputBuilder,
|
TextInputBuilder,
|
||||||
TextInputStyle,
|
TextInputStyle,
|
||||||
|
type APIMessageComponentEmoji,
|
||||||
type Guild,
|
type Guild,
|
||||||
|
type GuildBasedChannel,
|
||||||
type GuildMember,
|
type GuildMember,
|
||||||
|
type GuildTextBasedChannel,
|
||||||
type Message,
|
type Message,
|
||||||
type SendableChannels,
|
type SendableChannels,
|
||||||
type TextChannel,
|
type TextChannel,
|
||||||
@@ -51,6 +54,52 @@ export class TicketError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class TicketPanelError extends Error {
|
||||||
|
constructor(public readonly code: 'not_configured' | 'missing_permission' | 'no_categories') {
|
||||||
|
super(code);
|
||||||
|
this.name = 'TicketPanelError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function botCanPostPanel(
|
||||||
|
channel: GuildBasedChannel,
|
||||||
|
meId: string
|
||||||
|
): channel is GuildTextBasedChannel {
|
||||||
|
if (!channel.isTextBased() || channel.isDMBased()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const permissions = channel.permissionsFor(meId);
|
||||||
|
if (!permissions) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
permissions.has(PermissionFlagsBits.ViewChannel) &&
|
||||||
|
permissions.has(PermissionFlagsBits.SendMessages) &&
|
||||||
|
permissions.has(PermissionFlagsBits.EmbedLinks)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseButtonEmoji(emoji?: string | null): APIMessageComponentEmoji | undefined {
|
||||||
|
if (!emoji) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const customMatch = emoji.match(/^<(a)?:(\w+):(\d+)>$/);
|
||||||
|
if (customMatch) {
|
||||||
|
return {
|
||||||
|
id: customMatch[3]!,
|
||||||
|
name: customMatch[2],
|
||||||
|
animated: Boolean(customMatch[1])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\d+$/.test(emoji)) {
|
||||||
|
return { id: emoji };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { name: emoji };
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchSendableChannel(
|
async function fetchSendableChannel(
|
||||||
context: BotContext,
|
context: BotContext,
|
||||||
channelId: string
|
channelId: string
|
||||||
@@ -185,8 +234,9 @@ export function buildPanelComponents(categories: TicketCategory[]): ActionRowBui
|
|||||||
.setCustomId(openButtonId(category.id))
|
.setCustomId(openButtonId(category.id))
|
||||||
.setLabel(category.name.slice(0, 80))
|
.setLabel(category.name.slice(0, 80))
|
||||||
.setStyle(ButtonStyle.Primary);
|
.setStyle(ButtonStyle.Primary);
|
||||||
if (category.emoji) {
|
const emoji = parseButtonEmoji(category.emoji);
|
||||||
button.setEmoji(category.emoji);
|
if (emoji) {
|
||||||
|
button.setEmoji(emoji);
|
||||||
}
|
}
|
||||||
row.addComponents(button);
|
row.addComponents(button);
|
||||||
}
|
}
|
||||||
@@ -197,17 +247,120 @@ export function buildPanelComponents(categories: TicketCategory[]): ActionRowBui
|
|||||||
.setCustomId(TICKET_SELECT_ID)
|
.setCustomId(TICKET_SELECT_ID)
|
||||||
.setPlaceholder('Select a category')
|
.setPlaceholder('Select a category')
|
||||||
.addOptions(
|
.addOptions(
|
||||||
categories.slice(0, 25).map((category) => ({
|
categories.slice(0, 25).map((category) => {
|
||||||
|
const emoji = parseButtonEmoji(category.emoji);
|
||||||
|
return {
|
||||||
label: category.name.slice(0, 100),
|
label: category.name.slice(0, 100),
|
||||||
value: category.id,
|
value: category.id,
|
||||||
description: category.description?.slice(0, 100) ?? undefined,
|
description: category.description?.slice(0, 100) ?? undefined,
|
||||||
emoji: category.emoji ? { name: category.emoji } : undefined
|
emoji
|
||||||
}))
|
};
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return [new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select)];
|
return [new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posts or updates the ticket panel. Used by `/ticket panel_create` and by the
|
||||||
|
* dashboard via BullMQ (`ticketPanelSync`).
|
||||||
|
*/
|
||||||
|
export async function syncTicketPanel(
|
||||||
|
context: BotContext,
|
||||||
|
guildId: string,
|
||||||
|
options: {
|
||||||
|
channelId?: string | null;
|
||||||
|
title?: string;
|
||||||
|
description?: string | null;
|
||||||
|
} = {}
|
||||||
|
): Promise<{ panelChannelId: string; panelMessageId: string }> {
|
||||||
|
const locale = await getGuildLocale(context.prisma, guildId);
|
||||||
|
const config = await getTicketConfig(context.prisma, guildId);
|
||||||
|
const categories = await getGuildCategories(context, guildId);
|
||||||
|
|
||||||
|
if (categories.length === 0) {
|
||||||
|
throw new TicketPanelError('no_categories');
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetChannelId = options.channelId || config.panelChannelId;
|
||||||
|
if (!targetChannelId) {
|
||||||
|
throw new TicketPanelError('not_configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const guild = await context.client.guilds.fetch(guildId);
|
||||||
|
const me = guild.members.me;
|
||||||
|
if (!me) {
|
||||||
|
throw new TicketPanelError('missing_permission');
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = await guild.channels.fetch(targetChannelId).catch(() => null);
|
||||||
|
if (!channel || !botCanPostPanel(channel, me.id)) {
|
||||||
|
throw new TicketPanelError('missing_permission');
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = options.title ?? t(locale, 'ticket.panel.defaultTitle');
|
||||||
|
const description =
|
||||||
|
options.description !== undefined
|
||||||
|
? options.description
|
||||||
|
: t(locale, 'ticket.panel.defaultDescription');
|
||||||
|
const payload = {
|
||||||
|
embeds: [buildPanelEmbed(locale, title, description)],
|
||||||
|
components: buildPanelComponents(categories)
|
||||||
|
};
|
||||||
|
|
||||||
|
const sameChannel =
|
||||||
|
Boolean(config.panelMessageId) && config.panelChannelId === channel.id;
|
||||||
|
|
||||||
|
if (sameChannel && config.panelMessageId) {
|
||||||
|
try {
|
||||||
|
const existing = await channel.messages.fetch(config.panelMessageId);
|
||||||
|
await existing.edit(payload);
|
||||||
|
if (!config.panelChannelId) {
|
||||||
|
await context.prisma.ticketConfig.update({
|
||||||
|
where: { guildId },
|
||||||
|
data: { panelChannelId: channel.id }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { panelChannelId: channel.id, panelMessageId: existing.id };
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
{ error, guildId, messageId: config.panelMessageId },
|
||||||
|
'Failed to edit ticket panel; recreating'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.panelMessageId && config.panelChannelId && config.panelChannelId !== channel.id) {
|
||||||
|
try {
|
||||||
|
const oldChannel = await guild.channels.fetch(config.panelChannelId);
|
||||||
|
if (oldChannel?.isTextBased() && !oldChannel.isDMBased()) {
|
||||||
|
const oldMessage = await oldChannel.messages.fetch(config.panelMessageId);
|
||||||
|
await oldMessage.delete();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Previous panel may already be gone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = await channel.send(payload);
|
||||||
|
await context.prisma.ticketConfig.update({
|
||||||
|
where: { guildId },
|
||||||
|
data: {
|
||||||
|
panelChannelId: channel.id,
|
||||||
|
panelMessageId: message.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { panelChannelId: channel.id, panelMessageId: message.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runTicketPanelSyncJob(
|
||||||
|
context: BotContext,
|
||||||
|
data: { guildId: string }
|
||||||
|
): Promise<{ panelChannelId: string; panelMessageId: string }> {
|
||||||
|
return syncTicketPanel(context, data.guildId);
|
||||||
|
}
|
||||||
|
|
||||||
export function buildOpenModal(category: TicketCategory, locale: 'de' | 'en'): ModalBuilder {
|
export function buildOpenModal(category: TicketCategory, locale: 'de' | 'en'): ModalBuilder {
|
||||||
const fields = parseFormFields(category.formFields);
|
const fields = parseFormFields(category.formFields);
|
||||||
const modal = new ModalBuilder()
|
const modal = new ModalBuilder()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { writeDashboardAudit } from '@/lib/audit';
|
|||||||
import {
|
import {
|
||||||
deleteTicketCategory,
|
deleteTicketCategory,
|
||||||
TicketCategoryConflictError,
|
TicketCategoryConflictError,
|
||||||
|
TicketPanelSyncError,
|
||||||
updateTicketCategory
|
updateTicketCategory
|
||||||
} from '@/lib/module-configs/tickets';
|
} from '@/lib/module-configs/tickets';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
@@ -38,6 +39,12 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
|||||||
if (error instanceof TicketCategoryConflictError) {
|
if (error instanceof TicketCategoryConflictError) {
|
||||||
return NextResponse.json({ error: error.message }, { status: 409 });
|
return NextResponse.json({ error: error.message }, { status: 409 });
|
||||||
}
|
}
|
||||||
|
if (error instanceof TicketPanelSyncError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message, code: error.code },
|
||||||
|
{ status: error.code === 'timeout' ? 504 : 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
return toApiErrorResponse(error);
|
return toApiErrorResponse(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,6 +68,12 @@ export async function DELETE(_request: NextRequest, { params }: RouteParams) {
|
|||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof TicketPanelSyncError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message, code: error.code },
|
||||||
|
{ status: error.code === 'timeout' ? 504 : 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
return toApiErrorResponse(error);
|
return toApiErrorResponse(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { TicketCategoryDashboardCreateSchema } from '@nexumi/shared';
|
|||||||
import { type NextRequest, NextResponse } from 'next/server';
|
import { type NextRequest, NextResponse } from 'next/server';
|
||||||
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
|
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
|
||||||
import { writeDashboardAudit } from '@/lib/audit';
|
import { writeDashboardAudit } from '@/lib/audit';
|
||||||
import { createTicketCategory, listTicketCategories, TicketCategoryConflictError } from '@/lib/module-configs/tickets';
|
import { createTicketCategory, listTicketCategories, TicketCategoryConflictError, TicketPanelSyncError } from '@/lib/module-configs/tickets';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
interface RouteParams {
|
interface RouteParams {
|
||||||
@@ -42,6 +42,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
if (error instanceof TicketCategoryConflictError) {
|
if (error instanceof TicketCategoryConflictError) {
|
||||||
return NextResponse.json({ error: error.message }, { status: 409 });
|
return NextResponse.json({ error: error.message }, { status: 409 });
|
||||||
}
|
}
|
||||||
|
if (error instanceof TicketPanelSyncError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message, code: error.code },
|
||||||
|
{ status: error.code === 'timeout' ? 504 : 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
return toApiErrorResponse(error);
|
return toApiErrorResponse(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { TicketConfigDashboardPatchSchema } from '@nexumi/shared';
|
|||||||
import { type NextRequest, NextResponse } from 'next/server';
|
import { type NextRequest, NextResponse } from 'next/server';
|
||||||
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
|
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
|
||||||
import { writeDashboardAudit } from '@/lib/audit';
|
import { writeDashboardAudit } from '@/lib/audit';
|
||||||
import { getTicketConfigDashboard, updateTicketConfigDashboard } from '@/lib/module-configs/tickets';
|
import {
|
||||||
|
getTicketConfigDashboard,
|
||||||
|
TicketPanelSyncError,
|
||||||
|
updateTicketConfigDashboard
|
||||||
|
} from '@/lib/module-configs/tickets';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
|
|
||||||
interface RouteParams {
|
interface RouteParams {
|
||||||
@@ -41,6 +45,12 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
|||||||
|
|
||||||
return NextResponse.json(after);
|
return NextResponse.json(after);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof TicketPanelSyncError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message, code: error.code },
|
||||||
|
{ status: error.code === 'timeout' ? 504 : 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
return toApiErrorResponse(error);
|
return toApiErrorResponse(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { FieldAnchor } from '@/components/layout/field-anchor';
|
|||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { DiscordEmojiSelect } from '@/components/ui/discord-emoji-select';
|
||||||
import { DiscordRoleMultiSelect } from '@/components/ui/discord-role-select';
|
import { DiscordRoleMultiSelect } from '@/components/ui/discord-role-select';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -24,6 +25,23 @@ interface TicketCategoriesManagerProps {
|
|||||||
initialCategories: TicketCategoryDashboard[];
|
initialCategories: TicketCategoryDashboard[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function panelSyncErrorMessage(
|
||||||
|
code: string | undefined,
|
||||||
|
fallback: string | undefined,
|
||||||
|
t: (key: string) => string
|
||||||
|
): string {
|
||||||
|
if (code === 'timeout') {
|
||||||
|
return t('modulePages.tickets.errors.panelTimeout');
|
||||||
|
}
|
||||||
|
if (code === 'no_categories') {
|
||||||
|
return t('modulePages.tickets.errors.noCategories');
|
||||||
|
}
|
||||||
|
if (code === 'not_posted') {
|
||||||
|
return t('modulePages.tickets.errors.panelNotPosted');
|
||||||
|
}
|
||||||
|
return fallback ?? t('common.saveError');
|
||||||
|
}
|
||||||
|
|
||||||
export function TicketCategoriesManager({ guildId, initialCategories }: TicketCategoriesManagerProps) {
|
export function TicketCategoriesManager({ guildId, initialCategories }: TicketCategoriesManagerProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const [categories, setCategories] = useState<LocalCategory[]>(
|
const [categories, setCategories] = useState<LocalCategory[]>(
|
||||||
@@ -49,9 +67,11 @@ export function TicketCategoriesManager({ guildId, initialCategories }: TicketCa
|
|||||||
supportRoleIds: draft.supportRoleIds
|
supportRoleIds: draft.supportRoleIds
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
const body = (await response.json().catch(() => null)) as (TicketCategoryDashboard & { error?: string }) | null;
|
const body = (await response.json().catch(() => null)) as
|
||||||
|
| (TicketCategoryDashboard & { error?: string; code?: string })
|
||||||
|
| null;
|
||||||
if (!response.ok || !body) {
|
if (!response.ok || !body) {
|
||||||
toast.error(body?.error ?? t('common.saveError'));
|
toast.error(panelSyncErrorMessage(body?.code, body?.error, t));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setCategories((prev) => [...prev, { ...body, clientKey: body.id ?? `new-${Date.now()}` }]);
|
setCategories((prev) => [...prev, { ...body, clientKey: body.id ?? `new-${Date.now()}` }]);
|
||||||
@@ -79,9 +99,11 @@ export function TicketCategoriesManager({ guildId, initialCategories }: TicketCa
|
|||||||
supportRoleIds: category.supportRoleIds
|
supportRoleIds: category.supportRoleIds
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
const body = (await response.json().catch(() => null)) as (TicketCategoryDashboard & { error?: string }) | null;
|
const body = (await response.json().catch(() => null)) as
|
||||||
|
| (TicketCategoryDashboard & { error?: string; code?: string })
|
||||||
|
| null;
|
||||||
if (!response.ok || !body) {
|
if (!response.ok || !body) {
|
||||||
toast.error(body?.error ?? t('common.saveError'));
|
toast.error(panelSyncErrorMessage(body?.code, body?.error, t));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast.success(t('common.saveSuccess'));
|
toast.success(t('common.saveSuccess'));
|
||||||
@@ -100,7 +122,8 @@ export function TicketCategoriesManager({ guildId, initialCategories }: TicketCa
|
|||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
toast.error(t('common.saveError'));
|
const body = (await response.json().catch(() => null)) as { error?: string; code?: string } | null;
|
||||||
|
toast.error(panelSyncErrorMessage(body?.code, body?.error, t));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setCategories((prev) => prev.filter((entry) => entry.clientKey !== category.clientKey));
|
setCategories((prev) => prev.filter((entry) => entry.clientKey !== category.clientKey));
|
||||||
@@ -139,12 +162,15 @@ export function TicketCategoriesManager({ guildId, initialCategories }: TicketCa
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.tickets.categoryEmoji')}</Label>
|
<Label>{t('modulePages.tickets.categoryEmoji')}</Label>
|
||||||
<Input
|
<DiscordEmojiSelect
|
||||||
value={category.emoji ?? ''}
|
guildId={guildId}
|
||||||
onChange={(event) =>
|
value={category.emoji ?? undefined}
|
||||||
|
onChange={(emoji) =>
|
||||||
setCategories((prev) =>
|
setCategories((prev) =>
|
||||||
prev.map((entry) =>
|
prev.map((entry) =>
|
||||||
entry.clientKey === category.clientKey ? { ...entry, emoji: event.target.value } : entry
|
entry.clientKey === category.clientKey
|
||||||
|
? { ...entry, emoji: emoji ?? null }
|
||||||
|
: entry
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -200,7 +226,11 @@ export function TicketCategoriesManager({ guildId, initialCategories }: TicketCa
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.tickets.categoryEmoji')}</Label>
|
<Label>{t('modulePages.tickets.categoryEmoji')}</Label>
|
||||||
<Input value={draft.emoji} onChange={(event) => setDraft((prev) => ({ ...prev, emoji: event.target.value }))} />
|
<DiscordEmojiSelect
|
||||||
|
guildId={guildId}
|
||||||
|
value={draft.emoji || undefined}
|
||||||
|
onChange={(emoji) => setDraft((prev) => ({ ...prev, emoji: emoji ?? '' }))}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2 space-y-2">
|
<div className="col-span-2 space-y-2">
|
||||||
<Label>{t('modulePages.tickets.categoryDescription')}</Label>
|
<Label>{t('modulePages.tickets.categoryDescription')}</Label>
|
||||||
|
|||||||
@@ -13,7 +13,16 @@ import { Switch } from '@/components/ui/switch';
|
|||||||
import type { SettingsSaveResult } from '@/components/settings/settings-form';
|
import type { SettingsSaveResult } from '@/components/settings/settings-form';
|
||||||
import { SettingsForm } from '@/components/settings/settings-form';
|
import { SettingsForm } from '@/components/settings/settings-form';
|
||||||
|
|
||||||
async function saveTicketConfig(guildId: string, value: TicketConfigDashboard): Promise<SettingsSaveResult> {
|
async function saveTicketConfig(
|
||||||
|
guildId: string,
|
||||||
|
value: TicketConfigDashboard,
|
||||||
|
errorMessages: {
|
||||||
|
timeout: string;
|
||||||
|
notPosted: string;
|
||||||
|
noCategories: string;
|
||||||
|
network: string;
|
||||||
|
}
|
||||||
|
): Promise<SettingsSaveResult> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/guilds/${guildId}/tickets`, {
|
const response = await fetch(`/api/guilds/${guildId}/tickets`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@@ -21,12 +30,24 @@ async function saveTicketConfig(guildId: string, value: TicketConfigDashboard):
|
|||||||
body: JSON.stringify(value)
|
body: JSON.stringify(value)
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const body = (await response.json().catch(() => null)) as { error?: string } | null;
|
const body = (await response.json().catch(() => null)) as {
|
||||||
|
error?: string;
|
||||||
|
code?: string;
|
||||||
|
} | null;
|
||||||
|
if (body?.code === 'timeout') {
|
||||||
|
return { ok: false, error: errorMessages.timeout };
|
||||||
|
}
|
||||||
|
if (body?.code === 'no_categories') {
|
||||||
|
return { ok: false, error: errorMessages.noCategories };
|
||||||
|
}
|
||||||
|
if (body?.code === 'not_posted') {
|
||||||
|
return { ok: false, error: errorMessages.notPosted };
|
||||||
|
}
|
||||||
return { ok: false, error: body?.error ?? `Request failed with status ${response.status}` };
|
return { ok: false, error: body?.error ?? `Request failed with status ${response.status}` };
|
||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
} catch {
|
} catch {
|
||||||
return { ok: false, error: 'Network error' };
|
return { ok: false, error: errorMessages.network };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +63,14 @@ export function TicketConfigForm({ guildId, initialValue }: TicketConfigFormProp
|
|||||||
return (
|
return (
|
||||||
<SettingsForm<TicketConfigDashboard>
|
<SettingsForm<TicketConfigDashboard>
|
||||||
initialValue={initialValue}
|
initialValue={initialValue}
|
||||||
onSave={(value) => saveTicketConfig(guildId, value)}
|
onSave={(value) =>
|
||||||
|
saveTicketConfig(guildId, value, {
|
||||||
|
timeout: t('modulePages.tickets.errors.panelTimeout'),
|
||||||
|
notPosted: t('modulePages.tickets.errors.panelNotPosted'),
|
||||||
|
noCategories: t('modulePages.tickets.errors.noCategories'),
|
||||||
|
network: t('common.networkError')
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{({ value, setValue }) => (
|
{({ value, setValue }) => (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -77,6 +105,18 @@ export function TicketConfigForm({ guildId, initialValue }: TicketConfigFormProp
|
|||||||
</div>
|
</div>
|
||||||
</FieldAnchor>
|
</FieldAnchor>
|
||||||
|
|
||||||
|
<FieldAnchor id="field-tickets-panel-channel">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('modulePages.tickets.panelChannelId')}</Label>
|
||||||
|
<DiscordChannelSelect
|
||||||
|
guildId={guildId}
|
||||||
|
value={value.panelChannelId}
|
||||||
|
onChange={(panelChannelId) => setValue((prev) => ({ ...prev, panelChannelId }))}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('modulePages.tickets.panelChannelHint')}</p>
|
||||||
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
|
||||||
<FieldAnchor id="field-tickets-category">
|
<FieldAnchor id="field-tickets-category">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.tickets.categoryId')}</Label>
|
<Label>{t('modulePages.tickets.categoryId')}</Label>
|
||||||
|
|||||||
@@ -623,6 +623,13 @@ export const SETTINGS_SEARCH_FIELDS: SearchIndexEntry[] = [
|
|||||||
href: 'tickets',
|
href: 'tickets',
|
||||||
hash: 'field-tickets-mode'
|
hash: 'field-tickets-mode'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-tickets-panel-channel',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.tickets.panelChannelId',
|
||||||
|
href: 'tickets',
|
||||||
|
hash: 'field-tickets-panel-channel'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'setting-tickets-category',
|
id: 'setting-tickets-category',
|
||||||
kind: 'setting',
|
kind: 'setting',
|
||||||
|
|||||||
@@ -7,6 +7,17 @@ import type {
|
|||||||
} from '@nexumi/shared';
|
} from '@nexumi/shared';
|
||||||
import type { TicketCategory } from '@prisma/client';
|
import type { TicketCategory } from '@prisma/client';
|
||||||
import { prisma } from '../prisma';
|
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) {
|
async function ensureTicketConfig(guildId: string) {
|
||||||
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
|
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'],
|
mode: config.mode as TicketConfigDashboard['mode'],
|
||||||
categoryId: config.categoryId ?? '',
|
categoryId: config.categoryId ?? '',
|
||||||
logChannelId: config.logChannelId ?? '',
|
logChannelId: config.logChannelId ?? '',
|
||||||
|
panelChannelId: config.panelChannelId ?? '',
|
||||||
transcriptDm: config.transcriptDm,
|
transcriptDm: config.transcriptDm,
|
||||||
inactivityHours: config.inactivityHours,
|
inactivityHours: config.inactivityHours,
|
||||||
ratingEnabled: config.ratingEnabled
|
ratingEnabled: config.ratingEnabled
|
||||||
@@ -30,22 +42,86 @@ export async function getTicketConfigDashboard(guildId: string): Promise<TicketC
|
|||||||
return toConfigDashboard(config);
|
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(
|
export async function updateTicketConfigDashboard(
|
||||||
guildId: string,
|
guildId: string,
|
||||||
patch: TicketConfigDashboardPatch
|
patch: TicketConfigDashboardPatch
|
||||||
): Promise<TicketConfigDashboard> {
|
): Promise<TicketConfigDashboard> {
|
||||||
await ensureTicketConfig(guildId);
|
await ensureTicketConfig(guildId);
|
||||||
|
|
||||||
const { categoryId, logChannelId, ...rest } = patch;
|
const { categoryId, logChannelId, panelChannelId, ...rest } = patch;
|
||||||
const updated = await prisma.ticketConfig.update({
|
const updated = await prisma.ticketConfig.update({
|
||||||
where: { guildId },
|
where: { guildId },
|
||||||
data: {
|
data: {
|
||||||
...rest,
|
...rest,
|
||||||
...(categoryId !== undefined ? { categoryId: categoryId === '' ? null : categoryId } : {}),
|
...(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 {
|
function toCategoryDashboard(category: TicketCategory): TicketCategoryDashboard {
|
||||||
@@ -87,6 +163,7 @@ export async function createTicketCategory(
|
|||||||
supportRoleIds: input.supportRoleIds
|
supportRoleIds: input.supportRoleIds
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
await maybeSyncTicketPanel(guildId);
|
||||||
return toCategoryDashboard(category);
|
return toCategoryDashboard(category);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error && typeof error === 'object' && 'code' in error && error.code === 'P2002') {
|
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 } : {})
|
...(patch.supportRoleIds !== undefined ? { supportRoleIds: patch.supportRoleIds } : {})
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
await maybeSyncTicketPanel(guildId);
|
||||||
return toCategoryDashboard(updated);
|
return toCategoryDashboard(updated);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error && typeof error === 'object' && 'code' in error && error.code === 'P2002') {
|
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> {
|
export async function deleteTicketCategory(guildId: string, categoryId: string): Promise<boolean> {
|
||||||
const result = await prisma.ticketCategory.deleteMany({ where: { id: categoryId, guildId } });
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const verificationQueueName = 'verification';
|
|||||||
export const automodQueueName = 'automod';
|
export const automodQueueName = 'automod';
|
||||||
export const presenceQueueName = 'presence';
|
export const presenceQueueName = 'presence';
|
||||||
export const selfrolesQueueName = 'selfroles';
|
export const selfrolesQueueName = 'selfroles';
|
||||||
|
export const ticketsQueueName = 'tickets';
|
||||||
|
|
||||||
const globalForQueues = globalThis as unknown as {
|
const globalForQueues = globalThis as unknown as {
|
||||||
__nexumiGiveawayQueue?: Queue;
|
__nexumiGiveawayQueue?: Queue;
|
||||||
@@ -34,12 +35,14 @@ const globalForQueues = globalThis as unknown as {
|
|||||||
__nexumiAutomodQueue?: Queue;
|
__nexumiAutomodQueue?: Queue;
|
||||||
__nexumiPresenceQueue?: Queue;
|
__nexumiPresenceQueue?: Queue;
|
||||||
__nexumiSelfrolesQueue?: Queue;
|
__nexumiSelfrolesQueue?: Queue;
|
||||||
|
__nexumiTicketsQueue?: Queue;
|
||||||
__nexumiGiveawayQueueEvents?: QueueEvents;
|
__nexumiGiveawayQueueEvents?: QueueEvents;
|
||||||
__nexumiGuildBackupQueueEvents?: QueueEvents;
|
__nexumiGuildBackupQueueEvents?: QueueEvents;
|
||||||
__nexumiSuggestionsQueueEvents?: QueueEvents;
|
__nexumiSuggestionsQueueEvents?: QueueEvents;
|
||||||
__nexumiMessagesQueueEvents?: QueueEvents;
|
__nexumiMessagesQueueEvents?: QueueEvents;
|
||||||
__nexumiSelfrolesQueueEvents?: QueueEvents;
|
__nexumiSelfrolesQueueEvents?: QueueEvents;
|
||||||
__nexumiVerificationQueueEvents?: QueueEvents;
|
__nexumiVerificationQueueEvents?: QueueEvents;
|
||||||
|
__nexumiTicketsQueueEvents?: QueueEvents;
|
||||||
};
|
};
|
||||||
|
|
||||||
function queueConnection() {
|
function queueConnection() {
|
||||||
@@ -114,6 +117,13 @@ export function getSelfrolesQueue(): Queue {
|
|||||||
return globalForQueues.__nexumiSelfrolesQueue;
|
return globalForQueues.__nexumiSelfrolesQueue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getTicketsQueue(): Queue {
|
||||||
|
globalForQueues.__nexumiTicketsQueue ??= new Queue(ticketsQueueName, {
|
||||||
|
connection: queueConnection()
|
||||||
|
});
|
||||||
|
return globalForQueues.__nexumiTicketsQueue;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* QueueEvents instances are only needed for jobs where the dashboard needs
|
* QueueEvents instances are only needed for jobs where the dashboard needs
|
||||||
* to wait for the bot to finish (e.g. ending a giveaway so the winners list
|
* to wait for the bot to finish (e.g. ending a giveaway so the winners list
|
||||||
@@ -161,6 +171,13 @@ export function getVerificationQueueEvents(): QueueEvents {
|
|||||||
return globalForQueues.__nexumiVerificationQueueEvents;
|
return globalForQueues.__nexumiVerificationQueueEvents;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getTicketsQueueEvents(): QueueEvents {
|
||||||
|
globalForQueues.__nexumiTicketsQueueEvents ??= new QueueEvents(ticketsQueueName, {
|
||||||
|
connection: queueEventsConnection()
|
||||||
|
});
|
||||||
|
return globalForQueues.__nexumiTicketsQueueEvents;
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_WAIT_TIMEOUT_MS = 15_000;
|
const DEFAULT_WAIT_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -668,29 +668,36 @@
|
|||||||
},
|
},
|
||||||
"tickets": {
|
"tickets": {
|
||||||
"title": "Tickets",
|
"title": "Tickets",
|
||||||
"description": "Lege fest, wie Support-Tickets erstellt und verwaltet werden.",
|
"description": "Lege fest, wie Support-Tickets erstellt werden. Beim Speichern (aktiviert, mit Panel-Kanal und mindestens einer Kategorie) wird das Ticket-Panel im gewählten Kanal gepostet bzw. aktualisiert.",
|
||||||
"enabledLabel": "Ticket-Modul aktiviert",
|
"enabledLabel": "Ticket-Modul aktiviert",
|
||||||
"mode": "Ticket-Modus",
|
"mode": "Ticket-Modus",
|
||||||
"modes": {
|
"modes": {
|
||||||
"CHANNEL": "Privater Kanal",
|
"CHANNEL": "Privater Kanal",
|
||||||
"THREAD": "Privater Thread"
|
"THREAD": "Privater Thread"
|
||||||
},
|
},
|
||||||
"categoryId": "Ticket-Kategorie-ID",
|
"panelChannelId": "Panel-Kanal",
|
||||||
|
"panelChannelHint": "Kanal, in dem das Ticket-Panel mit Kategorie-Buttons erscheint.",
|
||||||
|
"categoryId": "Discord-Kategorie für Ticket-Kanäle",
|
||||||
"logChannelId": "Log-Kanal",
|
"logChannelId": "Log-Kanal",
|
||||||
"inactivityHours": "Automatisch schließen nach Inaktivität (Stunden)",
|
"inactivityHours": "Automatisch schließen nach Inaktivität (Stunden)",
|
||||||
"transcriptDm": "Transkript per DM an Ticket-Ersteller",
|
"transcriptDm": "Transkript per DM an Ticket-Ersteller",
|
||||||
"transcriptDmHint": "Sendet dem Nutzer nach Schließung ein Transkript per DM.",
|
"transcriptDmHint": "Sendet dem Nutzer nach Schließung ein Transkript per DM.",
|
||||||
"ratingEnabled": "Nach Schließung um Bewertung bitten",
|
"ratingEnabled": "Nach Schließung um Bewertung bitten",
|
||||||
"categoriesTitle": "Ticket-Kategorien",
|
"categoriesTitle": "Ticket-Kategorien",
|
||||||
"categoriesDescription": "Kategorien, die Mitglieder beim Öffnen eines Tickets auswählen können.",
|
"categoriesDescription": "Kategorien, die Mitglieder beim Öffnen eines Tickets auswählen können. Änderungen aktualisieren ein vorhandenes Panel.",
|
||||||
"categoriesEmpty": "Noch keine Ticket-Kategorien konfiguriert.",
|
"categoriesEmpty": "Noch keine Ticket-Kategorien konfiguriert.",
|
||||||
"categoryName": "Name",
|
"categoryName": "Name",
|
||||||
"categoryEmoji": "Emoji",
|
"categoryEmoji": "Emoji",
|
||||||
"categoryDescription": "Beschreibung",
|
"categoryDescription": "Beschreibung",
|
||||||
"supportRoleIds": "Support-Rollen-IDs",
|
"supportRoleIds": "Support-Rollen",
|
||||||
"idsPlaceholder": "Eine oder mehrere IDs, kommagetrennt",
|
"idsPlaceholder": "Eine oder mehrere IDs, kommagetrennt",
|
||||||
"addCategory": "Kategorie hinzufügen",
|
"addCategory": "Kategorie hinzufügen",
|
||||||
"nameRequired": "Ein Name ist erforderlich."
|
"nameRequired": "Ein Name ist erforderlich.",
|
||||||
|
"errors": {
|
||||||
|
"panelTimeout": "Der Bot hat das Ticket-Panel nicht rechtzeitig bestätigt. Es wird möglicherweise noch gepostet — prüfe den Kanal und die Bot-Berechtigungen, dann speichere erneut.",
|
||||||
|
"panelNotPosted": "Das Ticket-Panel konnte nicht im Discord-Kanal erstellt werden. Prüfe Bot-Berechtigungen (Kanal sehen, Nachrichten senden, Embeds).",
|
||||||
|
"noCategories": "Lege mindestens eine Ticket-Kategorie an, bevor das Panel gepostet werden kann."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"giveaways": {
|
"giveaways": {
|
||||||
"formIncomplete": "Bitte Kanal, Preis und Enddatum ausfüllen.",
|
"formIncomplete": "Bitte Kanal, Preis und Enddatum ausfüllen.",
|
||||||
|
|||||||
@@ -668,29 +668,36 @@
|
|||||||
},
|
},
|
||||||
"tickets": {
|
"tickets": {
|
||||||
"title": "Tickets",
|
"title": "Tickets",
|
||||||
"description": "Configure how support tickets are created and managed.",
|
"description": "Configure how support tickets are created. Saving (enabled, with panel channel and at least one category) posts or updates the ticket panel in the selected channel.",
|
||||||
"enabledLabel": "Tickets module enabled",
|
"enabledLabel": "Tickets module enabled",
|
||||||
"mode": "Ticket mode",
|
"mode": "Ticket mode",
|
||||||
"modes": {
|
"modes": {
|
||||||
"CHANNEL": "Private channel",
|
"CHANNEL": "Private channel",
|
||||||
"THREAD": "Private thread"
|
"THREAD": "Private thread"
|
||||||
},
|
},
|
||||||
"categoryId": "Ticket category ID",
|
"panelChannelId": "Panel channel",
|
||||||
|
"panelChannelHint": "Channel where the ticket panel with category buttons is posted.",
|
||||||
|
"categoryId": "Discord category for ticket channels",
|
||||||
"logChannelId": "Log channel",
|
"logChannelId": "Log channel",
|
||||||
"inactivityHours": "Auto-close after inactivity (hours)",
|
"inactivityHours": "Auto-close after inactivity (hours)",
|
||||||
"transcriptDm": "DM transcript to ticket creator",
|
"transcriptDm": "DM transcript to ticket creator",
|
||||||
"transcriptDmHint": "Sends a transcript to the user via DM once their ticket is closed.",
|
"transcriptDmHint": "Sends a transcript to the user via DM once their ticket is closed.",
|
||||||
"ratingEnabled": "Ask for a rating after closing",
|
"ratingEnabled": "Ask for a rating after closing",
|
||||||
"categoriesTitle": "Ticket categories",
|
"categoriesTitle": "Ticket categories",
|
||||||
"categoriesDescription": "Categories members can choose from when opening a ticket.",
|
"categoriesDescription": "Categories members can choose from when opening a ticket. Changes update an existing panel.",
|
||||||
"categoriesEmpty": "No ticket categories configured yet.",
|
"categoriesEmpty": "No ticket categories configured yet.",
|
||||||
"categoryName": "Name",
|
"categoryName": "Name",
|
||||||
"categoryEmoji": "Emoji",
|
"categoryEmoji": "Emoji",
|
||||||
"categoryDescription": "Description",
|
"categoryDescription": "Description",
|
||||||
"supportRoleIds": "Support role IDs",
|
"supportRoleIds": "Support roles",
|
||||||
"idsPlaceholder": "One or more IDs, comma-separated",
|
"idsPlaceholder": "One or more IDs, comma-separated",
|
||||||
"addCategory": "Add category",
|
"addCategory": "Add category",
|
||||||
"nameRequired": "A name is required."
|
"nameRequired": "A name is required.",
|
||||||
|
"errors": {
|
||||||
|
"panelTimeout": "The bot did not confirm the ticket panel in time. It may still be posting — check the channel and bot permissions, then save again.",
|
||||||
|
"panelNotPosted": "The ticket panel could not be created in the Discord channel. Check bot permissions (View Channel, Send Messages, Embed Links).",
|
||||||
|
"noCategories": "Create at least one ticket category before the panel can be posted."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"giveaways": {
|
"giveaways": {
|
||||||
"formIncomplete": "Please fill in the channel, prize and end date.",
|
"formIncomplete": "Please fill in the channel, prize and end date.",
|
||||||
|
|||||||
@@ -481,3 +481,21 @@
|
|||||||
- [ ] Speichern mit geändertem Modus → bestehendes Panel wird aktualisiert
|
- [ ] Speichern mit geändertem Modus → bestehendes Panel wird aktualisiert
|
||||||
- [ ] Kanal wechseln und speichern → altes Panel weg, neues im Zielkanal
|
- [ ] Kanal wechseln und speichern → altes Panel weg, neues im Zielkanal
|
||||||
- [ ] Bot ohne Send-Permission → Fehlermeldung; Config bleibt gespeichert, erneutes Speichern nach Fix
|
- [ ] Bot ohne Send-Permission → Fehlermeldung; Config bleibt gespeichert, erneutes Speichern nach Fix
|
||||||
|
|
||||||
|
## Post-Phase – Ticket Panel aus WebUI + Kategorie-Emoji-Picker (Status: implementiert)
|
||||||
|
|
||||||
|
### Abgeschlossen (Code)
|
||||||
|
|
||||||
|
- Root cause: Dashboard speicherte Ticket-Config/Kategorien ohne Panel in Discord
|
||||||
|
- Prisma `TicketConfig.panelChannelId` / `panelMessageId`; Migration `20260725160000_ticket_panel`
|
||||||
|
- BullMQ-Job `ticketPanelSync` (Queue `tickets`); Sync auch bei Kategorie create/update/delete
|
||||||
|
- `syncTicketPanel` shared für `/ticket panel_create` und Dashboard; Custom-Emoji-Parsing für Buttons/Select
|
||||||
|
- WebUI: Panel-Kanal-Picker; `DiscordEmojiSelect` in Kategorie-Erstellung und -Bearbeitung
|
||||||
|
- Emoji-Zod max 80 (Custom-Emoji-Format)
|
||||||
|
|
||||||
|
### Manuell testen
|
||||||
|
|
||||||
|
- [ ] WebUI: Kategorie(n) anlegen, Panel-Kanal setzen, Speichern → Panel mit Buttons im Kanal
|
||||||
|
- [ ] Kategorie mit Emoji-Picker (Unicode + Server-Emoji) speichern → Button zeigt Emoji
|
||||||
|
- [ ] Kategorie ändern/löschen → Panel-Buttons aktualisieren sich
|
||||||
|
- [ ] Panel-Kanal wechseln → altes Panel weg, neues im Zielkanal
|
||||||
|
|||||||
@@ -385,6 +385,7 @@ export const TicketConfigDashboardSchema = z.object({
|
|||||||
mode: TicketModeSchema,
|
mode: TicketModeSchema,
|
||||||
categoryId: OptionalSnowflakeSchema,
|
categoryId: OptionalSnowflakeSchema,
|
||||||
logChannelId: OptionalSnowflakeSchema,
|
logChannelId: OptionalSnowflakeSchema,
|
||||||
|
panelChannelId: OptionalSnowflakeSchema,
|
||||||
transcriptDm: z.boolean(),
|
transcriptDm: z.boolean(),
|
||||||
inactivityHours: z.number().int().positive(),
|
inactivityHours: z.number().int().positive(),
|
||||||
ratingEnabled: z.boolean()
|
ratingEnabled: z.boolean()
|
||||||
@@ -398,7 +399,7 @@ export const TicketCategoryDashboardSchema = z.object({
|
|||||||
id: z.string().optional(),
|
id: z.string().optional(),
|
||||||
name: z.string().min(1).max(100),
|
name: z.string().min(1).max(100),
|
||||||
description: z.string().max(500).nullable().optional(),
|
description: z.string().max(500).nullable().optional(),
|
||||||
emoji: z.string().max(32).nullable().optional(),
|
emoji: z.string().max(80).nullable().optional(),
|
||||||
supportRoleIds: z.array(z.string())
|
supportRoleIds: z.array(z.string())
|
||||||
});
|
});
|
||||||
export type TicketCategoryDashboard = z.infer<typeof TicketCategoryDashboardSchema>;
|
export type TicketCategoryDashboard = z.infer<typeof TicketCategoryDashboardSchema>;
|
||||||
|
|||||||
@@ -415,6 +415,7 @@ const de: Dictionary = {
|
|||||||
'giveaway.error.invalid_duration': 'Ungültige Dauer. Nutze z. B. 1h, 2d.',
|
'giveaway.error.invalid_duration': 'Ungültige Dauer. Nutze z. B. 1h, 2d.',
|
||||||
'giveaway.error.duration_too_short': 'Die Dauer muss mindestens 1 Minute betragen.',
|
'giveaway.error.duration_too_short': 'Die Dauer muss mindestens 1 Minute betragen.',
|
||||||
'giveaway.error.invalid_channel': 'Ungültiger Kanal.',
|
'giveaway.error.invalid_channel': 'Ungültiger Kanal.',
|
||||||
|
'ticket.panel.defaultTitle': 'Support-Tickets',
|
||||||
'ticket.panel.defaultDescription': 'Wähle eine Kategorie, um ein Ticket zu öffnen.',
|
'ticket.panel.defaultDescription': 'Wähle eine Kategorie, um ein Ticket zu öffnen.',
|
||||||
'ticket.panel.created': 'Ticket-Panel erstellt.',
|
'ticket.panel.created': 'Ticket-Panel erstellt.',
|
||||||
'ticket.category.created': 'Kategorie **{name}** erstellt.',
|
'ticket.category.created': 'Kategorie **{name}** erstellt.',
|
||||||
@@ -458,6 +459,8 @@ const de: Dictionary = {
|
|||||||
'ticket.error.no_channel': 'Ticket-Kanal nicht gefunden.',
|
'ticket.error.no_channel': 'Ticket-Kanal nicht gefunden.',
|
||||||
'ticket.error.invalid_name': 'Ungültiger Name.',
|
'ticket.error.invalid_name': 'Ungültiger Name.',
|
||||||
'ticket.error.invalid_channel': 'Ungültiger Kanal.',
|
'ticket.error.invalid_channel': 'Ungültiger Kanal.',
|
||||||
|
'ticket.error.missing_permission':
|
||||||
|
'Mir fehlen Berechtigungen, um das Ticket-Panel in diesem Kanal zu posten (Kanal sehen, Nachrichten senden, Embeds).',
|
||||||
'ticket.error.no_parent_channel': 'Kein Elternkanal für Thread-Tickets konfiguriert.',
|
'ticket.error.no_parent_channel': 'Kein Elternkanal für Thread-Tickets konfiguriert.',
|
||||||
'ticket.error.invalid_parent_channel': 'Der konfigurierte Elternkanal ist ungültig.',
|
'ticket.error.invalid_parent_channel': 'Der konfigurierte Elternkanal ist ungültig.',
|
||||||
'ticket.error.not_found': 'Ticket nicht gefunden.',
|
'ticket.error.not_found': 'Ticket nicht gefunden.',
|
||||||
@@ -1010,6 +1013,7 @@ const en: Dictionary = {
|
|||||||
'giveaway.error.invalid_duration': 'Invalid duration. Use e.g. 1h, 2d.',
|
'giveaway.error.invalid_duration': 'Invalid duration. Use e.g. 1h, 2d.',
|
||||||
'giveaway.error.duration_too_short': 'Duration must be at least 1 minute.',
|
'giveaway.error.duration_too_short': 'Duration must be at least 1 minute.',
|
||||||
'giveaway.error.invalid_channel': 'Invalid channel.',
|
'giveaway.error.invalid_channel': 'Invalid channel.',
|
||||||
|
'ticket.panel.defaultTitle': 'Support Tickets',
|
||||||
'ticket.panel.defaultDescription': 'Select a category to open a ticket.',
|
'ticket.panel.defaultDescription': 'Select a category to open a ticket.',
|
||||||
'ticket.panel.created': 'Ticket panel created.',
|
'ticket.panel.created': 'Ticket panel created.',
|
||||||
'ticket.category.created': 'Category **{name}** created.',
|
'ticket.category.created': 'Category **{name}** created.',
|
||||||
@@ -1053,6 +1057,8 @@ const en: Dictionary = {
|
|||||||
'ticket.error.no_channel': 'Ticket channel not found.',
|
'ticket.error.no_channel': 'Ticket channel not found.',
|
||||||
'ticket.error.invalid_name': 'Invalid name.',
|
'ticket.error.invalid_name': 'Invalid name.',
|
||||||
'ticket.error.invalid_channel': 'Invalid channel.',
|
'ticket.error.invalid_channel': 'Invalid channel.',
|
||||||
|
'ticket.error.missing_permission':
|
||||||
|
'I am missing permissions to post the ticket panel in this channel (View Channel, Send Messages, Embed Links).',
|
||||||
'ticket.error.no_parent_channel': 'No parent channel configured for thread tickets.',
|
'ticket.error.no_parent_channel': 'No parent channel configured for thread tickets.',
|
||||||
'ticket.error.invalid_parent_channel': 'The configured parent channel is invalid.',
|
'ticket.error.invalid_parent_channel': 'The configured parent channel is invalid.',
|
||||||
'ticket.error.not_found': 'Ticket not found.',
|
'ticket.error.not_found': 'Ticket not found.',
|
||||||
|
|||||||
Reference in New Issue
Block a user