From 1eec10ba807b8c178c572dcad963d871ed3668fd Mon Sep 17 00:00:00 2001 From: TheOnlyMace <0815cracky@gmail.com> Date: Sat, 25 Jul 2026 16:06:17 +0200 Subject: [PATCH] Implement ticket panel synchronization and configuration updates - Added `panelChannelId` and `panelMessageId` fields to the `TicketConfig` model for managing ticket panel settings. - Introduced `syncTicketPanel` function to handle posting and updating the ticket panel in Discord channels via BullMQ. - Enhanced error handling for ticket panel synchronization, including specific error messages for missing categories and posting failures. - Updated WebUI components to support panel channel selection and improved error messaging for synchronization issues. - Added localization entries for new error messages related to ticket panel synchronization in both English and German. - Refactored ticket category management to trigger panel synchronization upon category creation, update, or deletion. --- .../20260725160000_ticket_panel/migration.sql | 3 + apps/bot/prisma/schema.prisma | 2 + apps/bot/src/jobs.ts | 5 +- apps/bot/src/modules/tickets/commands.ts | 28 ++- apps/bot/src/modules/tickets/index.ts | 7 +- apps/bot/src/modules/tickets/service.ts | 169 +++++++++++++++++- .../tickets/categories/[categoryId]/route.ts | 13 ++ .../[guildId]/tickets/categories/route.ts | 8 +- .../app/api/guilds/[guildId]/tickets/route.ts | 12 +- .../modules/ticket-categories-manager.tsx | 50 ++++-- .../components/modules/ticket-config-form.tsx | 48 ++++- apps/webui/src/lib/dashboard-search-index.ts | 7 + apps/webui/src/lib/module-configs/tickets.ts | 93 +++++++++- apps/webui/src/lib/queues.ts | 17 ++ apps/webui/src/messages/de.json | 17 +- apps/webui/src/messages/en.json | 17 +- docs/PHASE-TRACKING.md | 18 ++ packages/shared/src/dashboard.ts | 3 +- packages/shared/src/index.ts | 6 + 19 files changed, 476 insertions(+), 47 deletions(-) create mode 100644 apps/bot/prisma/migrations/20260725160000_ticket_panel/migration.sql diff --git a/apps/bot/prisma/migrations/20260725160000_ticket_panel/migration.sql b/apps/bot/prisma/migrations/20260725160000_ticket_panel/migration.sql new file mode 100644 index 0000000..ea1a8ca --- /dev/null +++ b/apps/bot/prisma/migrations/20260725160000_ticket_panel/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "TicketConfig" ADD COLUMN "panelChannelId" TEXT; +ALTER TABLE "TicketConfig" ADD COLUMN "panelMessageId" TEXT; diff --git a/apps/bot/prisma/schema.prisma b/apps/bot/prisma/schema.prisma index 73a5f1d..c685304 100644 --- a/apps/bot/prisma/schema.prisma +++ b/apps/bot/prisma/schema.prisma @@ -513,6 +513,8 @@ model TicketConfig { mode String @default("CHANNEL") categoryId String? logChannelId String? + panelChannelId String? + panelMessageId String? transcriptDm Boolean @default(false) inactivityHours Int @default(72) ratingEnabled Boolean @default(true) diff --git a/apps/bot/src/jobs.ts b/apps/bot/src/jobs.ts index 2c88d2a..4821ebf 100644 --- a/apps/bot/src/jobs.ts +++ b/apps/bot/src/jobs.ts @@ -29,7 +29,7 @@ import { runGiveawayDeleteJob, GiveawayError } 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 { ensureStatsRecurringJobs, startStatsWorker } from './modules/stats/index.js'; import { pollAllFeeds } from './modules/feeds/index.js'; @@ -348,6 +348,9 @@ export function startWorkers(context: BotContext): Worker[] { const ticketWorker = new Worker( ticketQueueName, async (job) => { + if (job.name === 'ticketPanelSync') { + return runTicketPanelSyncJob(context, job.data as { guildId: string }); + } if (job.name === 'selfRoleExpire') { const data = job.data as SelfRoleExpireJob; await expireSelfRole(context, data.guildId, data.userId, data.roleId); diff --git a/apps/bot/src/modules/tickets/commands.ts b/apps/bot/src/modules/tickets/commands.ts index 2b87054..d17c9de 100644 --- a/apps/bot/src/modules/tickets/commands.ts +++ b/apps/bot/src/modules/tickets/commands.ts @@ -7,8 +7,6 @@ import { ticketCommandData } from './command-definitions.js'; import { addUserToTicket, assertTicketStaff, - buildPanelComponents, - buildPanelEmbed, claimTicket, closeTicket, createCategoryWithRole, @@ -17,7 +15,9 @@ import { removeUserFromTicket, renameTicket, setTicketPriority, + syncTicketPanel, TicketError, + TicketPanelError, upsertCategoryByName } from './service.js'; @@ -67,10 +67,26 @@ const ticketCommand: SlashCommand = { return; } - const embed = buildPanelEmbed(locale, name, description); - const components = buildPanelComponents(categories); - await channel.send({ embeds: [embed], components }); - await interaction.reply({ content: t(locale, 'ticket.panel.created'), ephemeral: true }); + try { + await syncTicketPanel(context, interaction.guildId!, { + channelId: channel.id, + title: name, + description + }); + 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; } diff --git a/apps/bot/src/modules/tickets/index.ts b/apps/bot/src/modules/tickets/index.ts index 3464c30..486c97e 100644 --- a/apps/bot/src/modules/tickets/index.ts +++ b/apps/bot/src/modules/tickets/index.ts @@ -1,4 +1,9 @@ export { ticketsCommands } from './commands.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'; diff --git a/apps/bot/src/modules/tickets/service.ts b/apps/bot/src/modules/tickets/service.ts index 8adef4c..4d5b695 100644 --- a/apps/bot/src/modules/tickets/service.ts +++ b/apps/bot/src/modules/tickets/service.ts @@ -10,8 +10,11 @@ import { StringSelectMenuBuilder, TextInputBuilder, TextInputStyle, + type APIMessageComponentEmoji, type Guild, + type GuildBasedChannel, type GuildMember, + type GuildTextBasedChannel, type Message, type SendableChannels, 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( context: BotContext, channelId: string @@ -185,8 +234,9 @@ export function buildPanelComponents(categories: TicketCategory[]): ActionRowBui .setCustomId(openButtonId(category.id)) .setLabel(category.name.slice(0, 80)) .setStyle(ButtonStyle.Primary); - if (category.emoji) { - button.setEmoji(category.emoji); + const emoji = parseButtonEmoji(category.emoji); + if (emoji) { + button.setEmoji(emoji); } row.addComponents(button); } @@ -197,17 +247,120 @@ export function buildPanelComponents(categories: TicketCategory[]): ActionRowBui .setCustomId(TICKET_SELECT_ID) .setPlaceholder('Select a category') .addOptions( - categories.slice(0, 25).map((category) => ({ - label: category.name.slice(0, 100), - value: category.id, - description: category.description?.slice(0, 100) ?? undefined, - emoji: category.emoji ? { name: category.emoji } : undefined - })) + categories.slice(0, 25).map((category) => { + const emoji = parseButtonEmoji(category.emoji); + return { + label: category.name.slice(0, 100), + value: category.id, + description: category.description?.slice(0, 100) ?? undefined, + emoji + }; + }) ); return [new ActionRowBuilder().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 { const fields = parseFormFields(category.formFields); const modal = new ModalBuilder() diff --git a/apps/webui/src/app/api/guilds/[guildId]/tickets/categories/[categoryId]/route.ts b/apps/webui/src/app/api/guilds/[guildId]/tickets/categories/[categoryId]/route.ts index 059842a..522535a 100644 --- a/apps/webui/src/app/api/guilds/[guildId]/tickets/categories/[categoryId]/route.ts +++ b/apps/webui/src/app/api/guilds/[guildId]/tickets/categories/[categoryId]/route.ts @@ -5,6 +5,7 @@ import { writeDashboardAudit } from '@/lib/audit'; import { deleteTicketCategory, TicketCategoryConflictError, + TicketPanelSyncError, updateTicketCategory } from '@/lib/module-configs/tickets'; import { prisma } from '@/lib/prisma'; @@ -38,6 +39,12 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { if (error instanceof TicketCategoryConflictError) { 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); } } @@ -61,6 +68,12 @@ export async function DELETE(_request: NextRequest, { params }: RouteParams) { return NextResponse.json({ ok: true }); } catch (error) { + if (error instanceof TicketPanelSyncError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: error.code === 'timeout' ? 504 : 502 } + ); + } return toApiErrorResponse(error); } } diff --git a/apps/webui/src/app/api/guilds/[guildId]/tickets/categories/route.ts b/apps/webui/src/app/api/guilds/[guildId]/tickets/categories/route.ts index 97d44f3..9edd0e5 100644 --- a/apps/webui/src/app/api/guilds/[guildId]/tickets/categories/route.ts +++ b/apps/webui/src/app/api/guilds/[guildId]/tickets/categories/route.ts @@ -2,7 +2,7 @@ import { TicketCategoryDashboardCreateSchema } from '@nexumi/shared'; import { type NextRequest, NextResponse } from 'next/server'; import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; 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'; interface RouteParams { @@ -42,6 +42,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) { if (error instanceof TicketCategoryConflictError) { 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); } } diff --git a/apps/webui/src/app/api/guilds/[guildId]/tickets/route.ts b/apps/webui/src/app/api/guilds/[guildId]/tickets/route.ts index 9c6a074..dc5d39c 100644 --- a/apps/webui/src/app/api/guilds/[guildId]/tickets/route.ts +++ b/apps/webui/src/app/api/guilds/[guildId]/tickets/route.ts @@ -2,7 +2,11 @@ import { TicketConfigDashboardPatchSchema } from '@nexumi/shared'; import { type NextRequest, NextResponse } from 'next/server'; import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; 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'; interface RouteParams { @@ -41,6 +45,12 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { return NextResponse.json(after); } catch (error) { + if (error instanceof TicketPanelSyncError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: error.code === 'timeout' ? 504 : 502 } + ); + } return toApiErrorResponse(error); } } diff --git a/apps/webui/src/components/modules/ticket-categories-manager.tsx b/apps/webui/src/components/modules/ticket-categories-manager.tsx index 9f66474..735d1e8 100644 --- a/apps/webui/src/components/modules/ticket-categories-manager.tsx +++ b/apps/webui/src/components/modules/ticket-categories-manager.tsx @@ -8,6 +8,7 @@ import { FieldAnchor } from '@/components/layout/field-anchor'; import { useTranslations } from '@/components/locale-provider'; import { Button } from '@/components/ui/button'; 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 { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -24,6 +25,23 @@ interface TicketCategoriesManagerProps { 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) { const t = useTranslations(); const [categories, setCategories] = useState( @@ -49,9 +67,11 @@ export function TicketCategoriesManager({ guildId, initialCategories }: TicketCa 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) { - toast.error(body?.error ?? t('common.saveError')); + toast.error(panelSyncErrorMessage(body?.code, body?.error, t)); return; } setCategories((prev) => [...prev, { ...body, clientKey: body.id ?? `new-${Date.now()}` }]); @@ -79,9 +99,11 @@ export function TicketCategoriesManager({ guildId, initialCategories }: TicketCa 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) { - toast.error(body?.error ?? t('common.saveError')); + toast.error(panelSyncErrorMessage(body?.code, body?.error, t)); return; } toast.success(t('common.saveSuccess')); @@ -100,7 +122,8 @@ export function TicketCategoriesManager({ guildId, initialCategories }: TicketCa method: 'DELETE' }); 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; } setCategories((prev) => prev.filter((entry) => entry.clientKey !== category.clientKey)); @@ -139,12 +162,15 @@ export function TicketCategoriesManager({ guildId, initialCategories }: TicketCa
- + setCategories((prev) => 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
- setDraft((prev) => ({ ...prev, emoji: event.target.value }))} /> + setDraft((prev) => ({ ...prev, emoji: emoji ?? '' }))} + />
diff --git a/apps/webui/src/components/modules/ticket-config-form.tsx b/apps/webui/src/components/modules/ticket-config-form.tsx index caf574e..4179aa5 100644 --- a/apps/webui/src/components/modules/ticket-config-form.tsx +++ b/apps/webui/src/components/modules/ticket-config-form.tsx @@ -13,7 +13,16 @@ import { Switch } from '@/components/ui/switch'; import type { SettingsSaveResult } from '@/components/settings/settings-form'; import { SettingsForm } from '@/components/settings/settings-form'; -async function saveTicketConfig(guildId: string, value: TicketConfigDashboard): Promise { +async function saveTicketConfig( + guildId: string, + value: TicketConfigDashboard, + errorMessages: { + timeout: string; + notPosted: string; + noCategories: string; + network: string; + } +): Promise { try { const response = await fetch(`/api/guilds/${guildId}/tickets`, { method: 'PATCH', @@ -21,12 +30,24 @@ async function saveTicketConfig(guildId: string, value: TicketConfigDashboard): body: JSON.stringify(value) }); 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: true }; } catch { - return { ok: false, error: 'Network error' }; + return { ok: false, error: errorMessages.network }; } } @@ -42,7 +63,14 @@ export function TicketConfigForm({ guildId, initialValue }: TicketConfigFormProp return ( 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 }) => ( @@ -77,6 +105,18 @@ export function TicketConfigForm({ guildId, initialValue }: TicketConfigFormProp
+ +
+ + setValue((prev) => ({ ...prev, panelChannelId }))} + /> +

{t('modulePages.tickets.panelChannelHint')}

+
+
+
diff --git a/apps/webui/src/lib/dashboard-search-index.ts b/apps/webui/src/lib/dashboard-search-index.ts index 55a10de..2c739db 100644 --- a/apps/webui/src/lib/dashboard-search-index.ts +++ b/apps/webui/src/lib/dashboard-search-index.ts @@ -623,6 +623,13 @@ export const SETTINGS_SEARCH_FIELDS: SearchIndexEntry[] = [ href: 'tickets', 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', kind: 'setting', diff --git a/apps/webui/src/lib/module-configs/tickets.ts b/apps/webui/src/lib/module-configs/tickets.ts index a27ca5f..1db4f4b 100644 --- a/apps/webui/src/lib/module-configs/tickets.ts +++ b/apps/webui/src/lib/module-configs/tickets.ts @@ -7,6 +7,17 @@ import type { } from '@nexumi/shared'; import type { 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'; + } +} async function ensureTicketConfig(guildId: string) { await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } }); @@ -19,6 +30,7 @@ function toConfigDashboard(config: Awaited mode: config.mode as TicketConfigDashboard['mode'], categoryId: config.categoryId ?? '', logChannelId: config.logChannelId ?? '', + panelChannelId: config.panelChannelId ?? '', transcriptDm: config.transcriptDm, inactivityHours: config.inactivityHours, ratingEnabled: config.ratingEnabled @@ -30,22 +42,86 @@ export async function getTicketConfigDashboard(guildId: string): Promise { + 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 { + const config = await prisma.ticketConfig.findUnique({ where: { guildId } }); + if (!config?.enabled || !config.panelChannelId) { + return; + } + await syncTicketPanel(guildId); +} + export async function updateTicketConfigDashboard( guildId: string, patch: TicketConfigDashboardPatch ): Promise { await ensureTicketConfig(guildId); - const { categoryId, logChannelId, ...rest } = patch; + 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 } : {}) + ...(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 { @@ -87,6 +163,7 @@ export async function createTicketCategory( supportRoleIds: input.supportRoleIds } }); + await maybeSyncTicketPanel(guildId); return toCategoryDashboard(category); } catch (error) { 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 } : {}) } }); + await maybeSyncTicketPanel(guildId); return toCategoryDashboard(updated); } catch (error) { 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 { 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; } diff --git a/apps/webui/src/lib/queues.ts b/apps/webui/src/lib/queues.ts index 105c76d..679cb7d 100644 --- a/apps/webui/src/lib/queues.ts +++ b/apps/webui/src/lib/queues.ts @@ -23,6 +23,7 @@ export const verificationQueueName = 'verification'; export const automodQueueName = 'automod'; export const presenceQueueName = 'presence'; export const selfrolesQueueName = 'selfroles'; +export const ticketsQueueName = 'tickets'; const globalForQueues = globalThis as unknown as { __nexumiGiveawayQueue?: Queue; @@ -34,12 +35,14 @@ const globalForQueues = globalThis as unknown as { __nexumiAutomodQueue?: Queue; __nexumiPresenceQueue?: Queue; __nexumiSelfrolesQueue?: Queue; + __nexumiTicketsQueue?: Queue; __nexumiGiveawayQueueEvents?: QueueEvents; __nexumiGuildBackupQueueEvents?: QueueEvents; __nexumiSuggestionsQueueEvents?: QueueEvents; __nexumiMessagesQueueEvents?: QueueEvents; __nexumiSelfrolesQueueEvents?: QueueEvents; __nexumiVerificationQueueEvents?: QueueEvents; + __nexumiTicketsQueueEvents?: QueueEvents; }; function queueConnection() { @@ -114,6 +117,13 @@ export function getSelfrolesQueue(): Queue { 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 * 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; } +export function getTicketsQueueEvents(): QueueEvents { + globalForQueues.__nexumiTicketsQueueEvents ??= new QueueEvents(ticketsQueueName, { + connection: queueEventsConnection() + }); + return globalForQueues.__nexumiTicketsQueueEvents; +} + const DEFAULT_WAIT_TIMEOUT_MS = 15_000; /** diff --git a/apps/webui/src/messages/de.json b/apps/webui/src/messages/de.json index a730acb..ec9f867 100644 --- a/apps/webui/src/messages/de.json +++ b/apps/webui/src/messages/de.json @@ -668,29 +668,36 @@ }, "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", "mode": "Ticket-Modus", "modes": { "CHANNEL": "Privater Kanal", "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", "inactivityHours": "Automatisch schließen nach Inaktivität (Stunden)", "transcriptDm": "Transkript per DM an Ticket-Ersteller", "transcriptDmHint": "Sendet dem Nutzer nach Schließung ein Transkript per DM.", "ratingEnabled": "Nach Schließung um Bewertung bitten", "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.", "categoryName": "Name", "categoryEmoji": "Emoji", "categoryDescription": "Beschreibung", - "supportRoleIds": "Support-Rollen-IDs", + "supportRoleIds": "Support-Rollen", "idsPlaceholder": "Eine oder mehrere IDs, kommagetrennt", "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": { "formIncomplete": "Bitte Kanal, Preis und Enddatum ausfüllen.", diff --git a/apps/webui/src/messages/en.json b/apps/webui/src/messages/en.json index af9ea5f..fc46001 100644 --- a/apps/webui/src/messages/en.json +++ b/apps/webui/src/messages/en.json @@ -668,29 +668,36 @@ }, "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", "mode": "Ticket mode", "modes": { "CHANNEL": "Private channel", "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", "inactivityHours": "Auto-close after inactivity (hours)", "transcriptDm": "DM transcript to ticket creator", "transcriptDmHint": "Sends a transcript to the user via DM once their ticket is closed.", "ratingEnabled": "Ask for a rating after closing", "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.", "categoryName": "Name", "categoryEmoji": "Emoji", "categoryDescription": "Description", - "supportRoleIds": "Support role IDs", + "supportRoleIds": "Support roles", "idsPlaceholder": "One or more IDs, comma-separated", "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": { "formIncomplete": "Please fill in the channel, prize and end date.", diff --git a/docs/PHASE-TRACKING.md b/docs/PHASE-TRACKING.md index 0e7556a..0cba9ff 100644 --- a/docs/PHASE-TRACKING.md +++ b/docs/PHASE-TRACKING.md @@ -481,3 +481,21 @@ - [ ] Speichern mit geändertem Modus → bestehendes Panel wird aktualisiert - [ ] Kanal wechseln und speichern → altes Panel weg, neues im Zielkanal - [ ] 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 diff --git a/packages/shared/src/dashboard.ts b/packages/shared/src/dashboard.ts index b2607b7..914ca7c 100644 --- a/packages/shared/src/dashboard.ts +++ b/packages/shared/src/dashboard.ts @@ -385,6 +385,7 @@ export const TicketConfigDashboardSchema = z.object({ mode: TicketModeSchema, categoryId: OptionalSnowflakeSchema, logChannelId: OptionalSnowflakeSchema, + panelChannelId: OptionalSnowflakeSchema, transcriptDm: z.boolean(), inactivityHours: z.number().int().positive(), ratingEnabled: z.boolean() @@ -398,7 +399,7 @@ export const TicketCategoryDashboardSchema = z.object({ id: z.string().optional(), name: z.string().min(1).max(100), 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()) }); export type TicketCategoryDashboard = z.infer; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index da63c95..a1134f4 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -415,6 +415,7 @@ const de: Dictionary = { '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.invalid_channel': 'Ungültiger Kanal.', + 'ticket.panel.defaultTitle': 'Support-Tickets', 'ticket.panel.defaultDescription': 'Wähle eine Kategorie, um ein Ticket zu öffnen.', 'ticket.panel.created': 'Ticket-Panel erstellt.', 'ticket.category.created': 'Kategorie **{name}** erstellt.', @@ -458,6 +459,8 @@ const de: Dictionary = { 'ticket.error.no_channel': 'Ticket-Kanal nicht gefunden.', 'ticket.error.invalid_name': 'Ungültiger Name.', '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.invalid_parent_channel': 'Der konfigurierte Elternkanal ist ungültig.', '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.duration_too_short': 'Duration must be at least 1 minute.', 'giveaway.error.invalid_channel': 'Invalid channel.', + 'ticket.panel.defaultTitle': 'Support Tickets', 'ticket.panel.defaultDescription': 'Select a category to open a ticket.', 'ticket.panel.created': 'Ticket panel created.', 'ticket.category.created': 'Category **{name}** created.', @@ -1053,6 +1057,8 @@ const en: Dictionary = { 'ticket.error.no_channel': 'Ticket channel not found.', 'ticket.error.invalid_name': 'Invalid name.', '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.invalid_parent_channel': 'The configured parent channel is invalid.', 'ticket.error.not_found': 'Ticket not found.',