From 5c11b68fc0a357c2bee80d1293837fd8f0e3c10d Mon Sep 17 00:00:00 2001 From: TheOnlyMace <0815cracky@gmail.com> Date: Sat, 25 Jul 2026 16:21:51 +0200 Subject: [PATCH] Enhance ticket management with dashboard actions and priority settings - Introduced a new `runTicketDashboardActionJob` to handle ticket actions (close, claim, set priority) with Discord side effects. - Added a priority dropdown in the ticket control panel, allowing users to set ticket urgency levels. - Updated WebUI to display open tickets with options to claim, close, or adjust priority, enhancing user interaction. - Improved localization for new ticket management features in both English and German. - Documented changes in phase tracking to reflect the new ticket dashboard functionalities. --- apps/bot/src/jobs.ts | 15 +- apps/bot/src/modules/tickets/index.ts | 2 + apps/bot/src/modules/tickets/interactions.ts | 26 +- apps/bot/src/modules/tickets/service.ts | 126 +++++++++- .../tickets/open/[ticketId]/action/route.ts | 48 ++++ .../guilds/[guildId]/tickets/open/route.ts | 18 ++ .../app/dashboard/[guildId]/tickets/page.tsx | 13 +- .../modules/open-tickets-manager.tsx | 237 ++++++++++++++++++ apps/webui/src/lib/dashboard-search-index.ts | 7 + apps/webui/src/lib/module-configs/tickets.ts | 116 ++++++++- apps/webui/src/messages/de.json | 25 ++ apps/webui/src/messages/en.json | 25 ++ docs/PHASE-TRACKING.md | 17 ++ packages/shared/src/dashboard.ts | 35 ++- packages/shared/src/index.ts | 14 +- 15 files changed, 710 insertions(+), 14 deletions(-) create mode 100644 apps/webui/src/app/api/guilds/[guildId]/tickets/open/[ticketId]/action/route.ts create mode 100644 apps/webui/src/app/api/guilds/[guildId]/tickets/open/route.ts create mode 100644 apps/webui/src/components/modules/open-tickets-manager.tsx diff --git a/apps/bot/src/jobs.ts b/apps/bot/src/jobs.ts index 4821ebf..8f3525a 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, runTicketPanelSyncJob } from './modules/tickets/index.js'; +import { closeInactiveTickets, runTicketDashboardActionJob, runTicketPanelSyncJob } from './modules/tickets/index.js'; import { expireBirthdayRole, runBirthdayCheck } from './modules/birthdays/index.js'; import { ensureStatsRecurringJobs, startStatsWorker } from './modules/stats/index.js'; import { pollAllFeeds } from './modules/feeds/index.js'; @@ -351,6 +351,19 @@ export function startWorkers(context: BotContext): Worker[] { if (job.name === 'ticketPanelSync') { return runTicketPanelSyncJob(context, job.data as { guildId: string }); } + if (job.name === 'ticketDashboardAction') { + return runTicketDashboardActionJob( + context, + job.data as { + ticketId: string; + guildId: string; + actorUserId: string; + action: 'close' | 'claim' | 'priority'; + reason?: string; + priority?: string; + } + ); + } if (job.name === 'selfRoleExpire') { const data = job.data as SelfRoleExpireJob; await expireSelfRole(context, data.guildId, data.userId, data.roleId); diff --git a/apps/bot/src/modules/tickets/index.ts b/apps/bot/src/modules/tickets/index.ts index 486c97e..a69d555 100644 --- a/apps/bot/src/modules/tickets/index.ts +++ b/apps/bot/src/modules/tickets/index.ts @@ -4,6 +4,8 @@ export { closeInactiveTickets, checkInactiveTickets, runTicketPanelSyncJob, + runTicketDashboardActionJob, TicketPanelError } from './service.js'; export { registerTicketEvents } from './events.js'; + diff --git a/apps/bot/src/modules/tickets/interactions.ts b/apps/bot/src/modules/tickets/interactions.ts index effe52f..2a086d5 100644 --- a/apps/bot/src/modules/tickets/interactions.ts +++ b/apps/bot/src/modules/tickets/interactions.ts @@ -21,9 +21,11 @@ import { parseRateButton, rateTicket, removeUserFromTicket, + setTicketPriority, TICKET_CTRL_ADD, TICKET_CTRL_CLAIM, TICKET_CTRL_CLOSE, + TICKET_CTRL_PRIORITY, TICKET_CTRL_REMOVE, TICKET_MODAL_PREFIX, TICKET_OPEN_PREFIX, @@ -109,7 +111,7 @@ async function handleOpenTicketRequest( } async function handleTicketControl( - interaction: ButtonInteraction | UserSelectMenuInteraction, + interaction: ButtonInteraction | UserSelectMenuInteraction | StringSelectMenuInteraction, context: BotContext ): Promise { const locale = await getGuildLocale(context.prisma, interaction.guildId); @@ -153,6 +155,22 @@ async function handleTicketControl( return; } + if (interaction.isStringSelectMenu() && interaction.customId === TICKET_CTRL_PRIORITY) { + const level = interaction.values[0]; + if (!level) { + await interaction.reply({ content: t(locale, 'ticket.error.not_found'), ...ephemeral() }); + return; + } + await setTicketPriority(context, ticket, member, level, locale); + await interaction.reply({ + content: tf(locale, 'ticket.priority.success', { + priority: t(locale, `ticket.priority.${level}`) + }), + ...ephemeral() + }); + return; + } + if (interaction.isUserSelectMenu() && interaction.customId === TICKET_CTRL_ADD) { const userId = interaction.values[0]; if (!userId) { @@ -213,7 +231,11 @@ export async function handleTicketInteraction( return; } - if (interaction.isButton() || interaction.isUserSelectMenu()) { + if ( + interaction.isButton() || + interaction.isUserSelectMenu() || + interaction.isStringSelectMenu() + ) { if (isTicketControlInteraction(interaction.customId)) { await handleTicketControl(interaction, context); return; diff --git a/apps/bot/src/modules/tickets/service.ts b/apps/bot/src/modules/tickets/service.ts index ef23e70..02dce78 100644 --- a/apps/bot/src/modules/tickets/service.ts +++ b/apps/bot/src/modules/tickets/service.ts @@ -43,6 +43,7 @@ export const TICKET_CTRL_CLAIM = 'ticket:ctrl:claim'; export const TICKET_CTRL_CLOSE = 'ticket:ctrl:close'; export const TICKET_CTRL_ADD = 'ticket:ctrl:add'; export const TICKET_CTRL_REMOVE = 'ticket:ctrl:remove'; +export const TICKET_CTRL_PRIORITY = 'ticket:ctrl:priority'; /** Cap private-thread staff adds to stay within Discord rate limits. */ const MAX_THREAD_SUPPORT_MEMBERS = 50; @@ -160,13 +161,16 @@ export function isTicketControlInteraction(customId: string): boolean { customId === TICKET_CTRL_CLAIM || customId === TICKET_CTRL_CLOSE || customId === TICKET_CTRL_ADD || - customId === TICKET_CTRL_REMOVE + customId === TICKET_CTRL_REMOVE || + customId === TICKET_CTRL_PRIORITY ); } export function buildTicketControlPanel(locale: 'de' | 'en'): { embeds: EmbedBuilder[]; - components: ActionRowBuilder[]; + components: ActionRowBuilder< + ButtonBuilder | UserSelectMenuBuilder | StringSelectMenuBuilder + >[]; } { const embed = new EmbedBuilder() .setTitle(t(locale, 'ticket.control.title')) @@ -184,6 +188,34 @@ export function buildTicketControlPanel(locale: 'de' | 'en'): { .setStyle(ButtonStyle.Danger) ); + const priorityRow = new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId(TICKET_CTRL_PRIORITY) + .setPlaceholder(t(locale, 'ticket.control.priorityPlaceholder')) + .addOptions( + { + label: t(locale, 'ticket.priority.LOW'), + value: 'LOW', + description: t(locale, 'ticket.control.priorityLowHint') + }, + { + label: t(locale, 'ticket.priority.NORMAL'), + value: 'NORMAL', + description: t(locale, 'ticket.control.priorityNormalHint') + }, + { + label: t(locale, 'ticket.priority.HIGH'), + value: 'HIGH', + description: t(locale, 'ticket.control.priorityHighHint') + }, + { + label: t(locale, 'ticket.priority.URGENT'), + value: 'URGENT', + description: t(locale, 'ticket.control.priorityUrgentHint') + } + ) + ); + const addRow = new ActionRowBuilder().addComponents( new UserSelectMenuBuilder() .setCustomId(TICKET_CTRL_ADD) @@ -202,7 +234,7 @@ export function buildTicketControlPanel(locale: 'de' | 'en'): { return { embeds: [embed], - components: [buttons, addRow, removeRow] + components: [buttons, priorityRow, addRow, removeRow] }; } @@ -467,6 +499,94 @@ export async function runTicketPanelSyncJob( return syncTicketPanel(context, data.guildId); } +/** + * Dashboard ticket actions (close / claim / priority). Staff checks are done by + * the WebUI (`requireGuildAccess`); the bot applies Discord side effects. + */ +export async function runTicketDashboardActionJob( + context: BotContext, + data: { + ticketId: string; + guildId: string; + actorUserId: string; + action: 'close' | 'claim' | 'priority'; + reason?: string; + priority?: string; + } +): Promise<{ id: string; status: string; priority: string; claimedById: string | null }> { + const ticket = await context.prisma.ticket.findFirst({ + where: { id: data.ticketId, guildId: data.guildId }, + include: { category: true } + }); + if (!ticket) { + throw new TicketError('not_found'); + } + if (ticket.status === 'CLOSED') { + throw new TicketError('closed'); + } + + const locale = await getGuildLocale(context.prisma, data.guildId); + + if (data.action === 'close') { + await closeTicket(context, ticket, data.actorUserId, locale, data.reason); + const closed = await context.prisma.ticket.findUniqueOrThrow({ where: { id: ticket.id } }); + return { + id: closed.id, + status: closed.status, + priority: closed.priority, + claimedById: closed.claimedById + }; + } + + if (data.action === 'claim') { + if (ticket.claimedById === data.actorUserId) { + throw new TicketError('already_claimed'); + } + const updated = await context.prisma.ticket.update({ + where: { id: ticket.id }, + data: { + claimedById: data.actorUserId, + status: 'CLAIMED', + lastActivityAt: new Date() + } + }); + const channelId = ticket.channelId ?? ticket.threadId; + if (channelId) { + const channel = await fetchSendableChannel(context, channelId); + if (channel) { + await channel.send(tf(locale, 'ticket.claimed', { user: `<@${data.actorUserId}>` })); + } + } + return { + id: updated.id, + status: updated.status, + priority: updated.priority, + claimedById: updated.claimedById + }; + } + + const parsed = TicketPrioritySchema.parse(data.priority); + const updated = await context.prisma.ticket.update({ + where: { id: ticket.id }, + data: { priority: parsed, lastActivityAt: new Date() } + }); + const channelId = ticket.channelId ?? ticket.threadId; + if (channelId) { + const channel = await fetchSendableChannel(context, channelId); + if (channel) { + await channel.send( + tf(locale, 'ticket.prioritySet', { priority: t(locale, `ticket.priority.${parsed}`) }) + ); + } + } + return { + id: updated.id, + status: updated.status, + priority: updated.priority, + claimedById: updated.claimedById + }; +} + export function buildOpenModal(category: TicketCategory, locale: 'de' | 'en'): ModalBuilder { const fields = parseFormFields(category.formFields); const modal = new ModalBuilder() diff --git a/apps/webui/src/app/api/guilds/[guildId]/tickets/open/[ticketId]/action/route.ts b/apps/webui/src/app/api/guilds/[guildId]/tickets/open/[ticketId]/action/route.ts new file mode 100644 index 0000000..057f31f --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/tickets/open/[ticketId]/action/route.ts @@ -0,0 +1,48 @@ +import { TicketDashboardActionSchema } from '@nexumi/shared'; +import { type NextRequest, NextResponse } from 'next/server'; +import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; +import { writeDashboardAudit } from '@/lib/audit'; +import { + applyTicketDashboardAction, + TicketActionSyncError +} from '@/lib/module-configs/tickets'; +import { prisma } from '@/lib/prisma'; + +interface RouteParams { + params: Promise<{ guildId: string; ticketId: string }>; +} + +export async function POST(request: NextRequest, { params }: RouteParams) { + const { guildId, ticketId } = await params; + try { + const session = await requireGuildAccess(guildId); + const body = await request.json(); + const action = TicketDashboardActionSchema.parse(body); + + const after = await applyTicketDashboardAction( + guildId, + ticketId, + action, + session.user.id + ); + if (!after) { + return NextResponse.json({ error: 'Ticket not found' }, { status: 404 }); + } + + await writeDashboardAudit(prisma, { + guildId, + actorUserId: session.user.id, + action: `tickets.${action.action}`, + path: `/dashboard/${guildId}/tickets`, + before: { ticketId }, + after + }); + + return NextResponse.json(after); + } catch (error) { + if (error instanceof TicketActionSyncError) { + return NextResponse.json({ error: error.message }, { status: 504 }); + } + return toApiErrorResponse(error); + } +} diff --git a/apps/webui/src/app/api/guilds/[guildId]/tickets/open/route.ts b/apps/webui/src/app/api/guilds/[guildId]/tickets/open/route.ts new file mode 100644 index 0000000..82c6026 --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/tickets/open/route.ts @@ -0,0 +1,18 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; +import { listOpenTickets } from '@/lib/module-configs/tickets'; + +interface RouteParams { + params: Promise<{ guildId: string }>; +} + +export async function GET(_request: NextRequest, { params }: RouteParams) { + const { guildId } = await params; + try { + await requireGuildAccess(guildId); + const tickets = await listOpenTickets(guildId); + return NextResponse.json({ tickets }); + } catch (error) { + return toApiErrorResponse(error); + } +} diff --git a/apps/webui/src/app/dashboard/[guildId]/tickets/page.tsx b/apps/webui/src/app/dashboard/[guildId]/tickets/page.tsx index ef785c1..6499084 100644 --- a/apps/webui/src/app/dashboard/[guildId]/tickets/page.tsx +++ b/apps/webui/src/app/dashboard/[guildId]/tickets/page.tsx @@ -1,7 +1,12 @@ +import { OpenTicketsManager } from '@/components/modules/open-tickets-manager'; import { TicketCategoriesManager } from '@/components/modules/ticket-categories-manager'; import { TicketConfigForm } from '@/components/modules/ticket-config-form'; import { getLocale, t } from '@/lib/i18n'; -import { getTicketConfigDashboard, listTicketCategories } from '@/lib/module-configs/tickets'; +import { + getTicketConfigDashboard, + listOpenTickets, + listTicketCategories +} from '@/lib/module-configs/tickets'; interface TicketsPageProps { params: Promise<{ guildId: string }>; @@ -9,10 +14,11 @@ interface TicketsPageProps { export default async function TicketsPage({ params }: TicketsPageProps) { const { guildId } = await params; - const [locale, config, categories] = await Promise.all([ + const [locale, config, categories, openTickets] = await Promise.all([ getLocale(), getTicketConfigDashboard(guildId), - listTicketCategories(guildId) + listTicketCategories(guildId), + listOpenTickets(guildId) ]); return ( @@ -21,6 +27,7 @@ export default async function TicketsPage({ params }: TicketsPageProps) {

{t(locale, 'modules.tickets.label')}

{t(locale, 'modules.tickets.description')}

+ diff --git a/apps/webui/src/components/modules/open-tickets-manager.tsx b/apps/webui/src/components/modules/open-tickets-manager.tsx new file mode 100644 index 0000000..427a2cc --- /dev/null +++ b/apps/webui/src/components/modules/open-tickets-manager.tsx @@ -0,0 +1,237 @@ +'use client'; + +import type { TicketDashboard, TicketPriority } from '@nexumi/shared'; +import { ExternalLink, RefreshCw } from 'lucide-react'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { FieldAnchor } from '@/components/layout/field-anchor'; +import { useTranslations } from '@/components/locale-provider'; +import { Badge, type BadgeProps } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; + +const STATUS_VARIANTS: Record<'OPEN' | 'CLAIMED', BadgeProps['variant']> = { + OPEN: 'secondary', + CLAIMED: 'success' +}; + +const PRIORITY_VARIANTS: Record = { + LOW: 'outline', + NORMAL: 'secondary', + HIGH: 'default', + URGENT: 'destructive' +}; + +const PRIORITIES: TicketPriority[] = ['LOW', 'NORMAL', 'HIGH', 'URGENT']; + +function formatDate(iso: string): string { + return new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }); +} + +function discordChannelUrl(guildId: string, ticket: TicketDashboard): string | null { + const channelId = ticket.channelId ?? ticket.threadId; + if (!channelId) { + return null; + } + return `https://discord.com/channels/${guildId}/${channelId}`; +} + +interface OpenTicketsManagerProps { + guildId: string; + initialTickets: TicketDashboard[]; +} + +export function OpenTicketsManager({ guildId, initialTickets }: OpenTicketsManagerProps) { + const t = useTranslations(); + const [tickets, setTickets] = useState(initialTickets); + const [pendingId, setPendingId] = useState(null); + const [reasons, setReasons] = useState>({}); + const [loading, setLoading] = useState(false); + + async function reload() { + setLoading(true); + try { + const response = await fetch(`/api/guilds/${guildId}/tickets/open`); + const body = (await response.json().catch(() => null)) as { tickets?: TicketDashboard[] } | null; + if (response.ok && body?.tickets) { + setTickets(body.tickets); + } else { + toast.error(t('common.saveError')); + } + } catch { + toast.error(t('common.networkError')); + } finally { + setLoading(false); + } + } + + async function runAction( + ticket: TicketDashboard, + payload: + | { action: 'close'; reason?: string } + | { action: 'claim' } + | { action: 'priority'; priority: TicketPriority } + ) { + setPendingId(ticket.id); + try { + const response = await fetch(`/api/guilds/${guildId}/tickets/open/${ticket.id}/action`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + const body = (await response.json().catch(() => null)) as + | (TicketDashboard & { error?: string }) + | null; + if (!response.ok || !body) { + toast.error(body?.error ?? t('common.saveError')); + return; + } + + if (payload.action === 'close') { + setTickets((prev) => prev.filter((entry) => entry.id !== ticket.id)); + toast.success(t('modulePages.tickets.openList.closed')); + } else { + setTickets((prev) => prev.map((entry) => (entry.id === ticket.id ? body : entry))); + toast.success(t('common.saveSuccess')); + } + } catch { + toast.error(t('common.networkError')); + } finally { + setPendingId(null); + } + } + + return ( + + + +
+ {t('modulePages.tickets.openList.title')} + {t('modulePages.tickets.openList.description')} +
+ +
+ + {!loading && tickets.length === 0 && ( +

{t('modulePages.tickets.openList.empty')}

+ )} + {tickets.map((ticket) => { + const channelUrl = discordChannelUrl(guildId, ticket); + const busy = pendingId === ticket.id; + const statusKey = ticket.status === 'CLAIMED' ? 'CLAIMED' : 'OPEN'; + + return ( +
+
+
+

+ {ticket.subject?.trim() || t('modulePages.tickets.openList.noSubject')} +

+

+ {ticket.categoryName + ? t('modulePages.tickets.openList.categoryLine', { + category: ticket.categoryName + }) + : t('modulePages.tickets.openList.noCategory')} + {' · '} + <@{ticket.openerId}> + {ticket.claimedById ? ( + <> + {' · '} + {t('modulePages.tickets.openList.claimedBy', { + user: `<@${ticket.claimedById}>` + })} + + ) : null} + {' · '} + {formatDate(ticket.lastActivityAt)} +

+
+
+ + {t(`modulePages.tickets.openList.status.${statusKey}`)} + + + {t(`modulePages.tickets.openList.priority.${ticket.priority}`)} + +
+
+ +
+ + setReasons((prev) => ({ ...prev, [ticket.id]: event.target.value })) + } + /> +
+ {channelUrl ? ( + + ) : null} + + + +
+
+
+ ); + })} +
+
+
+ ); +} diff --git a/apps/webui/src/lib/dashboard-search-index.ts b/apps/webui/src/lib/dashboard-search-index.ts index 2c739db..4cb3d93 100644 --- a/apps/webui/src/lib/dashboard-search-index.ts +++ b/apps/webui/src/lib/dashboard-search-index.ts @@ -672,6 +672,13 @@ export const SETTINGS_SEARCH_FIELDS: SearchIndexEntry[] = [ href: 'tickets', hash: 'field-tickets-categories' }, + { + id: 'setting-tickets-open', + kind: 'setting', + labelKey: 'modulePages.tickets.openList.title', + href: 'tickets', + hash: 'field-tickets-open' + }, // Giveaways { id: 'setting-giveaways-create', diff --git a/apps/webui/src/lib/module-configs/tickets.ts b/apps/webui/src/lib/module-configs/tickets.ts index 1db4f4b..42a12a0 100644 --- a/apps/webui/src/lib/module-configs/tickets.ts +++ b/apps/webui/src/lib/module-configs/tickets.ts @@ -3,9 +3,13 @@ import type { TicketCategoryDashboardCreate, TicketCategoryDashboardUpdate, TicketConfigDashboard, - TicketConfigDashboardPatch + TicketConfigDashboardPatch, + TicketDashboard, + TicketDashboardAction, + TicketPriority, + TicketStatus } from '@nexumi/shared'; -import type { TicketCategory } from '@prisma/client'; +import type { Ticket, TicketCategory } from '@prisma/client'; import { prisma } from '../prisma'; import { addJobAndAwait, getTicketsQueue, getTicketsQueueEvents } from '../queues'; @@ -19,6 +23,13 @@ export class TicketPanelSyncError extends Error { } } +export class TicketActionSyncError extends Error { + constructor(message: string) { + super(message); + this.name = 'TicketActionSyncError'; + } +} + async function ensureTicketConfig(guildId: string) { await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } }); return prisma.ticketConfig.upsert({ where: { guildId }, update: {}, create: { guildId } }); @@ -214,3 +225,104 @@ export async function deleteTicketCategory(guildId: string, categoryId: string): } return true; } + +type TicketWithCategory = Ticket & { category: TicketCategory | null }; + +function toTicketDashboard(ticket: TicketWithCategory): TicketDashboard { + return { + id: ticket.id, + openerId: ticket.openerId, + claimedById: ticket.claimedById, + channelId: ticket.channelId, + threadId: ticket.threadId, + status: ticket.status as TicketStatus, + priority: ticket.priority as TicketPriority, + subject: ticket.subject, + categoryId: ticket.categoryId, + categoryName: ticket.category?.name ?? null, + lastActivityAt: ticket.lastActivityAt.toISOString(), + createdAt: ticket.createdAt.toISOString() + }; +} + +export async function listOpenTickets(guildId: string): Promise { + const tickets = await prisma.ticket.findMany({ + where: { + guildId, + status: { in: ['OPEN', 'CLAIMED'] } + }, + include: { category: true }, + orderBy: { lastActivityAt: 'desc' }, + take: 100 + }); + return tickets.map(toTicketDashboard); +} + +export async function getTicketDashboard( + guildId: string, + ticketId: string +): Promise { + const ticket = await prisma.ticket.findFirst({ + where: { id: ticketId, guildId }, + include: { category: true } + }); + return ticket ? toTicketDashboard(ticket) : null; +} + +/** + * Close / claim / priority need Discord side effects → bot via BullMQ. + */ +export async function applyTicketDashboardAction( + guildId: string, + ticketId: string, + action: TicketDashboardAction, + actorUserId: string +): Promise { + const existing = await prisma.ticket.findFirst({ + where: { id: ticketId, guildId }, + include: { category: true } + }); + if (!existing) { + return null; + } + if (existing.status === 'CLOSED') { + throw new TicketActionSyncError('This ticket is already closed'); + } + + const { confirmed } = await addJobAndAwait( + getTicketsQueue(), + getTicketsQueueEvents(), + 'ticketDashboardAction', + { + ticketId, + guildId, + actorUserId, + action: action.action, + reason: action.action === 'close' ? action.reason : undefined, + priority: action.action === 'priority' ? action.priority : undefined + }, + { + jobId: `ticket-action-${ticketId}-${action.action}-${Date.now()}`, + removeOnComplete: true, + removeOnFail: 25 + }, + 45_000 + ); + + if (!confirmed) { + throw new TicketActionSyncError( + 'The bot did not confirm the ticket action in time. Reload the list shortly to check the result.' + ); + } + + if (action.action === 'close') { + const closed = await getTicketDashboard(guildId, ticketId); + return closed; + } + + const updated = await getTicketDashboard(guildId, ticketId); + if (!updated) { + throw new TicketActionSyncError('Ticket action finished but the ticket could not be reloaded'); + } + return updated; +} diff --git a/apps/webui/src/messages/de.json b/apps/webui/src/messages/de.json index 3fe991c..e3f2331 100644 --- a/apps/webui/src/messages/de.json +++ b/apps/webui/src/messages/de.json @@ -694,6 +694,31 @@ "idsPlaceholder": "Eine oder mehrere IDs, kommagetrennt", "addCategory": "Kategorie hinzufügen", "nameRequired": "Ein Name ist erforderlich.", + "openList": { + "title": "Offene Tickets", + "description": "Aktive Tickets (offen oder übernommen). Schließen, übernehmen oder Priorität setzen – Änderungen werden in Discord ausgeführt.", + "refresh": "Aktualisieren", + "empty": "Keine offenen Tickets.", + "noSubject": "Ohne Betreff", + "noCategory": "Ohne Kategorie", + "categoryLine": "Kategorie: {category}", + "claimedBy": "Übernommen von {user}", + "openInDiscord": "In Discord öffnen", + "claim": "Übernehmen", + "close": "Schließen", + "closed": "Ticket geschlossen.", + "closeReasonPlaceholder": "Optionaler Schließgrund…", + "status": { + "OPEN": "Offen", + "CLAIMED": "Übernommen" + }, + "priority": { + "LOW": "Niedrig", + "NORMAL": "Normal", + "HIGH": "Hoch", + "URGENT": "Dringend" + } + }, "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).", diff --git a/apps/webui/src/messages/en.json b/apps/webui/src/messages/en.json index 778ec8e..e67dcd2 100644 --- a/apps/webui/src/messages/en.json +++ b/apps/webui/src/messages/en.json @@ -694,6 +694,31 @@ "idsPlaceholder": "One or more IDs, comma-separated", "addCategory": "Add category", "nameRequired": "A name is required.", + "openList": { + "title": "Open tickets", + "description": "Active tickets (open or claimed). Close, claim, or set priority — changes are applied in Discord.", + "refresh": "Refresh", + "empty": "No open tickets.", + "noSubject": "No subject", + "noCategory": "No category", + "categoryLine": "Category: {category}", + "claimedBy": "Claimed by {user}", + "openInDiscord": "Open in Discord", + "claim": "Claim", + "close": "Close", + "closed": "Ticket closed.", + "closeReasonPlaceholder": "Optional close reason…", + "status": { + "OPEN": "Open", + "CLAIMED": "Claimed" + }, + "priority": { + "LOW": "Low", + "NORMAL": "Normal", + "HIGH": "High", + "URGENT": "Urgent" + } + }, "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).", diff --git a/docs/PHASE-TRACKING.md b/docs/PHASE-TRACKING.md index be76ab6..feb1746 100644 --- a/docs/PHASE-TRACKING.md +++ b/docs/PHASE-TRACKING.md @@ -508,6 +508,7 @@ - Ticket-Eröffnung pingt Support-Rollen (`allowedMentions.roles`) - Control-Panel im Ticket: Claim, Close, User-Select Add/Remove (wie TempVoice-Pattern) - Opener darf schließen; Claim/Add/Remove nur Support-Staff +- Prioritäts-Dropdown im Discord-Control-Panel (`ticket:ctrl:priority`) ### Manuell testen @@ -516,6 +517,22 @@ - [ ] Control-Panel: Claim / Close / User hinzufügen / entfernen - [ ] Opener kann schließen; Nicht-Staff kann nicht claimen +## Post-Phase – Offene Tickets im WebUI (Status: implementiert) + +### Abgeschlossen (Code) + +- Liste offener Tickets (`OPEN`/`CLAIMED`) auf der Tickets-Seite +- Aktionen: In Discord öffnen, Übernehmen, Schließen (optionaler Grund), Priorität setzen +- BullMQ-Job `ticketDashboardAction`; Shared Zod `TicketDashboard` / `TicketDashboardAction` +- API `GET …/tickets/open`, `POST …/tickets/open/[ticketId]/action` + +### Manuell testen + +- [ ] Ticket öffnen → erscheint in der WebUI-Liste +- [ ] Claim / Priority aus Dashboard → Nachricht im Ticket-Kanal +- [ ] Close aus Dashboard → Ticket verschwindet aus der Liste, Kanal/Thread wird geschlossen +- [ ] Refresh-Button aktualisiert die Liste + ## Post-Phase – Ban/Kick-Reason Logging + Verification Fail Event (Status: implementiert) ### Abgeschlossen (Code) diff --git a/packages/shared/src/dashboard.ts b/packages/shared/src/dashboard.ts index 1923a9a..51b3240 100644 --- a/packages/shared/src/dashboard.ts +++ b/packages/shared/src/dashboard.ts @@ -19,7 +19,9 @@ import { SelfRoleModeSchema, SuggestionStatusSchema, TagResponseTypeSchema, - TicketModeSchema + TicketModeSchema, + TicketPrioritySchema, + TicketStatusSchema } from './phase4.js'; import { SocialFeedTypeSchema } from './phase5.js'; @@ -411,6 +413,37 @@ export type TicketCategoryDashboardCreate = z.infer; +export const TicketDashboardSchema = z.object({ + id: z.string(), + openerId: z.string(), + claimedById: z.string().nullable(), + channelId: z.string().nullable(), + threadId: z.string().nullable(), + status: TicketStatusSchema, + priority: TicketPrioritySchema, + subject: z.string().nullable(), + categoryId: z.string().nullable(), + categoryName: z.string().nullable(), + lastActivityAt: z.string(), + createdAt: z.string() +}); +export type TicketDashboard = z.infer; + +export const TicketDashboardActionSchema = z.discriminatedUnion('action', [ + z.object({ + action: z.literal('close'), + reason: z.string().max(500).optional() + }), + z.object({ + action: z.literal('claim') + }), + z.object({ + action: z.literal('priority'), + priority: TicketPrioritySchema + }) +]); +export type TicketDashboardAction = z.infer; + // --------------------------------------------------------------------------- // Giveaways // --------------------------------------------------------------------------- diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 2c4490b..e8de529 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -427,11 +427,16 @@ const de: Dictionary = { 'ticket.claimed': '{user} hat dieses Ticket übernommen.', 'ticket.control.title': 'Ticket-Steuerung', 'ticket.control.description': - 'Übernehmen oder schließen, bzw. weitere Nutzer über die Auswahlmenüs hinzufügen oder entfernen. Slash-Commands: `/ticket rename`, `/ticket priority`.', + 'Übernehmen, schließen, Priorität setzen oder Nutzer über die Menüs hinzufügen/entfernen. Zusätzlich: `/ticket rename`.', 'ticket.control.claim': 'Übernehmen', 'ticket.control.close': 'Schließen', 'ticket.control.addPlaceholder': 'Nutzer zum Ticket hinzufügen…', 'ticket.control.removePlaceholder': 'Nutzer aus dem Ticket entfernen…', + 'ticket.control.priorityPlaceholder': 'Priorität wählen…', + 'ticket.control.priorityLowHint': 'Geringe Dringlichkeit', + 'ticket.control.priorityNormalHint': 'Standard-Priorität', + 'ticket.control.priorityHighHint': 'Erhöhte Dringlichkeit', + 'ticket.control.priorityUrgentHint': 'Sofortige Bearbeitung', 'ticket.userAdded': '{user} wurde zum Ticket hinzugefügt.', 'ticket.userRemoved': '{user} wurde aus dem Ticket entfernt.', 'ticket.renamed': 'Ticket umbenannt in **{name}**.', @@ -1034,11 +1039,16 @@ const en: Dictionary = { 'ticket.claimed': '{user} claimed this ticket.', 'ticket.control.title': 'Ticket controls', 'ticket.control.description': - 'Claim or close this ticket, or add/remove users with the menus below. Slash commands: `/ticket rename`, `/ticket priority`.', + 'Claim, close, set priority, or add/remove users with the menus below. Also: `/ticket rename`.', 'ticket.control.claim': 'Claim', 'ticket.control.close': 'Close', 'ticket.control.addPlaceholder': 'Add a user to this ticket…', 'ticket.control.removePlaceholder': 'Remove a user from this ticket…', + 'ticket.control.priorityPlaceholder': 'Choose priority…', + 'ticket.control.priorityLowHint': 'Low urgency', + 'ticket.control.priorityNormalHint': 'Default priority', + 'ticket.control.priorityHighHint': 'Elevated urgency', + 'ticket.control.priorityUrgentHint': 'Needs immediate attention', 'ticket.userAdded': '{user} was added to the ticket.', 'ticket.userRemoved': '{user} was removed from the ticket.', 'ticket.renamed': 'Ticket renamed to **{name}**.',