deploy #2

Merged
smueller merged 24 commits from deploy into main 2026-07-29 07:44:03 +00:00
15 changed files with 710 additions and 14 deletions
Showing only changes of commit 5c11b68fc0 - Show all commits

View File

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

View File

@@ -4,6 +4,8 @@ export {
closeInactiveTickets,
checkInactiveTickets,
runTicketPanelSyncJob,
runTicketDashboardActionJob,
TicketPanelError
} from './service.js';
export { registerTicketEvents } from './events.js';

View File

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

View File

@@ -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()

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

View File

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

View File

@@ -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
// ---------------------------------------------------------------------------

View File

@@ -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}**.',