From 429f974bfcf330d3b7d0237ed8711a36b7aa209d Mon Sep 17 00:00:00 2001 From: smueller Date: Wed, 22 Jul 2026 15:22:56 +0200 Subject: [PATCH] Refactor giveaway job processing and update environment documentation - Improved the `giveawayCreate` job handling in the bot's job processing, ensuring consistent functionality with existing commands. - Enhanced the `.env.example` file to provide clearer instructions on the usage of `BOT_TOKEN` for both the bot and WebUI. - Added the `bullmq` dependency to the WebUI package for better job management capabilities. --- .../guildbackup/[backupId]/download/route.ts | 29 ++++ .../guildbackup/[backupId]/restore/route.ts | 47 +++++++ .../[guildId]/guildbackup/[backupId]/route.ts | 54 ++++++++ .../api/guilds/[guildId]/guildbackup/route.ts | 44 ++++++ apps/webui/src/lib/guild-backup.ts | 120 ++++++++++++++++ .../webui/src/lib/module-configs/birthdays.ts | 40 ++++++ .../src/lib/module-configs/guildbackup.ts | 75 ++++++++++ .../webui/src/lib/module-configs/starboard.ts | 40 ++++++ apps/webui/src/lib/module-configs/stats.ts | 49 +++++++ .../webui/src/lib/module-configs/tempvoice.ts | 40 ++++++ apps/webui/src/lib/module-configs/tickets.ts | 131 ++++++++++++++++++ 11 files changed, 669 insertions(+) create mode 100644 apps/webui/src/app/api/guilds/[guildId]/guildbackup/[backupId]/download/route.ts create mode 100644 apps/webui/src/app/api/guilds/[guildId]/guildbackup/[backupId]/restore/route.ts create mode 100644 apps/webui/src/app/api/guilds/[guildId]/guildbackup/[backupId]/route.ts create mode 100644 apps/webui/src/app/api/guilds/[guildId]/guildbackup/route.ts create mode 100644 apps/webui/src/lib/guild-backup.ts create mode 100644 apps/webui/src/lib/module-configs/birthdays.ts create mode 100644 apps/webui/src/lib/module-configs/guildbackup.ts create mode 100644 apps/webui/src/lib/module-configs/starboard.ts create mode 100644 apps/webui/src/lib/module-configs/stats.ts create mode 100644 apps/webui/src/lib/module-configs/tempvoice.ts create mode 100644 apps/webui/src/lib/module-configs/tickets.ts diff --git a/apps/webui/src/app/api/guilds/[guildId]/guildbackup/[backupId]/download/route.ts b/apps/webui/src/app/api/guilds/[guildId]/guildbackup/[backupId]/download/route.ts new file mode 100644 index 0000000..0691916 --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/guildbackup/[backupId]/download/route.ts @@ -0,0 +1,29 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; +import { getGuildBackupRecord } from '@/lib/module-configs/guildbackup'; + +interface RouteParams { + params: Promise<{ guildId: string; backupId: string }>; +} + +export async function GET(_request: NextRequest, { params }: RouteParams) { + const { guildId, backupId } = await params; + try { + await requireGuildAccess(guildId); + const backup = await getGuildBackupRecord(guildId, backupId); + if (!backup) { + return NextResponse.json({ error: 'Backup not found' }, { status: 404 }); + } + + const filename = `nexumi-backup-${backup.name.replace(/[^a-z0-9-_]+/gi, '_')}-${backup.id}.json`; + return new NextResponse(JSON.stringify(backup.payload, null, 2), { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Content-Disposition': `attachment; filename="${filename}"` + } + }); + } catch (error) { + return toApiErrorResponse(error); + } +} diff --git a/apps/webui/src/app/api/guilds/[guildId]/guildbackup/[backupId]/restore/route.ts b/apps/webui/src/app/api/guilds/[guildId]/guildbackup/[backupId]/restore/route.ts new file mode 100644 index 0000000..94e0c62 --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/guildbackup/[backupId]/restore/route.ts @@ -0,0 +1,47 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import { ForbiddenError, requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; +import { writeDashboardAudit } from '@/lib/audit'; +import { fetchDiscordUserGuildsCached } from '@/lib/discord-oauth'; +import { enqueueGuildBackupRestore, getGuildBackupRecord } from '@/lib/module-configs/guildbackup'; +import { prisma } from '@/lib/prisma'; + +interface RouteParams { + params: Promise<{ guildId: string; backupId: string }>; +} + +/** + * Restore is intentionally restricted to the Discord server owner, per SPEC + * ("Restore nur durch Server-Owner mit doppelter Bestätigung"). The double + * confirmation itself happens client-side before this request is sent. + */ +export async function POST(_request: NextRequest, { params }: RouteParams) { + const { guildId, backupId } = await params; + try { + const session = await requireGuildAccess(guildId); + + const guilds = await fetchDiscordUserGuildsCached(session.accessToken); + const guild = guilds.find((entry) => entry.id === guildId); + if (!guild?.owner) { + throw new ForbiddenError('Only the Discord server owner can restore a backup'); + } + + const backup = await getGuildBackupRecord(guildId, backupId); + if (!backup) { + return NextResponse.json({ error: 'Backup not found' }, { status: 404 }); + } + + await enqueueGuildBackupRestore(backupId, guildId, session.user.id); + + await writeDashboardAudit(prisma, { + guildId, + actorUserId: session.user.id, + action: 'guildbackup.restore', + path: `/dashboard/${guildId}/backup`, + after: { backupId } + }); + + return NextResponse.json({ ok: true, queued: true }); + } catch (error) { + return toApiErrorResponse(error); + } +} diff --git a/apps/webui/src/app/api/guilds/[guildId]/guildbackup/[backupId]/route.ts b/apps/webui/src/app/api/guilds/[guildId]/guildbackup/[backupId]/route.ts new file mode 100644 index 0000000..ef340a0 --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/guildbackup/[backupId]/route.ts @@ -0,0 +1,54 @@ +import { GuildBackupPayloadSchema } from '@nexumi/shared'; +import { type NextRequest, NextResponse } from 'next/server'; +import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; +import { writeDashboardAudit } from '@/lib/audit'; +import { deleteGuildBackupDashboard, getGuildBackupRecord } from '@/lib/module-configs/guildbackup'; +import { prisma } from '@/lib/prisma'; + +interface RouteParams { + params: Promise<{ guildId: string; backupId: string }>; +} + +export async function GET(_request: NextRequest, { params }: RouteParams) { + const { guildId, backupId } = await params; + try { + await requireGuildAccess(guildId); + const backup = await getGuildBackupRecord(guildId, backupId); + if (!backup) { + return NextResponse.json({ error: 'Backup not found' }, { status: 404 }); + } + const parsed = GuildBackupPayloadSchema.safeParse(backup.payload); + return NextResponse.json({ + id: backup.id, + name: backup.name, + createdById: backup.createdById, + createdAt: backup.createdAt.toISOString(), + payload: parsed.success ? parsed.data : null + }); + } catch (error) { + return toApiErrorResponse(error); + } +} + +export async function DELETE(_request: NextRequest, { params }: RouteParams) { + const { guildId, backupId } = await params; + try { + const session = await requireGuildAccess(guildId); + const deleted = await deleteGuildBackupDashboard(guildId, backupId); + if (!deleted) { + return NextResponse.json({ error: 'Backup not found' }, { status: 404 }); + } + + await writeDashboardAudit(prisma, { + guildId, + actorUserId: session.user.id, + action: 'guildbackup.delete', + path: `/dashboard/${guildId}/backup`, + before: { backupId } + }); + + return NextResponse.json({ ok: true }); + } catch (error) { + return toApiErrorResponse(error); + } +} diff --git a/apps/webui/src/app/api/guilds/[guildId]/guildbackup/route.ts b/apps/webui/src/app/api/guilds/[guildId]/guildbackup/route.ts new file mode 100644 index 0000000..1edadf1 --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/guildbackup/route.ts @@ -0,0 +1,44 @@ +import { GuildBackupCreateDashboardSchema } from '@nexumi/shared'; +import { type NextRequest, NextResponse } from 'next/server'; +import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; +import { writeDashboardAudit } from '@/lib/audit'; +import { createGuildBackupDashboard, listGuildBackupsDashboard } from '@/lib/module-configs/guildbackup'; +import { prisma } from '@/lib/prisma'; + +interface RouteParams { + params: Promise<{ guildId: string }>; +} + +export async function GET(_request: NextRequest, { params }: RouteParams) { + const { guildId } = await params; + try { + await requireGuildAccess(guildId); + const backups = await listGuildBackupsDashboard(guildId); + return NextResponse.json({ backups }); + } catch (error) { + return toApiErrorResponse(error); + } +} + +export async function POST(request: NextRequest, { params }: RouteParams) { + const { guildId } = await params; + try { + const session = await requireGuildAccess(guildId); + const body = await request.json(); + const input = GuildBackupCreateDashboardSchema.parse(body); + + const backup = await createGuildBackupDashboard(guildId, input.name, session.user.id); + + await writeDashboardAudit(prisma, { + guildId, + actorUserId: session.user.id, + action: 'guildbackup.create', + path: `/dashboard/${guildId}/backup`, + after: backup + }); + + return NextResponse.json(backup, { status: 201 }); + } catch (error) { + return toApiErrorResponse(error); + } +} diff --git a/apps/webui/src/lib/guild-backup.ts b/apps/webui/src/lib/guild-backup.ts new file mode 100644 index 0000000..b5576bf --- /dev/null +++ b/apps/webui/src/lib/guild-backup.ts @@ -0,0 +1,120 @@ +import { GuildBackupPayloadSchema, type GuildBackupPayload } from '@nexumi/shared'; +import { env } from './env'; + +const DISCORD_API_BASE = 'https://discord.com/api/v10'; + +async function discordBotFetch(path: string): Promise { + const response = await fetch(`${DISCORD_API_BASE}${path}`, { + headers: { Authorization: `Bot ${env.BOT_TOKEN}` }, + cache: 'no-store' + }); + if (!response.ok) { + throw new Error(`Discord API request to ${path} failed with status ${response.status}`); + } + return (await response.json()) as T; +} + +interface DiscordRoleRest { + id: string; + name: string; + color: number; + hoist: boolean; + mentionable: boolean; + permissions: string; + position: number; +} + +interface DiscordOverwriteRest { + id: string; + type: number; + allow: string; + deny: string; +} + +interface DiscordChannelRest { + id: string; + name: string; + type: number; + parent_id: string | null; + position: number; + topic?: string | null; + nsfw?: boolean; + rate_limit_per_user?: number; + bitrate?: number; + user_limit?: number; + permission_overwrites?: DiscordOverwriteRest[]; +} + +interface DiscordGuildRest { + name: string; + verification_level: number; + explicit_content_filter: number; + default_message_notifications: number; + afk_timeout: number; +} + +/** + * Builds a guild backup payload via narrow Discord REST calls (bot token, + * no discord.js Client/gateway). This mirrors + * `apps/bot/src/modules/guildbackup/service.ts#buildPayloadFromGuild` field + * for field, since the WebUI process has no live guild cache to read from. + */ +export async function buildGuildBackupPayloadViaRest(guildId: string): Promise { + const [guild, roles, channels] = await Promise.all([ + discordBotFetch(`/guilds/${guildId}`), + discordBotFetch(`/guilds/${guildId}/roles`), + discordBotFetch(`/guilds/${guildId}/channels`) + ]); + + const payload: GuildBackupPayload = { + version: 1, + guildName: guild.name, + roles: roles + .slice() + .sort((a, b) => a.position - b.position) + .map((role) => ({ + id: role.id, + name: role.name, + color: role.color, + hoist: role.hoist, + mentionable: role.mentionable, + permissions: role.permissions, + position: role.position + })), + channels: channels + .slice() + .sort((a, b) => { + if (a.parent_id !== b.parent_id) { + return (a.parent_id ?? '').localeCompare(b.parent_id ?? ''); + } + return a.position - b.position; + }) + .map((channel) => ({ + id: channel.id, + name: channel.name, + type: channel.type, + parentId: channel.parent_id ?? null, + position: channel.position, + topic: channel.topic ?? undefined, + nsfw: channel.nsfw ?? undefined, + rateLimitPerUser: channel.rate_limit_per_user ?? undefined, + bitrate: channel.bitrate ?? undefined, + userLimit: channel.user_limit ?? undefined, + permissionOverwrites: (channel.permission_overwrites ?? []).map((overwrite) => ({ + id: overwrite.id, + type: overwrite.type, + allow: overwrite.allow, + deny: overwrite.deny + })) + })), + settings: { + name: guild.name, + verificationLevel: guild.verification_level, + explicitContentFilter: guild.explicit_content_filter, + defaultMessageNotifications: guild.default_message_notifications, + afkTimeout: guild.afk_timeout + } + }; + + return GuildBackupPayloadSchema.parse(payload); +} diff --git a/apps/webui/src/lib/module-configs/birthdays.ts b/apps/webui/src/lib/module-configs/birthdays.ts new file mode 100644 index 0000000..34792fa --- /dev/null +++ b/apps/webui/src/lib/module-configs/birthdays.ts @@ -0,0 +1,40 @@ +import type { BirthdayConfigDashboard, BirthdayConfigDashboardPatch } from '@nexumi/shared'; +import { prisma } from '../prisma'; + +async function ensureBirthdayConfig(guildId: string) { + await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } }); + return prisma.birthdayConfig.upsert({ where: { guildId }, update: {}, create: { guildId } }); +} + +function toDashboard(config: Awaited>): BirthdayConfigDashboard { + return { + enabled: config.enabled, + channelId: config.channelId ?? '', + roleId: config.roleId ?? '', + message: config.message, + timezone: config.timezone + }; +} + +export async function getBirthdayDashboard(guildId: string): Promise { + const config = await ensureBirthdayConfig(guildId); + return toDashboard(config); +} + +export async function updateBirthdayDashboard( + guildId: string, + patch: BirthdayConfigDashboardPatch +): Promise { + await ensureBirthdayConfig(guildId); + + const { channelId, roleId, ...rest } = patch; + const updated = await prisma.birthdayConfig.update({ + where: { guildId }, + data: { + ...rest, + ...(channelId !== undefined ? { channelId: channelId === '' ? null : channelId } : {}), + ...(roleId !== undefined ? { roleId: roleId === '' ? null : roleId } : {}) + } + }); + return toDashboard(updated); +} diff --git a/apps/webui/src/lib/module-configs/guildbackup.ts b/apps/webui/src/lib/module-configs/guildbackup.ts new file mode 100644 index 0000000..d0d2f16 --- /dev/null +++ b/apps/webui/src/lib/module-configs/guildbackup.ts @@ -0,0 +1,75 @@ +import { GuildBackupPayloadSchema, type GuildBackupDashboard } from '@nexumi/shared'; +import type { GuildBackup } from '@prisma/client'; +import { buildGuildBackupPayloadViaRest } from '../guild-backup'; +import { prisma } from '../prisma'; +import { guildBackupQueue, guildBackupQueueName } from '../queues'; + +async function ensureGuild(guildId: string): Promise { + await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } }); +} + +function toDashboard(backup: GuildBackup): GuildBackupDashboard { + const parsed = GuildBackupPayloadSchema.safeParse(backup.payload); + return { + id: backup.id, + name: backup.name, + createdById: backup.createdById, + createdAt: backup.createdAt.toISOString(), + roleCount: parsed.success ? parsed.data.roles.length : 0, + channelCount: parsed.success ? parsed.data.channels.length : 0 + }; +} + +export async function listGuildBackupsDashboard(guildId: string): Promise { + const backups = await prisma.guildBackup.findMany({ + where: { guildId }, + orderBy: { createdAt: 'desc' }, + take: 15 + }); + return backups.map(toDashboard); +} + +export async function getGuildBackupRecord(guildId: string, backupId: string): Promise { + return prisma.guildBackup.findFirst({ where: { id: backupId, guildId } }); +} + +export async function createGuildBackupDashboard( + guildId: string, + name: string, + createdById: string +): Promise { + await ensureGuild(guildId); + const payload = await buildGuildBackupPayloadViaRest(guildId); + const backup = await prisma.guildBackup.create({ + data: { guildId, name, createdById, payload } + }); + return toDashboard(backup); +} + +export async function deleteGuildBackupDashboard(guildId: string, backupId: string): Promise { + const result = await prisma.guildBackup.deleteMany({ where: { id: backupId, guildId } }); + return result.count > 0; +} + +/** + * Enqueues the same `restoreGuildBackup` BullMQ job the bot's `/backup + * restore` command uses, so the actual restore (creating roles/channels via + * discord.js) always runs on the bot process where the guild cache lives. + */ +export async function enqueueGuildBackupRestore( + backupId: string, + guildId: string, + requestedById: string +): Promise { + await guildBackupQueue.add( + 'restoreGuildBackup', + { backupId, guildId, requestedById }, + { + jobId: `guild-backup-restore-${backupId}`, + removeOnComplete: true, + removeOnFail: 25 + } + ); +} + +export { guildBackupQueueName }; diff --git a/apps/webui/src/lib/module-configs/starboard.ts b/apps/webui/src/lib/module-configs/starboard.ts new file mode 100644 index 0000000..da5a98e --- /dev/null +++ b/apps/webui/src/lib/module-configs/starboard.ts @@ -0,0 +1,40 @@ +import type { StarboardConfigDashboard, StarboardConfigDashboardPatch } from '@nexumi/shared'; +import { prisma } from '../prisma'; + +async function ensureStarboardConfig(guildId: string) { + await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } }); + return prisma.starboardConfig.upsert({ where: { guildId }, update: {}, create: { guildId } }); +} + +function toDashboard(config: Awaited>): StarboardConfigDashboard { + return { + enabled: config.enabled, + channelId: config.channelId ?? '', + emoji: config.emoji, + threshold: config.threshold, + allowSelfStar: config.allowSelfStar, + ignoredChannelIds: config.ignoredChannelIds + }; +} + +export async function getStarboardDashboard(guildId: string): Promise { + const config = await ensureStarboardConfig(guildId); + return toDashboard(config); +} + +export async function updateStarboardDashboard( + guildId: string, + patch: StarboardConfigDashboardPatch +): Promise { + await ensureStarboardConfig(guildId); + + const { channelId, ...rest } = patch; + const updated = await prisma.starboardConfig.update({ + where: { guildId }, + data: { + ...rest, + ...(channelId !== undefined ? { channelId: channelId === '' ? null : channelId } : {}) + } + }); + return toDashboard(updated); +} diff --git a/apps/webui/src/lib/module-configs/stats.ts b/apps/webui/src/lib/module-configs/stats.ts new file mode 100644 index 0000000..720e22f --- /dev/null +++ b/apps/webui/src/lib/module-configs/stats.ts @@ -0,0 +1,49 @@ +import type { StatsConfigDashboard, StatsConfigDashboardPatch } from '@nexumi/shared'; +import { prisma } from '../prisma'; + +async function ensureStatsConfig(guildId: string) { + await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } }); + return prisma.statsConfig.upsert({ where: { guildId }, update: {}, create: { guildId } }); +} + +function toDashboard(config: Awaited>): StatsConfigDashboard { + return { + enabled: config.enabled, + membersChannelId: config.membersChannelId ?? '', + onlineChannelId: config.onlineChannelId ?? '', + boostsChannelId: config.boostsChannelId ?? '', + membersTemplate: config.membersTemplate, + onlineTemplate: config.onlineTemplate, + boostsTemplate: config.boostsTemplate + }; +} + +export async function getStatsDashboard(guildId: string): Promise { + const config = await ensureStatsConfig(guildId); + return toDashboard(config); +} + +export async function updateStatsDashboard( + guildId: string, + patch: StatsConfigDashboardPatch +): Promise { + await ensureStatsConfig(guildId); + + const { membersChannelId, onlineChannelId, boostsChannelId, ...rest } = patch; + const updated = await prisma.statsConfig.update({ + where: { guildId }, + data: { + ...rest, + ...(membersChannelId !== undefined + ? { membersChannelId: membersChannelId === '' ? null : membersChannelId } + : {}), + ...(onlineChannelId !== undefined + ? { onlineChannelId: onlineChannelId === '' ? null : onlineChannelId } + : {}), + ...(boostsChannelId !== undefined + ? { boostsChannelId: boostsChannelId === '' ? null : boostsChannelId } + : {}) + } + }); + return toDashboard(updated); +} diff --git a/apps/webui/src/lib/module-configs/tempvoice.ts b/apps/webui/src/lib/module-configs/tempvoice.ts new file mode 100644 index 0000000..8d35a94 --- /dev/null +++ b/apps/webui/src/lib/module-configs/tempvoice.ts @@ -0,0 +1,40 @@ +import type { TempVoiceConfigDashboard, TempVoiceConfigDashboardPatch } from '@nexumi/shared'; +import { prisma } from '../prisma'; + +async function ensureTempVoiceConfig(guildId: string) { + await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } }); + return prisma.tempVoiceConfig.upsert({ where: { guildId }, update: {}, create: { guildId } }); +} + +function toDashboard(config: Awaited>): TempVoiceConfigDashboard { + return { + enabled: config.enabled, + hubChannelId: config.hubChannelId ?? '', + categoryId: config.categoryId ?? '', + defaultName: config.defaultName, + defaultLimit: config.defaultLimit + }; +} + +export async function getTempVoiceDashboard(guildId: string): Promise { + const config = await ensureTempVoiceConfig(guildId); + return toDashboard(config); +} + +export async function updateTempVoiceDashboard( + guildId: string, + patch: TempVoiceConfigDashboardPatch +): Promise { + await ensureTempVoiceConfig(guildId); + + const { hubChannelId, categoryId, ...rest } = patch; + const updated = await prisma.tempVoiceConfig.update({ + where: { guildId }, + data: { + ...rest, + ...(hubChannelId !== undefined ? { hubChannelId: hubChannelId === '' ? null : hubChannelId } : {}), + ...(categoryId !== undefined ? { categoryId: categoryId === '' ? null : categoryId } : {}) + } + }); + return toDashboard(updated); +} diff --git a/apps/webui/src/lib/module-configs/tickets.ts b/apps/webui/src/lib/module-configs/tickets.ts new file mode 100644 index 0000000..a27ca5f --- /dev/null +++ b/apps/webui/src/lib/module-configs/tickets.ts @@ -0,0 +1,131 @@ +import type { + TicketCategoryDashboard, + TicketCategoryDashboardCreate, + TicketCategoryDashboardUpdate, + TicketConfigDashboard, + TicketConfigDashboardPatch +} from '@nexumi/shared'; +import type { TicketCategory } from '@prisma/client'; +import { prisma } from '../prisma'; + +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 } }); +} + +function toConfigDashboard(config: Awaited>): TicketConfigDashboard { + return { + enabled: config.enabled, + mode: config.mode as TicketConfigDashboard['mode'], + categoryId: config.categoryId ?? '', + logChannelId: config.logChannelId ?? '', + transcriptDm: config.transcriptDm, + inactivityHours: config.inactivityHours, + ratingEnabled: config.ratingEnabled + }; +} + +export async function getTicketConfigDashboard(guildId: string): Promise { + const config = await ensureTicketConfig(guildId); + return toConfigDashboard(config); +} + +export async function updateTicketConfigDashboard( + guildId: string, + patch: TicketConfigDashboardPatch +): Promise { + await ensureTicketConfig(guildId); + + const { categoryId, logChannelId, ...rest } = patch; + const updated = await prisma.ticketConfig.update({ + where: { guildId }, + data: { + ...rest, + ...(categoryId !== undefined ? { categoryId: categoryId === '' ? null : categoryId } : {}), + ...(logChannelId !== undefined ? { logChannelId: logChannelId === '' ? null : logChannelId } : {}) + } + }); + return toConfigDashboard(updated); +} + +function toCategoryDashboard(category: TicketCategory): TicketCategoryDashboard { + return { + id: category.id, + name: category.name, + description: category.description, + emoji: category.emoji, + supportRoleIds: category.supportRoleIds + }; +} + +export async function listTicketCategories(guildId: string): Promise { + const categories = await prisma.ticketCategory.findMany({ + where: { guildId }, + orderBy: { createdAt: 'asc' } + }); + return categories.map(toCategoryDashboard); +} + +export class TicketCategoryConflictError extends Error { + constructor() { + super('A ticket category with this name already exists'); + } +} + +export async function createTicketCategory( + guildId: string, + input: TicketCategoryDashboardCreate +): Promise { + await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } }); + try { + const category = await prisma.ticketCategory.create({ + data: { + guildId, + name: input.name, + description: input.description ?? null, + emoji: input.emoji ?? null, + supportRoleIds: input.supportRoleIds + } + }); + return toCategoryDashboard(category); + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'P2002') { + throw new TicketCategoryConflictError(); + } + throw error; + } +} + +export async function updateTicketCategory( + guildId: string, + categoryId: string, + patch: TicketCategoryDashboardUpdate +): Promise { + const existing = await prisma.ticketCategory.findFirst({ where: { id: categoryId, guildId } }); + if (!existing) { + return null; + } + + try { + const updated = await prisma.ticketCategory.update({ + where: { id: categoryId }, + data: { + ...(patch.name !== undefined ? { name: patch.name } : {}), + ...(patch.description !== undefined ? { description: patch.description } : {}), + ...(patch.emoji !== undefined ? { emoji: patch.emoji } : {}), + ...(patch.supportRoleIds !== undefined ? { supportRoleIds: patch.supportRoleIds } : {}) + } + }); + return toCategoryDashboard(updated); + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'P2002') { + throw new TicketCategoryConflictError(); + } + throw error; + } +} + +export async function deleteTicketCategory(guildId: string, categoryId: string): Promise { + const result = await prisma.ticketCategory.deleteMany({ where: { id: categoryId, guildId } }); + return result.count > 0; +}