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:
smueller
2026-07-22 15:22:56 +02:00
parent 677142672f
commit 429f974bfc
11 changed files with 669 additions and 0 deletions

View 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);
}

View 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);
}

View 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 };

View 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);
}

View 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);
}

View 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);
}

View 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;
}