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

@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest';
import {
AutomodConfigDashboardPatchSchema,
CaseListQuerySchema,
EconomyConfigDashboardPatchSchema,
FunConfigDashboardSchema,
LoggingConfigDashboardPatchSchema,
ModerationDashboardPatchSchema,
OptionalSnowflakeSchema,
WelcomeConfigDashboardPatchSchema
} from './dashboard.js';
describe('dashboard schemas', () => {
it('accepts moderation patches with escalations', () => {
const parsed = ModerationDashboardPatchSchema.parse({
moderationEnabled: true,
escalations: [{ warnCount: 3, action: 'TIMEOUT', durationMs: 60_000, enabled: true }]
});
expect(parsed.escalations?.[0]?.action).toBe('TIMEOUT');
});
it('rejects empty moderation patches', () => {
expect(() => ModerationDashboardPatchSchema.parse({})).toThrow();
});
it('validates automod patches', () => {
const parsed = AutomodConfigDashboardPatchSchema.parse({
antiRaidEnabled: true,
antiRaidJoinThreshold: 15
});
expect(parsed.antiRaidJoinThreshold).toBe(15);
});
it('validates logging patches with channel mappings', () => {
const parsed = LoggingConfigDashboardPatchSchema.parse({
logChannels: [{ eventType: 'MEMBER_JOIN', channelId: '123456789012345678' }]
});
expect(parsed.logChannels?.[0]?.eventType).toBe('MEMBER_JOIN');
});
it('rejects invalid log channel ids', () => {
expect(() =>
LoggingConfigDashboardPatchSchema.parse({
logChannels: [{ eventType: 'MEMBER_JOIN', channelId: 'not-a-snowflake' }]
})
).toThrow();
});
it('accepts empty string or a valid snowflake as optional id', () => {
expect(OptionalSnowflakeSchema.parse('')).toBe('');
expect(OptionalSnowflakeSchema.parse('123456789012345678')).toBe('123456789012345678');
expect(() => OptionalSnowflakeSchema.parse('abc')).toThrow();
});
it('validates welcome patches', () => {
const parsed = WelcomeConfigDashboardPatchSchema.parse({
welcomeEnabled: true,
welcomeChannelId: '123456789012345678'
});
expect(parsed.welcomeEnabled).toBe(true);
});
it('validates economy patches', () => {
const parsed = EconomyConfigDashboardPatchSchema.parse({ currencyName: 'Gems', dailyAmount: 100 });
expect(parsed.currencyName).toBe('Gems');
});
it('validates fun config', () => {
const parsed = FunConfigDashboardSchema.parse({ enabled: true, mediaEnabled: false });
expect(parsed.mediaEnabled).toBe(false);
});
it('applies default limit for case list queries', () => {
const parsed = CaseListQuerySchema.parse({});
expect(parsed.limit).toBe(25);
});
});

View File

@@ -0,0 +1,279 @@
import { z } from 'zod';
/**
* Discord snowflake ID: 17-20 digit numeric string.
*/
export const SnowflakeSchema = z.string().regex(/^\d{17,20}$/, 'Invalid Discord ID');
/**
* Optional snowflake field for forms: accepts an empty string (treated as
* "unset"/cleared) or a valid snowflake. Kept as a plain string (rather than
* transformed to `undefined`) so that `JSON.stringify` does not silently
* drop the field when a user clears it in the dashboard - the API layer
* maps `''` to `null` when persisting.
*/
export const OptionalSnowflakeSchema = z.union([z.literal(''), SnowflakeSchema]);
function nonEmptyPatch<T extends z.ZodRawShape>(schema: z.ZodObject<T>) {
return schema.partial().refine((value) => Object.keys(value).length > 0, {
message: 'At least one field is required'
});
}
// ---------------------------------------------------------------------------
// Moderation
// ---------------------------------------------------------------------------
export const EscalationActionSchema = z.enum(['TIMEOUT', 'KICK', 'BAN']);
export type EscalationAction = z.infer<typeof EscalationActionSchema>;
export const EscalationRuleDashboardSchema = z.object({
id: z.string().optional(),
warnCount: z.number().int().positive(),
action: EscalationActionSchema,
durationMs: z.number().int().positive().nullable().optional(),
reason: z.string().max(500).nullable().optional(),
enabled: z.boolean().default(true)
});
export type EscalationRuleDashboard = z.infer<typeof EscalationRuleDashboardSchema>;
export const ModerationDashboardSchema = z.object({
moderationEnabled: z.boolean(),
escalations: z.array(EscalationRuleDashboardSchema)
});
export type ModerationDashboard = z.infer<typeof ModerationDashboardSchema>;
export const ModerationDashboardPatchSchema = z
.object({
moderationEnabled: z.boolean().optional(),
escalations: z.array(EscalationRuleDashboardSchema).optional()
})
.refine((value) => Object.keys(value).length > 0, { message: 'At least one field is required' });
export type ModerationDashboardPatch = z.infer<typeof ModerationDashboardPatchSchema>;
export const CaseDashboardSchema = z.object({
id: z.string(),
caseNumber: z.number().int(),
targetUserId: z.string(),
moderatorId: z.string(),
action: z.string(),
reason: z.string().nullable(),
createdAt: z.string(),
deletedAt: z.string().nullable()
});
export type CaseDashboard = z.infer<typeof CaseDashboardSchema>;
export const CaseListQuerySchema = z.object({
search: z.string().trim().max(100).optional(),
action: z.string().trim().max(50).optional(),
limit: z.coerce.number().int().positive().max(100).default(25)
});
export type CaseListQuery = z.infer<typeof CaseListQuerySchema>;
export const WarningDashboardSchema = z.object({
id: z.string(),
userId: z.string(),
moderatorId: z.string(),
reason: z.string().nullable(),
caseId: z.string().nullable(),
createdAt: z.string()
});
export type WarningDashboard = z.infer<typeof WarningDashboardSchema>;
export const WarningListQuerySchema = z.object({
userId: SnowflakeSchema.optional()
});
export type WarningListQuery = z.infer<typeof WarningListQuerySchema>;
// ---------------------------------------------------------------------------
// AutoMod
// ---------------------------------------------------------------------------
export const AntiRaidActionSchema = z.enum(['LOCKDOWN']);
export const AutomodConfigDashboardSchema = z.object({
enabled: z.boolean(),
antiRaidEnabled: z.boolean(),
antiRaidJoinThreshold: z.number().int().positive(),
antiRaidWindowSeconds: z.number().int().positive(),
antiRaidAction: AntiRaidActionSchema,
antiNukeEnabled: z.boolean(),
antiNukeBanThreshold: z.number().int().positive(),
antiNukeChannelThreshold: z.number().int().positive(),
antiNukeWindowSeconds: z.number().int().positive(),
lockdownActive: z.boolean()
});
export type AutomodConfigDashboard = z.infer<typeof AutomodConfigDashboardSchema>;
export const AutomodConfigDashboardPatchSchema = nonEmptyPatch(AutomodConfigDashboardSchema);
export type AutomodConfigDashboardPatch = z.infer<typeof AutomodConfigDashboardPatchSchema>;
// ---------------------------------------------------------------------------
// Logging
// ---------------------------------------------------------------------------
export const LogEventTypeDashboardSchema = z.enum([
'MESSAGE_EDIT',
'MESSAGE_DELETE',
'MESSAGE_BULK_DELETE',
'MEMBER_JOIN',
'MEMBER_LEAVE',
'MEMBER_BAN',
'MEMBER_UNBAN',
'MEMBER_UPDATE',
'CHANNEL_CREATE',
'CHANNEL_DELETE',
'CHANNEL_UPDATE',
'VOICE_JOIN',
'VOICE_LEAVE',
'VOICE_MOVE',
'INVITE_CREATE',
'EMOJI_CREATE',
'EMOJI_UPDATE',
'EMOJI_DELETE',
'STICKER_CREATE',
'STICKER_UPDATE',
'STICKER_DELETE',
'THREAD_CREATE',
'THREAD_UPDATE',
'THREAD_DELETE',
'GUILD_UPDATE',
'MOD_ACTION'
]);
export type LogEventTypeDashboard = z.infer<typeof LogEventTypeDashboardSchema>;
export const LogChannelMappingSchema = z.object({
eventType: LogEventTypeDashboardSchema,
channelId: SnowflakeSchema
});
export type LogChannelMapping = z.infer<typeof LogChannelMappingSchema>;
export const LoggingConfigDashboardSchema = z.object({
enabled: z.boolean(),
ignoreBots: z.boolean(),
ignoredChannelIds: z.array(z.string()),
ignoredRoleIds: z.array(z.string()),
logChannels: z.array(LogChannelMappingSchema)
});
export type LoggingConfigDashboard = z.infer<typeof LoggingConfigDashboardSchema>;
export const LoggingConfigDashboardPatchSchema = z
.object({
enabled: z.boolean().optional(),
ignoreBots: z.boolean().optional(),
ignoredChannelIds: z.array(z.string()).optional(),
ignoredRoleIds: z.array(z.string()).optional(),
logChannels: z.array(LogChannelMappingSchema).optional()
})
.refine((value) => Object.keys(value).length > 0, { message: 'At least one field is required' });
export type LoggingConfigDashboardPatch = z.infer<typeof LoggingConfigDashboardPatchSchema>;
// ---------------------------------------------------------------------------
// Welcome & Leave
// ---------------------------------------------------------------------------
export const WelcomeMessageTypeDashboardSchema = z.enum(['TEXT', 'EMBED', 'IMAGE']);
export const WelcomeConfigDashboardSchema = z.object({
welcomeEnabled: z.boolean(),
leaveEnabled: z.boolean(),
welcomeChannelId: OptionalSnowflakeSchema,
leaveChannelId: OptionalSnowflakeSchema,
welcomeType: WelcomeMessageTypeDashboardSchema,
welcomeContent: z.string().max(2000).nullable().optional(),
welcomeEmbed: z.record(z.string(), z.unknown()).nullable().optional(),
leaveContent: z.string().max(2000).nullable().optional(),
leaveEmbed: z.record(z.string(), z.unknown()).nullable().optional(),
welcomeDmEnabled: z.boolean(),
welcomeDmContent: z.string().max(2000).nullable().optional(),
userAutoroleIds: z.array(z.string()),
botAutoroleIds: z.array(z.string()),
imageTitle: z.string().max(200).nullable().optional(),
imageSubtitle: z.string().max(200).nullable().optional()
});
export type WelcomeConfigDashboard = z.infer<typeof WelcomeConfigDashboardSchema>;
export const WelcomeConfigDashboardPatchSchema = nonEmptyPatch(WelcomeConfigDashboardSchema);
export type WelcomeConfigDashboardPatch = z.infer<typeof WelcomeConfigDashboardPatchSchema>;
// ---------------------------------------------------------------------------
// Verification
// ---------------------------------------------------------------------------
export const VerificationModeDashboardSchema = z.enum(['BUTTON', 'CAPTCHA']);
export const VerificationFailActionDashboardSchema = z.enum(['KICK', 'BAN', 'NONE']);
export const VerificationConfigDashboardSchema = z.object({
enabled: z.boolean(),
channelId: OptionalSnowflakeSchema,
verifiedRoleId: OptionalSnowflakeSchema,
unverifiedRoleId: OptionalSnowflakeSchema,
mode: VerificationModeDashboardSchema,
minAccountAgeDays: z.number().int().nonnegative(),
failAction: VerificationFailActionDashboardSchema
});
export type VerificationConfigDashboard = z.infer<typeof VerificationConfigDashboardSchema>;
export const VerificationConfigDashboardPatchSchema = nonEmptyPatch(VerificationConfigDashboardSchema);
export type VerificationConfigDashboardPatch = z.infer<typeof VerificationConfigDashboardPatchSchema>;
// ---------------------------------------------------------------------------
// Leveling
// ---------------------------------------------------------------------------
export const LevelUpModeDashboardSchema = z.enum(['CHANNEL', 'DM', 'OFF']);
export const LevelingConfigDashboardSchema = z.object({
enabled: z.boolean(),
textXpMin: z.number().int().positive(),
textXpMax: z.number().int().positive(),
textCooldownSeconds: z.number().int().positive(),
voiceXpPerMinute: z.number().int().nonnegative(),
noXpChannelIds: z.array(z.string()),
noXpRoleIds: z.array(z.string()),
roleMultipliers: z.record(z.string(), z.number().positive()),
channelMultipliers: z.record(z.string(), z.number().positive()),
levelUpMode: LevelUpModeDashboardSchema,
levelUpChannelId: OptionalSnowflakeSchema,
levelUpMessage: z.string().max(500).nullable().optional(),
stackRewards: z.boolean(),
cardAccentColor: z.string().regex(/^#[0-9a-fA-F]{6}$/),
cardBackgroundColor: z.string().regex(/^#[0-9a-fA-F]{6}$/)
});
export type LevelingConfigDashboard = z.infer<typeof LevelingConfigDashboardSchema>;
export const LevelingConfigDashboardPatchSchema = nonEmptyPatch(LevelingConfigDashboardSchema);
export type LevelingConfigDashboardPatch = z.infer<typeof LevelingConfigDashboardPatchSchema>;
// ---------------------------------------------------------------------------
// Economy
// ---------------------------------------------------------------------------
export const EconomyConfigDashboardSchema = z.object({
enabled: z.boolean(),
currencyName: z.string().min(1).max(32),
currencySymbol: z.string().min(1).max(8),
dailyAmount: z.number().int().nonnegative(),
weeklyAmount: z.number().int().nonnegative(),
workMin: z.number().int().nonnegative(),
workMax: z.number().int().nonnegative(),
workCooldownSeconds: z.number().int().positive(),
startingBalance: z.number().int().nonnegative()
});
export type EconomyConfigDashboard = z.infer<typeof EconomyConfigDashboardSchema>;
export const EconomyConfigDashboardPatchSchema = nonEmptyPatch(EconomyConfigDashboardSchema);
export type EconomyConfigDashboardPatch = z.infer<typeof EconomyConfigDashboardPatchSchema>;
// ---------------------------------------------------------------------------
// Fun
// ---------------------------------------------------------------------------
export const FunConfigDashboardSchema = z.object({
enabled: z.boolean(),
mediaEnabled: z.boolean()
});
export type FunConfigDashboard = z.infer<typeof FunConfigDashboardSchema>;
export const FunConfigDashboardPatchSchema = nonEmptyPatch(FunConfigDashboardSchema);
export type FunConfigDashboardPatch = z.infer<typeof FunConfigDashboardPatchSchema>;

View File

@@ -8,6 +8,7 @@ export * from './phase3.js';
export * from './phase4.js';
export * from './phase5.js';
export * from './phase6.js';
export * from './dashboard.js';
export const LocaleSchema = z.enum(['de', 'en']);
export type Locale = z.infer<typeof LocaleSchema>;