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