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