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,257 @@
'use client';
import type { LogChannelMapping, LogEventTypeDashboard, LoggingConfigDashboard } from '@nexumi/shared';
import { Plus, Trash2 } from 'lucide-react';
import { useId } from 'react';
import { useTranslations } from '@/components/locale-provider';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import type { SettingsSaveResult } from '@/components/settings/settings-form';
import { SettingsForm } from '@/components/settings/settings-form';
const LOG_EVENT_TYPES: LogEventTypeDashboard[] = [
'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'
];
interface LocalLogChannel extends LogChannelMapping {
clientKey: string;
}
interface LocalLoggingValue {
enabled: boolean;
ignoreBots: boolean;
ignoredChannelIdsText: string;
ignoredRoleIdsText: string;
logChannels: LocalLogChannel[];
}
function toCsv(ids: string[]): string {
return ids.join(', ');
}
function fromCsv(text: string): string[] {
return text
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
function toLocal(config: LoggingConfigDashboard): LocalLoggingValue {
return {
enabled: config.enabled,
ignoreBots: config.ignoreBots,
ignoredChannelIdsText: toCsv(config.ignoredChannelIds),
ignoredRoleIdsText: toCsv(config.ignoredRoleIds),
logChannels: config.logChannels.map((mapping, index) => ({ ...mapping, clientKey: `${mapping.eventType}-${index}` }))
};
}
function toRemote(value: LocalLoggingValue): LoggingConfigDashboard {
return {
enabled: value.enabled,
ignoreBots: value.ignoreBots,
ignoredChannelIds: fromCsv(value.ignoredChannelIdsText),
ignoredRoleIds: fromCsv(value.ignoredRoleIdsText),
logChannels: value.logChannels.map(({ clientKey, ...rest }) => rest)
};
}
async function saveLogging(guildId: string, value: LocalLoggingValue): Promise<SettingsSaveResult> {
try {
const response = await fetch(`/api/guilds/${guildId}/logging`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(toRemote(value))
});
if (!response.ok) {
const body = (await response.json().catch(() => null)) as { error?: string } | null;
return { ok: false, error: body?.error ?? `Request failed with status ${response.status}` };
}
return { ok: true };
} catch {
return { ok: false, error: 'Network error' };
}
}
interface LoggingFormProps {
guildId: string;
initialValue: LoggingConfigDashboard;
}
export function LoggingForm({ guildId, initialValue }: LoggingFormProps) {
const t = useTranslations();
const ignoredChannelsId = useId();
const ignoredRolesId = useId();
return (
<SettingsForm<LocalLoggingValue>
initialValue={toLocal(initialValue)}
onSave={(value) => saveLogging(guildId, value)}
>
{({ value, setValue }) => (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>{t('modulePages.logging.title')}</CardTitle>
<CardDescription>{t('modulePages.logging.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.logging.enabledLabel')}</Label>
<Switch
checked={value.enabled}
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
/>
</div>
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.logging.ignoreBotsLabel')}</Label>
<Switch
checked={value.ignoreBots}
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, ignoreBots: checked }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor={ignoredChannelsId}>{t('modulePages.logging.ignoredChannelsLabel')}</Label>
<Textarea
id={ignoredChannelsId}
value={value.ignoredChannelIdsText}
onChange={(event) => setValue((prev) => ({ ...prev, ignoredChannelIdsText: event.target.value }))}
placeholder={t('modulePages.logging.idsPlaceholder')}
/>
<p className="text-xs text-muted-foreground">{t('modulePages.logging.idsHint')}</p>
</div>
<div className="space-y-2">
<Label htmlFor={ignoredRolesId}>{t('modulePages.logging.ignoredRolesLabel')}</Label>
<Textarea
id={ignoredRolesId}
value={value.ignoredRoleIdsText}
onChange={(event) => setValue((prev) => ({ ...prev, ignoredRoleIdsText: event.target.value }))}
placeholder={t('modulePages.logging.idsPlaceholder')}
/>
<p className="text-xs text-muted-foreground">{t('modulePages.logging.idsHint')}</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('modulePages.logging.channelsTitle')}</CardTitle>
<CardDescription>{t('modulePages.logging.channelsDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{value.logChannels.length === 0 && (
<p className="text-sm text-muted-foreground">{t('modulePages.logging.channelsEmpty')}</p>
)}
{value.logChannels.map((mapping) => (
<div key={mapping.clientKey} className="flex flex-wrap items-end gap-3 rounded-md border border-border p-3">
<div className="space-y-2">
<Label>{t('modulePages.logging.eventType')}</Label>
<Select
value={mapping.eventType}
onValueChange={(eventType) =>
setValue((prev) => ({
...prev,
logChannels: prev.logChannels.map((entry) =>
entry.clientKey === mapping.clientKey
? { ...entry, eventType: eventType as LogEventTypeDashboard }
: entry
)
}))
}
>
<SelectTrigger className="w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LOG_EVENT_TYPES.map((eventType) => (
<SelectItem key={eventType} value={eventType}>
{eventType}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1 space-y-2">
<Label>{t('modulePages.logging.channelId')}</Label>
<Input
value={mapping.channelId}
onChange={(event) =>
setValue((prev) => ({
...prev,
logChannels: prev.logChannels.map((entry) =>
entry.clientKey === mapping.clientKey ? { ...entry, channelId: event.target.value.trim() } : entry
)
}))
}
placeholder="123456789012345678"
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t('common.remove')}
onClick={() =>
setValue((prev) => ({
...prev,
logChannels: prev.logChannels.filter((entry) => entry.clientKey !== mapping.clientKey)
}))
}
>
<Trash2 className="size-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
onClick={() =>
setValue((prev) => ({
...prev,
logChannels: [
...prev.logChannels,
{ clientKey: `new-${Date.now()}`, eventType: 'MOD_ACTION', channelId: '' }
]
}))
}
>
<Plus className="size-4" />
{t('modulePages.logging.addMapping')}
</Button>
</CardContent>
</Card>
</div>
)}
</SettingsForm>
);
}