Enhance environment configuration and expand Prisma schema for bot management

- Added OWNER_USER_IDS to .env.example for specifying global bot owners.
- Introduced new models in the Prisma schema: CommandOverride, OwnerTeamMember, GlobalUserBlacklist, GuildBlacklist, FeatureFlag, BotPresenceConfig, ChangelogEntry, and OwnerAuditLog to support advanced bot management features.
- Updated env.ts to preprocess OWNER_USER_IDS for better handling of global bot owner IDs.
- Modified ModulePlaceholderPage to ensure proper routing for remaining dashboard modules.
This commit is contained in:
smueller
2026-07-22 14:51:56 +02:00
parent 946283dfba
commit 1de28fa494
54 changed files with 3564 additions and 3 deletions

View File

@@ -12,7 +12,25 @@ const EnvSchema = z.object({
WEBUI_URL: z.string().url().default('http://localhost:3000'),
SESSION_SECRET: z.string().min(32, 'SESSION_SECRET must be at least 32 characters long'),
SENTRY_DSN: z.string().optional(),
DEFAULT_LOCALE: z.enum(['de', 'en']).default('de')
DEFAULT_LOCALE: z.enum(['de', 'en']).default('de'),
/**
* Comma-separated Discord user IDs that are treated as global bot owners
* for the (future) owner panel. Optional for now.
*/
OWNER_USER_IDS: z.preprocess(
(value) => (typeof value === 'string' && value.trim() === '' ? undefined : value),
z
.string()
.optional()
.transform((value) =>
value
? value
.split(',')
.map((id) => id.trim())
.filter((id) => id.length > 0)
: []
)
)
});
export const env = EnvSchema.parse(process.env);

View File

@@ -0,0 +1,43 @@
import type { AutomodConfigDashboard, AutomodConfigDashboardPatch } from '@nexumi/shared';
import { prisma } from '../prisma';
async function ensureAutoModConfig(guildId: string) {
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
return prisma.autoModConfig.upsert({
where: { guildId },
update: {},
create: { guildId }
});
}
function toDashboard(config: Awaited<ReturnType<typeof ensureAutoModConfig>>): AutomodConfigDashboard {
return {
enabled: config.enabled,
antiRaidEnabled: config.antiRaidEnabled,
antiRaidJoinThreshold: config.antiRaidJoinThreshold,
antiRaidWindowSeconds: config.antiRaidWindowSeconds,
antiRaidAction: 'LOCKDOWN',
antiNukeEnabled: config.antiNukeEnabled,
antiNukeBanThreshold: config.antiNukeBanThreshold,
antiNukeChannelThreshold: config.antiNukeChannelThreshold,
antiNukeWindowSeconds: config.antiNukeWindowSeconds,
lockdownActive: config.lockdownActive
};
}
export async function getAutomodDashboard(guildId: string): Promise<AutomodConfigDashboard> {
const config = await ensureAutoModConfig(guildId);
return toDashboard(config);
}
export async function updateAutomodDashboard(
guildId: string,
patch: AutomodConfigDashboardPatch
): Promise<AutomodConfigDashboard> {
await ensureAutoModConfig(guildId);
const updated = await prisma.autoModConfig.update({
where: { guildId },
data: patch
});
return toDashboard(updated);
}

View File

@@ -0,0 +1,39 @@
import type { CaseDashboard, CaseListQuery } from '@nexumi/shared';
import type { Prisma } from '@prisma/client';
import { prisma } from '../prisma';
export async function listCases(guildId: string, query: CaseListQuery): Promise<CaseDashboard[]> {
const where: Prisma.CaseWhereInput = {
guildId,
deletedAt: null
};
if (query.action) {
where.action = query.action;
}
if (query.search) {
where.OR = [
{ targetUserId: { contains: query.search } },
{ moderatorId: { contains: query.search } },
{ reason: { contains: query.search, mode: 'insensitive' } }
];
}
const cases = await prisma.case.findMany({
where,
orderBy: { createdAt: 'desc' },
take: query.limit
});
return cases.map((entry) => ({
id: entry.id,
caseNumber: entry.caseNumber,
targetUserId: entry.targetUserId,
moderatorId: entry.moderatorId,
action: entry.action,
reason: entry.reason,
createdAt: entry.createdAt.toISOString(),
deletedAt: entry.deletedAt ? entry.deletedAt.toISOString() : null
}));
}

View File

@@ -0,0 +1,39 @@
import type { EconomyConfigDashboard, EconomyConfigDashboardPatch } from '@nexumi/shared';
import { prisma } from '../prisma';
async function ensureEconomyConfig(guildId: string) {
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
return prisma.economyConfig.upsert({
where: { guildId },
update: {},
create: { guildId }
});
}
function toDashboard(config: Awaited<ReturnType<typeof ensureEconomyConfig>>): EconomyConfigDashboard {
return {
enabled: config.enabled,
currencyName: config.currencyName,
currencySymbol: config.currencySymbol,
dailyAmount: config.dailyAmount,
weeklyAmount: config.weeklyAmount,
workMin: config.workMin,
workMax: config.workMax,
workCooldownSeconds: config.workCooldownSeconds,
startingBalance: config.startingBalance
};
}
export async function getEconomyDashboard(guildId: string): Promise<EconomyConfigDashboard> {
const config = await ensureEconomyConfig(guildId);
return toDashboard(config);
}
export async function updateEconomyDashboard(
guildId: string,
patch: EconomyConfigDashboardPatch
): Promise<EconomyConfigDashboard> {
await ensureEconomyConfig(guildId);
const updated = await prisma.economyConfig.update({ where: { guildId }, data: patch });
return toDashboard(updated);
}

View File

@@ -0,0 +1,29 @@
import type { FunConfigDashboard, FunConfigDashboardPatch } from '@nexumi/shared';
import { redis } from '../redis';
function funConfigKey(guildId: string): string {
return `fun:config:${guildId}`;
}
export async function getFunDashboard(guildId: string): Promise<FunConfigDashboard> {
const raw = await redis.get(funConfigKey(guildId));
if (!raw) {
return { enabled: true, mediaEnabled: true };
}
try {
const parsed = JSON.parse(raw) as Partial<FunConfigDashboard>;
return { enabled: parsed.enabled ?? true, mediaEnabled: parsed.mediaEnabled ?? true };
} catch {
return { enabled: true, mediaEnabled: true };
}
}
export async function updateFunDashboard(
guildId: string,
patch: FunConfigDashboardPatch
): Promise<FunConfigDashboard> {
const current = await getFunDashboard(guildId);
const next: FunConfigDashboard = { ...current, ...patch };
await redis.set(funConfigKey(guildId), JSON.stringify(next));
return next;
}

View File

@@ -0,0 +1,69 @@
import type { LevelingConfigDashboard, LevelingConfigDashboardPatch } from '@nexumi/shared';
import type { Prisma } from '@prisma/client';
import { prisma } from '../prisma';
async function ensureLevelingConfig(guildId: string) {
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
return prisma.levelingConfig.upsert({
where: { guildId },
update: {},
create: { guildId }
});
}
function toMultiplierMap(raw: unknown): Record<string, number> {
if (!raw || typeof raw !== 'object') {
return {};
}
const entries = Object.entries(raw as Record<string, unknown>).filter(
(entry): entry is [string, number] => typeof entry[1] === 'number'
);
return Object.fromEntries(entries);
}
function toDashboard(config: Awaited<ReturnType<typeof ensureLevelingConfig>>): LevelingConfigDashboard {
return {
enabled: config.enabled,
textXpMin: config.textXpMin,
textXpMax: config.textXpMax,
textCooldownSeconds: config.textCooldownSeconds,
voiceXpPerMinute: config.voiceXpPerMinute,
noXpChannelIds: config.noXpChannelIds,
noXpRoleIds: config.noXpRoleIds,
roleMultipliers: toMultiplierMap(config.roleMultipliers),
channelMultipliers: toMultiplierMap(config.channelMultipliers),
levelUpMode: config.levelUpMode as LevelingConfigDashboard['levelUpMode'],
levelUpChannelId: config.levelUpChannelId ?? '',
levelUpMessage: config.levelUpMessage,
stackRewards: config.stackRewards,
cardAccentColor: config.cardAccentColor,
cardBackgroundColor: config.cardBackgroundColor
};
}
export async function getLevelingDashboard(guildId: string): Promise<LevelingConfigDashboard> {
const config = await ensureLevelingConfig(guildId);
return toDashboard(config);
}
export async function updateLevelingDashboard(
guildId: string,
patch: LevelingConfigDashboardPatch
): Promise<LevelingConfigDashboard> {
await ensureLevelingConfig(guildId);
const { levelUpChannelId, roleMultipliers, channelMultipliers, ...rest } = patch;
const data: Prisma.LevelingConfigUpdateInput = { ...rest };
if (levelUpChannelId !== undefined) {
data.levelUpChannelId = levelUpChannelId === '' ? null : levelUpChannelId;
}
if (roleMultipliers !== undefined) {
data.roleMultipliers = roleMultipliers as Prisma.InputJsonValue;
}
if (channelMultipliers !== undefined) {
data.channelMultipliers = channelMultipliers as Prisma.InputJsonValue;
}
const updated = await prisma.levelingConfig.update({ where: { guildId }, data });
return toDashboard(updated);
}

View File

@@ -0,0 +1,55 @@
import type { LogEventTypeDashboard, LoggingConfigDashboard, LoggingConfigDashboardPatch } from '@nexumi/shared';
import { prisma } from '../prisma';
async function ensureLoggingConfig(guildId: string) {
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
return prisma.loggingConfig.upsert({
where: { guildId },
update: {},
create: { guildId }
});
}
export async function getLoggingDashboard(guildId: string): Promise<LoggingConfigDashboard> {
const [config, channels] = await Promise.all([
ensureLoggingConfig(guildId),
prisma.logChannel.findMany({ where: { guildId }, orderBy: { eventType: 'asc' } })
]);
return {
enabled: config.enabled,
ignoreBots: config.ignoreBots,
ignoredChannelIds: config.ignoredChannelIds,
ignoredRoleIds: config.ignoredRoleIds,
logChannels: channels.map((channel) => ({
eventType: channel.eventType as LogEventTypeDashboard,
channelId: channel.channelId
}))
};
}
export async function updateLoggingDashboard(
guildId: string,
patch: LoggingConfigDashboardPatch
): Promise<LoggingConfigDashboard> {
await ensureLoggingConfig(guildId);
const { logChannels, ...configPatch } = patch;
if (Object.keys(configPatch).length > 0) {
await prisma.loggingConfig.update({ where: { guildId }, data: configPatch });
}
if (logChannels !== undefined) {
await prisma.$transaction([
prisma.logChannel.deleteMany({ where: { guildId } }),
...logChannels.map((mapping) =>
prisma.logChannel.create({
data: { guildId, eventType: mapping.eventType, channelId: mapping.channelId }
})
)
]);
}
return getLoggingDashboard(guildId);
}

View File

@@ -0,0 +1,69 @@
import type { EscalationAction, ModerationDashboard, ModerationDashboardPatch } from '@nexumi/shared';
import { prisma } from '../prisma';
async function ensureGuild(guildId: string): Promise<void> {
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
}
async function ensureGuildSettings(guildId: string) {
await ensureGuild(guildId);
return prisma.guildSettings.upsert({
where: { guildId },
update: {},
create: { guildId }
});
}
export async function getModerationDashboard(guildId: string): Promise<ModerationDashboard> {
const [settings, rules] = await Promise.all([
ensureGuildSettings(guildId),
prisma.escalationRule.findMany({ where: { guildId }, orderBy: { warnCount: 'asc' } })
]);
return {
moderationEnabled: settings.moderationEnabled,
escalations: rules.map((rule) => ({
id: rule.id,
warnCount: rule.warnCount,
action: rule.action as EscalationAction,
durationMs: rule.durationMs,
reason: rule.reason,
enabled: rule.enabled
}))
};
}
export async function updateModerationDashboard(
guildId: string,
patch: ModerationDashboardPatch
): Promise<ModerationDashboard> {
await ensureGuild(guildId);
if (patch.moderationEnabled !== undefined) {
await prisma.guildSettings.upsert({
where: { guildId },
update: { moderationEnabled: patch.moderationEnabled },
create: { guildId, moderationEnabled: patch.moderationEnabled }
});
}
if (patch.escalations !== undefined) {
await prisma.$transaction([
prisma.escalationRule.deleteMany({ where: { guildId } }),
...patch.escalations.map((rule) =>
prisma.escalationRule.create({
data: {
guildId,
warnCount: rule.warnCount,
action: rule.action,
durationMs: rule.durationMs ?? null,
reason: rule.reason ?? null,
enabled: rule.enabled
}
})
)
]);
}
return getModerationDashboard(guildId);
}

View File

@@ -0,0 +1,51 @@
import type { VerificationConfigDashboard, VerificationConfigDashboardPatch } from '@nexumi/shared';
import type { Prisma } from '@prisma/client';
import { prisma } from '../prisma';
async function ensureVerificationConfig(guildId: string) {
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
return prisma.verificationConfig.upsert({
where: { guildId },
update: {},
create: { guildId }
});
}
function toDashboard(config: Awaited<ReturnType<typeof ensureVerificationConfig>>): VerificationConfigDashboard {
return {
enabled: config.enabled,
channelId: config.channelId ?? '',
verifiedRoleId: config.verifiedRoleId ?? '',
unverifiedRoleId: config.unverifiedRoleId ?? '',
mode: config.mode as VerificationConfigDashboard['mode'],
minAccountAgeDays: config.minAccountAgeDays,
failAction: config.failAction as VerificationConfigDashboard['failAction']
};
}
export async function getVerificationDashboard(guildId: string): Promise<VerificationConfigDashboard> {
const config = await ensureVerificationConfig(guildId);
return toDashboard(config);
}
export async function updateVerificationDashboard(
guildId: string,
patch: VerificationConfigDashboardPatch
): Promise<VerificationConfigDashboard> {
await ensureVerificationConfig(guildId);
const { channelId, verifiedRoleId, unverifiedRoleId, ...rest } = patch;
const data: Prisma.VerificationConfigUpdateInput = { ...rest };
if (channelId !== undefined) {
data.channelId = channelId === '' ? null : channelId;
}
if (verifiedRoleId !== undefined) {
data.verifiedRoleId = verifiedRoleId === '' ? null : verifiedRoleId;
}
if (unverifiedRoleId !== undefined) {
data.unverifiedRoleId = unverifiedRoleId === '' ? null : unverifiedRoleId;
}
const updated = await prisma.verificationConfig.update({ where: { guildId }, data });
return toDashboard(updated);
}

View File

@@ -0,0 +1,22 @@
import type { WarningDashboard, WarningListQuery } from '@nexumi/shared';
import { prisma } from '../prisma';
export async function listWarnings(guildId: string, query: WarningListQuery): Promise<WarningDashboard[]> {
const warnings = await prisma.warning.findMany({
where: {
guildId,
...(query.userId ? { userId: query.userId } : {})
},
orderBy: { createdAt: 'desc' },
take: 100
});
return warnings.map((warning) => ({
id: warning.id,
userId: warning.userId,
moderatorId: warning.moderatorId,
reason: warning.reason,
caseId: warning.caseId,
createdAt: warning.createdAt.toISOString()
}));
}

View File

@@ -0,0 +1,63 @@
import type { WelcomeConfigDashboard, WelcomeConfigDashboardPatch } from '@nexumi/shared';
import { Prisma } from '@prisma/client';
import { prisma } from '../prisma';
async function ensureWelcomeConfig(guildId: string) {
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
return prisma.welcomeConfig.upsert({
where: { guildId },
update: {},
create: { guildId }
});
}
function toDashboard(config: Awaited<ReturnType<typeof ensureWelcomeConfig>>): WelcomeConfigDashboard {
return {
welcomeEnabled: config.welcomeEnabled,
leaveEnabled: config.leaveEnabled,
welcomeChannelId: config.welcomeChannelId ?? '',
leaveChannelId: config.leaveChannelId ?? '',
welcomeType: config.welcomeType as WelcomeConfigDashboard['welcomeType'],
welcomeContent: config.welcomeContent,
welcomeEmbed: (config.welcomeEmbed as Record<string, unknown> | null) ?? null,
leaveContent: config.leaveContent,
leaveEmbed: (config.leaveEmbed as Record<string, unknown> | null) ?? null,
welcomeDmEnabled: config.welcomeDmEnabled,
welcomeDmContent: config.welcomeDmContent,
userAutoroleIds: config.userAutoroleIds,
botAutoroleIds: config.botAutoroleIds,
imageTitle: config.imageTitle,
imageSubtitle: config.imageSubtitle
};
}
export async function getWelcomeDashboard(guildId: string): Promise<WelcomeConfigDashboard> {
const config = await ensureWelcomeConfig(guildId);
return toDashboard(config);
}
export async function updateWelcomeDashboard(
guildId: string,
patch: WelcomeConfigDashboardPatch
): Promise<WelcomeConfigDashboard> {
await ensureWelcomeConfig(guildId);
const { welcomeChannelId, leaveChannelId, welcomeEmbed, leaveEmbed, ...rest } = patch;
const data: Prisma.WelcomeConfigUpdateInput = { ...rest };
if (welcomeChannelId !== undefined) {
data.welcomeChannelId = welcomeChannelId === '' ? null : welcomeChannelId;
}
if (leaveChannelId !== undefined) {
data.leaveChannelId = leaveChannelId === '' ? null : leaveChannelId;
}
if (welcomeEmbed !== undefined) {
data.welcomeEmbed = welcomeEmbed === null ? Prisma.JsonNull : (welcomeEmbed as Prisma.InputJsonValue);
}
if (leaveEmbed !== undefined) {
data.leaveEmbed = leaveEmbed === null ? Prisma.JsonNull : (leaveEmbed as Prisma.InputJsonValue);
}
const updated = await prisma.welcomeConfig.update({ where: { guildId }, data });
return toDashboard(updated);
}