Implement ticket panel synchronization and configuration updates
- Added `panelChannelId` and `panelMessageId` fields to the `TicketConfig` model for managing ticket panel settings. - Introduced `syncTicketPanel` function to handle posting and updating the ticket panel in Discord channels via BullMQ. - Enhanced error handling for ticket panel synchronization, including specific error messages for missing categories and posting failures. - Updated WebUI components to support panel channel selection and improved error messaging for synchronization issues. - Added localization entries for new error messages related to ticket panel synchronization in both English and German. - Refactored ticket category management to trigger panel synchronization upon category creation, update, or deletion.
This commit is contained in:
@@ -5,6 +5,7 @@ import { writeDashboardAudit } from '@/lib/audit';
|
||||
import {
|
||||
deleteTicketCategory,
|
||||
TicketCategoryConflictError,
|
||||
TicketPanelSyncError,
|
||||
updateTicketCategory
|
||||
} from '@/lib/module-configs/tickets';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
@@ -38,6 +39,12 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
if (error instanceof TicketCategoryConflictError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 409 });
|
||||
}
|
||||
if (error instanceof TicketPanelSyncError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, code: error.code },
|
||||
{ status: error.code === 'timeout' ? 504 : 502 }
|
||||
);
|
||||
}
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -61,6 +68,12 @@ export async function DELETE(_request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (error instanceof TicketPanelSyncError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, code: error.code },
|
||||
{ status: error.code === 'timeout' ? 504 : 502 }
|
||||
);
|
||||
}
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { TicketCategoryDashboardCreateSchema } from '@nexumi/shared';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
|
||||
import { writeDashboardAudit } from '@/lib/audit';
|
||||
import { createTicketCategory, listTicketCategories, TicketCategoryConflictError } from '@/lib/module-configs/tickets';
|
||||
import { createTicketCategory, listTicketCategories, TicketCategoryConflictError, TicketPanelSyncError } from '@/lib/module-configs/tickets';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
interface RouteParams {
|
||||
@@ -42,6 +42,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
if (error instanceof TicketCategoryConflictError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 409 });
|
||||
}
|
||||
if (error instanceof TicketPanelSyncError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, code: error.code },
|
||||
{ status: error.code === 'timeout' ? 504 : 502 }
|
||||
);
|
||||
}
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ import { TicketConfigDashboardPatchSchema } from '@nexumi/shared';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
|
||||
import { writeDashboardAudit } from '@/lib/audit';
|
||||
import { getTicketConfigDashboard, updateTicketConfigDashboard } from '@/lib/module-configs/tickets';
|
||||
import {
|
||||
getTicketConfigDashboard,
|
||||
TicketPanelSyncError,
|
||||
updateTicketConfigDashboard
|
||||
} from '@/lib/module-configs/tickets';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
interface RouteParams {
|
||||
@@ -41,6 +45,12 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
return NextResponse.json(after);
|
||||
} catch (error) {
|
||||
if (error instanceof TicketPanelSyncError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, code: error.code },
|
||||
{ status: error.code === 'timeout' ? 504 : 502 }
|
||||
);
|
||||
}
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||
import { useTranslations } from '@/components/locale-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { DiscordEmojiSelect } from '@/components/ui/discord-emoji-select';
|
||||
import { DiscordRoleMultiSelect } from '@/components/ui/discord-role-select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -24,6 +25,23 @@ interface TicketCategoriesManagerProps {
|
||||
initialCategories: TicketCategoryDashboard[];
|
||||
}
|
||||
|
||||
function panelSyncErrorMessage(
|
||||
code: string | undefined,
|
||||
fallback: string | undefined,
|
||||
t: (key: string) => string
|
||||
): string {
|
||||
if (code === 'timeout') {
|
||||
return t('modulePages.tickets.errors.panelTimeout');
|
||||
}
|
||||
if (code === 'no_categories') {
|
||||
return t('modulePages.tickets.errors.noCategories');
|
||||
}
|
||||
if (code === 'not_posted') {
|
||||
return t('modulePages.tickets.errors.panelNotPosted');
|
||||
}
|
||||
return fallback ?? t('common.saveError');
|
||||
}
|
||||
|
||||
export function TicketCategoriesManager({ guildId, initialCategories }: TicketCategoriesManagerProps) {
|
||||
const t = useTranslations();
|
||||
const [categories, setCategories] = useState<LocalCategory[]>(
|
||||
@@ -49,9 +67,11 @@ export function TicketCategoriesManager({ guildId, initialCategories }: TicketCa
|
||||
supportRoleIds: draft.supportRoleIds
|
||||
})
|
||||
});
|
||||
const body = (await response.json().catch(() => null)) as (TicketCategoryDashboard & { error?: string }) | null;
|
||||
const body = (await response.json().catch(() => null)) as
|
||||
| (TicketCategoryDashboard & { error?: string; code?: string })
|
||||
| null;
|
||||
if (!response.ok || !body) {
|
||||
toast.error(body?.error ?? t('common.saveError'));
|
||||
toast.error(panelSyncErrorMessage(body?.code, body?.error, t));
|
||||
return;
|
||||
}
|
||||
setCategories((prev) => [...prev, { ...body, clientKey: body.id ?? `new-${Date.now()}` }]);
|
||||
@@ -79,9 +99,11 @@ export function TicketCategoriesManager({ guildId, initialCategories }: TicketCa
|
||||
supportRoleIds: category.supportRoleIds
|
||||
})
|
||||
});
|
||||
const body = (await response.json().catch(() => null)) as (TicketCategoryDashboard & { error?: string }) | null;
|
||||
const body = (await response.json().catch(() => null)) as
|
||||
| (TicketCategoryDashboard & { error?: string; code?: string })
|
||||
| null;
|
||||
if (!response.ok || !body) {
|
||||
toast.error(body?.error ?? t('common.saveError'));
|
||||
toast.error(panelSyncErrorMessage(body?.code, body?.error, t));
|
||||
return;
|
||||
}
|
||||
toast.success(t('common.saveSuccess'));
|
||||
@@ -100,7 +122,8 @@ export function TicketCategoriesManager({ guildId, initialCategories }: TicketCa
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!response.ok) {
|
||||
toast.error(t('common.saveError'));
|
||||
const body = (await response.json().catch(() => null)) as { error?: string; code?: string } | null;
|
||||
toast.error(panelSyncErrorMessage(body?.code, body?.error, t));
|
||||
return;
|
||||
}
|
||||
setCategories((prev) => prev.filter((entry) => entry.clientKey !== category.clientKey));
|
||||
@@ -139,12 +162,15 @@ export function TicketCategoriesManager({ guildId, initialCategories }: TicketCa
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.tickets.categoryEmoji')}</Label>
|
||||
<Input
|
||||
value={category.emoji ?? ''}
|
||||
onChange={(event) =>
|
||||
<DiscordEmojiSelect
|
||||
guildId={guildId}
|
||||
value={category.emoji ?? undefined}
|
||||
onChange={(emoji) =>
|
||||
setCategories((prev) =>
|
||||
prev.map((entry) =>
|
||||
entry.clientKey === category.clientKey ? { ...entry, emoji: event.target.value } : entry
|
||||
entry.clientKey === category.clientKey
|
||||
? { ...entry, emoji: emoji ?? null }
|
||||
: entry
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -200,7 +226,11 @@ export function TicketCategoriesManager({ guildId, initialCategories }: TicketCa
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.tickets.categoryEmoji')}</Label>
|
||||
<Input value={draft.emoji} onChange={(event) => setDraft((prev) => ({ ...prev, emoji: event.target.value }))} />
|
||||
<DiscordEmojiSelect
|
||||
guildId={guildId}
|
||||
value={draft.emoji || undefined}
|
||||
onChange={(emoji) => setDraft((prev) => ({ ...prev, emoji: emoji ?? '' }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-2">
|
||||
<Label>{t('modulePages.tickets.categoryDescription')}</Label>
|
||||
|
||||
@@ -13,7 +13,16 @@ import { Switch } from '@/components/ui/switch';
|
||||
import type { SettingsSaveResult } from '@/components/settings/settings-form';
|
||||
import { SettingsForm } from '@/components/settings/settings-form';
|
||||
|
||||
async function saveTicketConfig(guildId: string, value: TicketConfigDashboard): Promise<SettingsSaveResult> {
|
||||
async function saveTicketConfig(
|
||||
guildId: string,
|
||||
value: TicketConfigDashboard,
|
||||
errorMessages: {
|
||||
timeout: string;
|
||||
notPosted: string;
|
||||
noCategories: string;
|
||||
network: string;
|
||||
}
|
||||
): Promise<SettingsSaveResult> {
|
||||
try {
|
||||
const response = await fetch(`/api/guilds/${guildId}/tickets`, {
|
||||
method: 'PATCH',
|
||||
@@ -21,12 +30,24 @@ async function saveTicketConfig(guildId: string, value: TicketConfigDashboard):
|
||||
body: JSON.stringify(value)
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as { error?: string } | null;
|
||||
const body = (await response.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
code?: string;
|
||||
} | null;
|
||||
if (body?.code === 'timeout') {
|
||||
return { ok: false, error: errorMessages.timeout };
|
||||
}
|
||||
if (body?.code === 'no_categories') {
|
||||
return { ok: false, error: errorMessages.noCategories };
|
||||
}
|
||||
if (body?.code === 'not_posted') {
|
||||
return { ok: false, error: errorMessages.notPosted };
|
||||
}
|
||||
return { ok: false, error: body?.error ?? `Request failed with status ${response.status}` };
|
||||
}
|
||||
return { ok: true };
|
||||
} catch {
|
||||
return { ok: false, error: 'Network error' };
|
||||
return { ok: false, error: errorMessages.network };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +63,14 @@ export function TicketConfigForm({ guildId, initialValue }: TicketConfigFormProp
|
||||
return (
|
||||
<SettingsForm<TicketConfigDashboard>
|
||||
initialValue={initialValue}
|
||||
onSave={(value) => saveTicketConfig(guildId, value)}
|
||||
onSave={(value) =>
|
||||
saveTicketConfig(guildId, value, {
|
||||
timeout: t('modulePages.tickets.errors.panelTimeout'),
|
||||
notPosted: t('modulePages.tickets.errors.panelNotPosted'),
|
||||
noCategories: t('modulePages.tickets.errors.noCategories'),
|
||||
network: t('common.networkError')
|
||||
})
|
||||
}
|
||||
>
|
||||
{({ value, setValue }) => (
|
||||
<Card>
|
||||
@@ -77,6 +105,18 @@ export function TicketConfigForm({ guildId, initialValue }: TicketConfigFormProp
|
||||
</div>
|
||||
</FieldAnchor>
|
||||
|
||||
<FieldAnchor id="field-tickets-panel-channel">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.tickets.panelChannelId')}</Label>
|
||||
<DiscordChannelSelect
|
||||
guildId={guildId}
|
||||
value={value.panelChannelId}
|
||||
onChange={(panelChannelId) => setValue((prev) => ({ ...prev, panelChannelId }))}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('modulePages.tickets.panelChannelHint')}</p>
|
||||
</div>
|
||||
</FieldAnchor>
|
||||
|
||||
<FieldAnchor id="field-tickets-category">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.tickets.categoryId')}</Label>
|
||||
|
||||
@@ -623,6 +623,13 @@ export const SETTINGS_SEARCH_FIELDS: SearchIndexEntry[] = [
|
||||
href: 'tickets',
|
||||
hash: 'field-tickets-mode'
|
||||
},
|
||||
{
|
||||
id: 'setting-tickets-panel-channel',
|
||||
kind: 'setting',
|
||||
labelKey: 'modulePages.tickets.panelChannelId',
|
||||
href: 'tickets',
|
||||
hash: 'field-tickets-panel-channel'
|
||||
},
|
||||
{
|
||||
id: 'setting-tickets-category',
|
||||
kind: 'setting',
|
||||
|
||||
@@ -7,6 +7,17 @@ import type {
|
||||
} from '@nexumi/shared';
|
||||
import type { TicketCategory } from '@prisma/client';
|
||||
import { prisma } from '../prisma';
|
||||
import { addJobAndAwait, getTicketsQueue, getTicketsQueueEvents } from '../queues';
|
||||
|
||||
export class TicketPanelSyncError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code: 'timeout' | 'not_posted' | 'no_categories' = 'not_posted'
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'TicketPanelSyncError';
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureTicketConfig(guildId: string) {
|
||||
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
|
||||
@@ -19,6 +30,7 @@ function toConfigDashboard(config: Awaited<ReturnType<typeof ensureTicketConfig>
|
||||
mode: config.mode as TicketConfigDashboard['mode'],
|
||||
categoryId: config.categoryId ?? '',
|
||||
logChannelId: config.logChannelId ?? '',
|
||||
panelChannelId: config.panelChannelId ?? '',
|
||||
transcriptDm: config.transcriptDm,
|
||||
inactivityHours: config.inactivityHours,
|
||||
ratingEnabled: config.ratingEnabled
|
||||
@@ -30,22 +42,86 @@ export async function getTicketConfigDashboard(guildId: string): Promise<TicketC
|
||||
return toConfigDashboard(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard has no discord.js Client, so panel posting is delegated to the bot
|
||||
* via the `tickets` BullMQ queue (`ticketPanelSync`).
|
||||
*/
|
||||
async function syncTicketPanel(guildId: string): Promise<void> {
|
||||
const categoryCount = await prisma.ticketCategory.count({ where: { guildId } });
|
||||
if (categoryCount === 0) {
|
||||
throw new TicketPanelSyncError(
|
||||
'Create at least one ticket category before posting the panel',
|
||||
'no_categories'
|
||||
);
|
||||
}
|
||||
|
||||
const { confirmed, result } = await addJobAndAwait<{
|
||||
panelChannelId: string;
|
||||
panelMessageId: string;
|
||||
}>(
|
||||
getTicketsQueue(),
|
||||
getTicketsQueueEvents(),
|
||||
'ticketPanelSync',
|
||||
{ guildId },
|
||||
{
|
||||
jobId: `ticket-panel-${guildId}-${Date.now()}`,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 25
|
||||
},
|
||||
30_000
|
||||
);
|
||||
|
||||
if (!confirmed || !result?.panelMessageId) {
|
||||
throw new TicketPanelSyncError(
|
||||
confirmed
|
||||
? 'Ticket panel job finished without posting a Discord message'
|
||||
: 'The bot did not confirm the ticket panel in time. It may still be posting — check the channel shortly.',
|
||||
confirmed ? 'not_posted' : 'timeout'
|
||||
);
|
||||
}
|
||||
|
||||
const config = await prisma.ticketConfig.findUnique({ where: { guildId } });
|
||||
if (!config?.panelMessageId) {
|
||||
throw new TicketPanelSyncError(
|
||||
'Ticket panel job finished without storing a Discord message id',
|
||||
'not_posted'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function maybeSyncTicketPanel(guildId: string): Promise<void> {
|
||||
const config = await prisma.ticketConfig.findUnique({ where: { guildId } });
|
||||
if (!config?.enabled || !config.panelChannelId) {
|
||||
return;
|
||||
}
|
||||
await syncTicketPanel(guildId);
|
||||
}
|
||||
|
||||
export async function updateTicketConfigDashboard(
|
||||
guildId: string,
|
||||
patch: TicketConfigDashboardPatch
|
||||
): Promise<TicketConfigDashboard> {
|
||||
await ensureTicketConfig(guildId);
|
||||
|
||||
const { categoryId, logChannelId, ...rest } = patch;
|
||||
const { categoryId, logChannelId, panelChannelId, ...rest } = patch;
|
||||
const updated = await prisma.ticketConfig.update({
|
||||
where: { guildId },
|
||||
data: {
|
||||
...rest,
|
||||
...(categoryId !== undefined ? { categoryId: categoryId === '' ? null : categoryId } : {}),
|
||||
...(logChannelId !== undefined ? { logChannelId: logChannelId === '' ? null : logChannelId } : {})
|
||||
...(logChannelId !== undefined ? { logChannelId: logChannelId === '' ? null : logChannelId } : {}),
|
||||
...(panelChannelId !== undefined
|
||||
? { panelChannelId: panelChannelId === '' ? null : panelChannelId }
|
||||
: {})
|
||||
}
|
||||
});
|
||||
return toConfigDashboard(updated);
|
||||
const dashboard = toConfigDashboard(updated);
|
||||
|
||||
if (dashboard.enabled && dashboard.panelChannelId) {
|
||||
await syncTicketPanel(guildId);
|
||||
}
|
||||
|
||||
return dashboard;
|
||||
}
|
||||
|
||||
function toCategoryDashboard(category: TicketCategory): TicketCategoryDashboard {
|
||||
@@ -87,6 +163,7 @@ export async function createTicketCategory(
|
||||
supportRoleIds: input.supportRoleIds
|
||||
}
|
||||
});
|
||||
await maybeSyncTicketPanel(guildId);
|
||||
return toCategoryDashboard(category);
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && 'code' in error && error.code === 'P2002') {
|
||||
@@ -116,6 +193,7 @@ export async function updateTicketCategory(
|
||||
...(patch.supportRoleIds !== undefined ? { supportRoleIds: patch.supportRoleIds } : {})
|
||||
}
|
||||
});
|
||||
await maybeSyncTicketPanel(guildId);
|
||||
return toCategoryDashboard(updated);
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && 'code' in error && error.code === 'P2002') {
|
||||
@@ -127,5 +205,12 @@ export async function updateTicketCategory(
|
||||
|
||||
export async function deleteTicketCategory(guildId: string, categoryId: string): Promise<boolean> {
|
||||
const result = await prisma.ticketCategory.deleteMany({ where: { id: categoryId, guildId } });
|
||||
return result.count > 0;
|
||||
if (result.count === 0) {
|
||||
return false;
|
||||
}
|
||||
const remaining = await prisma.ticketCategory.count({ where: { guildId } });
|
||||
if (remaining > 0) {
|
||||
await maybeSyncTicketPanel(guildId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export const verificationQueueName = 'verification';
|
||||
export const automodQueueName = 'automod';
|
||||
export const presenceQueueName = 'presence';
|
||||
export const selfrolesQueueName = 'selfroles';
|
||||
export const ticketsQueueName = 'tickets';
|
||||
|
||||
const globalForQueues = globalThis as unknown as {
|
||||
__nexumiGiveawayQueue?: Queue;
|
||||
@@ -34,12 +35,14 @@ const globalForQueues = globalThis as unknown as {
|
||||
__nexumiAutomodQueue?: Queue;
|
||||
__nexumiPresenceQueue?: Queue;
|
||||
__nexumiSelfrolesQueue?: Queue;
|
||||
__nexumiTicketsQueue?: Queue;
|
||||
__nexumiGiveawayQueueEvents?: QueueEvents;
|
||||
__nexumiGuildBackupQueueEvents?: QueueEvents;
|
||||
__nexumiSuggestionsQueueEvents?: QueueEvents;
|
||||
__nexumiMessagesQueueEvents?: QueueEvents;
|
||||
__nexumiSelfrolesQueueEvents?: QueueEvents;
|
||||
__nexumiVerificationQueueEvents?: QueueEvents;
|
||||
__nexumiTicketsQueueEvents?: QueueEvents;
|
||||
};
|
||||
|
||||
function queueConnection() {
|
||||
@@ -114,6 +117,13 @@ export function getSelfrolesQueue(): Queue {
|
||||
return globalForQueues.__nexumiSelfrolesQueue;
|
||||
}
|
||||
|
||||
export function getTicketsQueue(): Queue {
|
||||
globalForQueues.__nexumiTicketsQueue ??= new Queue(ticketsQueueName, {
|
||||
connection: queueConnection()
|
||||
});
|
||||
return globalForQueues.__nexumiTicketsQueue;
|
||||
}
|
||||
|
||||
/**
|
||||
* QueueEvents instances are only needed for jobs where the dashboard needs
|
||||
* to wait for the bot to finish (e.g. ending a giveaway so the winners list
|
||||
@@ -161,6 +171,13 @@ export function getVerificationQueueEvents(): QueueEvents {
|
||||
return globalForQueues.__nexumiVerificationQueueEvents;
|
||||
}
|
||||
|
||||
export function getTicketsQueueEvents(): QueueEvents {
|
||||
globalForQueues.__nexumiTicketsQueueEvents ??= new QueueEvents(ticketsQueueName, {
|
||||
connection: queueEventsConnection()
|
||||
});
|
||||
return globalForQueues.__nexumiTicketsQueueEvents;
|
||||
}
|
||||
|
||||
const DEFAULT_WAIT_TIMEOUT_MS = 15_000;
|
||||
|
||||
/**
|
||||
|
||||
@@ -668,29 +668,36 @@
|
||||
},
|
||||
"tickets": {
|
||||
"title": "Tickets",
|
||||
"description": "Lege fest, wie Support-Tickets erstellt und verwaltet werden.",
|
||||
"description": "Lege fest, wie Support-Tickets erstellt werden. Beim Speichern (aktiviert, mit Panel-Kanal und mindestens einer Kategorie) wird das Ticket-Panel im gewählten Kanal gepostet bzw. aktualisiert.",
|
||||
"enabledLabel": "Ticket-Modul aktiviert",
|
||||
"mode": "Ticket-Modus",
|
||||
"modes": {
|
||||
"CHANNEL": "Privater Kanal",
|
||||
"THREAD": "Privater Thread"
|
||||
},
|
||||
"categoryId": "Ticket-Kategorie-ID",
|
||||
"panelChannelId": "Panel-Kanal",
|
||||
"panelChannelHint": "Kanal, in dem das Ticket-Panel mit Kategorie-Buttons erscheint.",
|
||||
"categoryId": "Discord-Kategorie für Ticket-Kanäle",
|
||||
"logChannelId": "Log-Kanal",
|
||||
"inactivityHours": "Automatisch schließen nach Inaktivität (Stunden)",
|
||||
"transcriptDm": "Transkript per DM an Ticket-Ersteller",
|
||||
"transcriptDmHint": "Sendet dem Nutzer nach Schließung ein Transkript per DM.",
|
||||
"ratingEnabled": "Nach Schließung um Bewertung bitten",
|
||||
"categoriesTitle": "Ticket-Kategorien",
|
||||
"categoriesDescription": "Kategorien, die Mitglieder beim Öffnen eines Tickets auswählen können.",
|
||||
"categoriesDescription": "Kategorien, die Mitglieder beim Öffnen eines Tickets auswählen können. Änderungen aktualisieren ein vorhandenes Panel.",
|
||||
"categoriesEmpty": "Noch keine Ticket-Kategorien konfiguriert.",
|
||||
"categoryName": "Name",
|
||||
"categoryEmoji": "Emoji",
|
||||
"categoryDescription": "Beschreibung",
|
||||
"supportRoleIds": "Support-Rollen-IDs",
|
||||
"supportRoleIds": "Support-Rollen",
|
||||
"idsPlaceholder": "Eine oder mehrere IDs, kommagetrennt",
|
||||
"addCategory": "Kategorie hinzufügen",
|
||||
"nameRequired": "Ein Name ist erforderlich."
|
||||
"nameRequired": "Ein Name ist erforderlich.",
|
||||
"errors": {
|
||||
"panelTimeout": "Der Bot hat das Ticket-Panel nicht rechtzeitig bestätigt. Es wird möglicherweise noch gepostet — prüfe den Kanal und die Bot-Berechtigungen, dann speichere erneut.",
|
||||
"panelNotPosted": "Das Ticket-Panel konnte nicht im Discord-Kanal erstellt werden. Prüfe Bot-Berechtigungen (Kanal sehen, Nachrichten senden, Embeds).",
|
||||
"noCategories": "Lege mindestens eine Ticket-Kategorie an, bevor das Panel gepostet werden kann."
|
||||
}
|
||||
},
|
||||
"giveaways": {
|
||||
"formIncomplete": "Bitte Kanal, Preis und Enddatum ausfüllen.",
|
||||
|
||||
@@ -668,29 +668,36 @@
|
||||
},
|
||||
"tickets": {
|
||||
"title": "Tickets",
|
||||
"description": "Configure how support tickets are created and managed.",
|
||||
"description": "Configure how support tickets are created. Saving (enabled, with panel channel and at least one category) posts or updates the ticket panel in the selected channel.",
|
||||
"enabledLabel": "Tickets module enabled",
|
||||
"mode": "Ticket mode",
|
||||
"modes": {
|
||||
"CHANNEL": "Private channel",
|
||||
"THREAD": "Private thread"
|
||||
},
|
||||
"categoryId": "Ticket category ID",
|
||||
"panelChannelId": "Panel channel",
|
||||
"panelChannelHint": "Channel where the ticket panel with category buttons is posted.",
|
||||
"categoryId": "Discord category for ticket channels",
|
||||
"logChannelId": "Log channel",
|
||||
"inactivityHours": "Auto-close after inactivity (hours)",
|
||||
"transcriptDm": "DM transcript to ticket creator",
|
||||
"transcriptDmHint": "Sends a transcript to the user via DM once their ticket is closed.",
|
||||
"ratingEnabled": "Ask for a rating after closing",
|
||||
"categoriesTitle": "Ticket categories",
|
||||
"categoriesDescription": "Categories members can choose from when opening a ticket.",
|
||||
"categoriesDescription": "Categories members can choose from when opening a ticket. Changes update an existing panel.",
|
||||
"categoriesEmpty": "No ticket categories configured yet.",
|
||||
"categoryName": "Name",
|
||||
"categoryEmoji": "Emoji",
|
||||
"categoryDescription": "Description",
|
||||
"supportRoleIds": "Support role IDs",
|
||||
"supportRoleIds": "Support roles",
|
||||
"idsPlaceholder": "One or more IDs, comma-separated",
|
||||
"addCategory": "Add category",
|
||||
"nameRequired": "A name is required."
|
||||
"nameRequired": "A name is required.",
|
||||
"errors": {
|
||||
"panelTimeout": "The bot did not confirm the ticket panel in time. It may still be posting — check the channel and bot permissions, then save again.",
|
||||
"panelNotPosted": "The ticket panel could not be created in the Discord channel. Check bot permissions (View Channel, Send Messages, Embed Links).",
|
||||
"noCategories": "Create at least one ticket category before the panel can be posted."
|
||||
}
|
||||
},
|
||||
"giveaways": {
|
||||
"formIncomplete": "Please fill in the channel, prize and end date.",
|
||||
|
||||
Reference in New Issue
Block a user