Enhance ticket management with dashboard actions and priority settings

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

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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) {
<h1 className="text-2xl font-semibold">{t(locale, 'modules.tickets.label')}</h1>
<p className="text-sm text-muted-foreground">{t(locale, 'modules.tickets.description')}</p>
</div>
<OpenTicketsManager guildId={guildId} initialTickets={openTickets} />
<TicketConfigForm guildId={guildId} initialValue={config} />
<TicketCategoriesManager guildId={guildId} initialCategories={categories} />
</div>

View File

@@ -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<TicketPriority, BadgeProps['variant']> = {
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<TicketDashboard[]>(initialTickets);
const [pendingId, setPendingId] = useState<string | null>(null);
const [reasons, setReasons] = useState<Record<string, string>>({});
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 (
<FieldAnchor id="field-tickets-open">
<Card>
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>{t('modulePages.tickets.openList.title')}</CardTitle>
<CardDescription>{t('modulePages.tickets.openList.description')}</CardDescription>
</div>
<Button type="button" variant="outline" size="sm" onClick={reload} disabled={loading}>
<RefreshCw className={`size-4 ${loading ? 'animate-spin' : ''}`} />
{t('modulePages.tickets.openList.refresh')}
</Button>
</CardHeader>
<CardContent className="space-y-3">
{!loading && tickets.length === 0 && (
<p className="text-sm text-muted-foreground">{t('modulePages.tickets.openList.empty')}</p>
)}
{tickets.map((ticket) => {
const channelUrl = discordChannelUrl(guildId, ticket);
const busy = pendingId === ticket.id;
const statusKey = ticket.status === 'CLAIMED' ? 'CLAIMED' : 'OPEN';
return (
<div key={ticket.id} className="space-y-3 rounded-md border border-border p-4">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0 space-y-1">
<p className="font-medium">
{ticket.subject?.trim() || t('modulePages.tickets.openList.noSubject')}
</p>
<p className="text-xs text-muted-foreground">
{ticket.categoryName
? t('modulePages.tickets.openList.categoryLine', {
category: ticket.categoryName
})
: t('modulePages.tickets.openList.noCategory')}
{' · '}
&lt;@{ticket.openerId}&gt;
{ticket.claimedById ? (
<>
{' · '}
{t('modulePages.tickets.openList.claimedBy', {
user: `<@${ticket.claimedById}>`
})}
</>
) : null}
{' · '}
{formatDate(ticket.lastActivityAt)}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Badge variant={STATUS_VARIANTS[statusKey]}>
{t(`modulePages.tickets.openList.status.${statusKey}`)}
</Badge>
<Badge variant={PRIORITY_VARIANTS[ticket.priority]}>
{t(`modulePages.tickets.openList.priority.${ticket.priority}`)}
</Badge>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Input
className="sm:max-w-xs"
placeholder={t('modulePages.tickets.openList.closeReasonPlaceholder')}
value={reasons[ticket.id] ?? ''}
disabled={busy}
onChange={(event) =>
setReasons((prev) => ({ ...prev, [ticket.id]: event.target.value }))
}
/>
<div className="flex flex-wrap gap-2">
{channelUrl ? (
<Button type="button" variant="outline" size="sm" asChild>
<a href={channelUrl} target="_blank" rel="noreferrer">
<ExternalLink className="size-4" />
{t('modulePages.tickets.openList.openInDiscord')}
</a>
</Button>
) : null}
<Button
type="button"
variant="secondary"
size="sm"
disabled={busy}
onClick={() => runAction(ticket, { action: 'claim' })}
>
{t('modulePages.tickets.openList.claim')}
</Button>
<Button
type="button"
variant="destructive"
size="sm"
disabled={busy}
onClick={() =>
runAction(ticket, {
action: 'close',
reason: reasons[ticket.id]?.trim() || undefined
})
}
>
{t('modulePages.tickets.openList.close')}
</Button>
<Select
value={ticket.priority}
disabled={busy}
onValueChange={(priority) =>
runAction(ticket, {
action: 'priority',
priority: priority as TicketPriority
})
}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PRIORITIES.map((priority) => (
<SelectItem key={priority} value={priority}>
{t(`modulePages.tickets.openList.priority.${priority}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
);
})}
</CardContent>
</Card>
</FieldAnchor>
);
}

View File

@@ -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',

View File

@@ -3,9 +3,13 @@ import type {
TicketCategoryDashboardCreate,
TicketCategoryDashboardUpdate,
TicketConfigDashboard,
TicketConfigDashboardPatch
TicketConfigDashboardPatch,
TicketDashboard,
TicketDashboardAction,
TicketPriority,
TicketStatus
} from '@nexumi/shared';
import type { TicketCategory } from '@prisma/client';
import type { Ticket, TicketCategory } from '@prisma/client';
import { prisma } from '../prisma';
import { addJobAndAwait, getTicketsQueue, getTicketsQueueEvents } from '../queues';
@@ -19,6 +23,13 @@ export class TicketPanelSyncError extends Error {
}
}
export class TicketActionSyncError extends Error {
constructor(message: string) {
super(message);
this.name = 'TicketActionSyncError';
}
}
async function ensureTicketConfig(guildId: string) {
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
return prisma.ticketConfig.upsert({ where: { guildId }, update: {}, create: { guildId } });
@@ -214,3 +225,104 @@ export async function deleteTicketCategory(guildId: string, categoryId: string):
}
return true;
}
type TicketWithCategory = Ticket & { category: TicketCategory | null };
function toTicketDashboard(ticket: TicketWithCategory): TicketDashboard {
return {
id: ticket.id,
openerId: ticket.openerId,
claimedById: ticket.claimedById,
channelId: ticket.channelId,
threadId: ticket.threadId,
status: ticket.status as TicketStatus,
priority: ticket.priority as TicketPriority,
subject: ticket.subject,
categoryId: ticket.categoryId,
categoryName: ticket.category?.name ?? null,
lastActivityAt: ticket.lastActivityAt.toISOString(),
createdAt: ticket.createdAt.toISOString()
};
}
export async function listOpenTickets(guildId: string): Promise<TicketDashboard[]> {
const tickets = await prisma.ticket.findMany({
where: {
guildId,
status: { in: ['OPEN', 'CLAIMED'] }
},
include: { category: true },
orderBy: { lastActivityAt: 'desc' },
take: 100
});
return tickets.map(toTicketDashboard);
}
export async function getTicketDashboard(
guildId: string,
ticketId: string
): Promise<TicketDashboard | null> {
const ticket = await prisma.ticket.findFirst({
where: { id: ticketId, guildId },
include: { category: true }
});
return ticket ? toTicketDashboard(ticket) : null;
}
/**
* Close / claim / priority need Discord side effects → bot via BullMQ.
*/
export async function applyTicketDashboardAction(
guildId: string,
ticketId: string,
action: TicketDashboardAction,
actorUserId: string
): Promise<TicketDashboard | null> {
const existing = await prisma.ticket.findFirst({
where: { id: ticketId, guildId },
include: { category: true }
});
if (!existing) {
return null;
}
if (existing.status === 'CLOSED') {
throw new TicketActionSyncError('This ticket is already closed');
}
const { confirmed } = await addJobAndAwait(
getTicketsQueue(),
getTicketsQueueEvents(),
'ticketDashboardAction',
{
ticketId,
guildId,
actorUserId,
action: action.action,
reason: action.action === 'close' ? action.reason : undefined,
priority: action.action === 'priority' ? action.priority : undefined
},
{
jobId: `ticket-action-${ticketId}-${action.action}-${Date.now()}`,
removeOnComplete: true,
removeOnFail: 25
},
45_000
);
if (!confirmed) {
throw new TicketActionSyncError(
'The bot did not confirm the ticket action in time. Reload the list shortly to check the result.'
);
}
if (action.action === 'close') {
const closed = await getTicketDashboard(guildId, ticketId);
return closed;
}
const updated = await getTicketDashboard(guildId, ticketId);
if (!updated) {
throw new TicketActionSyncError('Ticket action finished but the ticket could not be reloaded');
}
return updated;
}

View File

@@ -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).",

View File

@@ -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).",