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.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
44
apps/webui/src/app/api/guilds/[guildId]/guildbackup/route.ts
Normal file
44
apps/webui/src/app/api/guilds/[guildId]/guildbackup/route.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
120
apps/webui/src/lib/guild-backup.ts
Normal file
120
apps/webui/src/lib/guild-backup.ts
Normal file
@@ -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<T>(path: string): Promise<T> {
|
||||
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<GuildBackupPayload> {
|
||||
const [guild, roles, channels] = await Promise.all([
|
||||
discordBotFetch<DiscordGuildRest>(`/guilds/${guildId}`),
|
||||
discordBotFetch<DiscordRoleRest[]>(`/guilds/${guildId}/roles`),
|
||||
discordBotFetch<DiscordChannelRest[]>(`/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);
|
||||
}
|
||||
40
apps/webui/src/lib/module-configs/birthdays.ts
Normal file
40
apps/webui/src/lib/module-configs/birthdays.ts
Normal file
@@ -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<ReturnType<typeof ensureBirthdayConfig>>): BirthdayConfigDashboard {
|
||||
return {
|
||||
enabled: config.enabled,
|
||||
channelId: config.channelId ?? '',
|
||||
roleId: config.roleId ?? '',
|
||||
message: config.message,
|
||||
timezone: config.timezone
|
||||
};
|
||||
}
|
||||
|
||||
export async function getBirthdayDashboard(guildId: string): Promise<BirthdayConfigDashboard> {
|
||||
const config = await ensureBirthdayConfig(guildId);
|
||||
return toDashboard(config);
|
||||
}
|
||||
|
||||
export async function updateBirthdayDashboard(
|
||||
guildId: string,
|
||||
patch: BirthdayConfigDashboardPatch
|
||||
): Promise<BirthdayConfigDashboard> {
|
||||
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);
|
||||
}
|
||||
75
apps/webui/src/lib/module-configs/guildbackup.ts
Normal file
75
apps/webui/src/lib/module-configs/guildbackup.ts
Normal file
@@ -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<void> {
|
||||
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<GuildBackupDashboard[]> {
|
||||
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<GuildBackup | null> {
|
||||
return prisma.guildBackup.findFirst({ where: { id: backupId, guildId } });
|
||||
}
|
||||
|
||||
export async function createGuildBackupDashboard(
|
||||
guildId: string,
|
||||
name: string,
|
||||
createdById: string
|
||||
): Promise<GuildBackupDashboard> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
await guildBackupQueue.add(
|
||||
'restoreGuildBackup',
|
||||
{ backupId, guildId, requestedById },
|
||||
{
|
||||
jobId: `guild-backup-restore-${backupId}`,
|
||||
removeOnComplete: true,
|
||||
removeOnFail: 25
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export { guildBackupQueueName };
|
||||
40
apps/webui/src/lib/module-configs/starboard.ts
Normal file
40
apps/webui/src/lib/module-configs/starboard.ts
Normal file
@@ -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<ReturnType<typeof ensureStarboardConfig>>): 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<StarboardConfigDashboard> {
|
||||
const config = await ensureStarboardConfig(guildId);
|
||||
return toDashboard(config);
|
||||
}
|
||||
|
||||
export async function updateStarboardDashboard(
|
||||
guildId: string,
|
||||
patch: StarboardConfigDashboardPatch
|
||||
): Promise<StarboardConfigDashboard> {
|
||||
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);
|
||||
}
|
||||
49
apps/webui/src/lib/module-configs/stats.ts
Normal file
49
apps/webui/src/lib/module-configs/stats.ts
Normal file
@@ -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<ReturnType<typeof ensureStatsConfig>>): 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<StatsConfigDashboard> {
|
||||
const config = await ensureStatsConfig(guildId);
|
||||
return toDashboard(config);
|
||||
}
|
||||
|
||||
export async function updateStatsDashboard(
|
||||
guildId: string,
|
||||
patch: StatsConfigDashboardPatch
|
||||
): Promise<StatsConfigDashboard> {
|
||||
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);
|
||||
}
|
||||
40
apps/webui/src/lib/module-configs/tempvoice.ts
Normal file
40
apps/webui/src/lib/module-configs/tempvoice.ts
Normal file
@@ -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<ReturnType<typeof ensureTempVoiceConfig>>): TempVoiceConfigDashboard {
|
||||
return {
|
||||
enabled: config.enabled,
|
||||
hubChannelId: config.hubChannelId ?? '',
|
||||
categoryId: config.categoryId ?? '',
|
||||
defaultName: config.defaultName,
|
||||
defaultLimit: config.defaultLimit
|
||||
};
|
||||
}
|
||||
|
||||
export async function getTempVoiceDashboard(guildId: string): Promise<TempVoiceConfigDashboard> {
|
||||
const config = await ensureTempVoiceConfig(guildId);
|
||||
return toDashboard(config);
|
||||
}
|
||||
|
||||
export async function updateTempVoiceDashboard(
|
||||
guildId: string,
|
||||
patch: TempVoiceConfigDashboardPatch
|
||||
): Promise<TempVoiceConfigDashboard> {
|
||||
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);
|
||||
}
|
||||
131
apps/webui/src/lib/module-configs/tickets.ts
Normal file
131
apps/webui/src/lib/module-configs/tickets.ts
Normal file
@@ -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<ReturnType<typeof ensureTicketConfig>>): 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<TicketConfigDashboard> {
|
||||
const config = await ensureTicketConfig(guildId);
|
||||
return toConfigDashboard(config);
|
||||
}
|
||||
|
||||
export async function updateTicketConfigDashboard(
|
||||
guildId: string,
|
||||
patch: TicketConfigDashboardPatch
|
||||
): Promise<TicketConfigDashboard> {
|
||||
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<TicketCategoryDashboard[]> {
|
||||
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<TicketCategoryDashboard> {
|
||||
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<TicketCategoryDashboard | null> {
|
||||
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<boolean> {
|
||||
const result = await prisma.ticketCategory.deleteMany({ where: { id: categoryId, guildId } });
|
||||
return result.count > 0;
|
||||
}
|
||||
Reference in New Issue
Block a user