Enhance guild management features in the owner panel
- Updated the guilds API to support searching by both guild name and ID, improving user experience. - Increased the number of guilds retrieved from the database from 100 to 200 for better visibility. - Implemented invite creation functionality, allowing owners to generate invites directly from the guild management interface. - Enhanced the UI to display guild names, icons, and member counts, providing a more informative overview. - Updated localization files to reflect new search capabilities and invite-related messages in both English and German.
This commit is contained in:
@@ -4,27 +4,39 @@ import { toApiErrorResponse } from '@/lib/auth';
|
||||
import { env } from '@/lib/env';
|
||||
import { writeOwnerAudit } from '@/lib/owner-audit';
|
||||
import { requireOwner } from '@/lib/owner-auth';
|
||||
import { createOwnerGuildInvite, enrichOwnerGuildListItems } from '@/lib/owner-guilds';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
await requireOwner('VIEWER');
|
||||
const q = new URL(request.url).searchParams.get('q')?.trim() ?? '';
|
||||
const q = new URL(request.url).searchParams.get('q')?.trim().toLowerCase() ?? '';
|
||||
const guilds = await prisma.guild.findMany({
|
||||
where: q ? { id: { contains: q } } : undefined,
|
||||
include: { settings: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100
|
||||
take: 200
|
||||
});
|
||||
const blacklist = await prisma.guildBlacklist.findMany();
|
||||
const blacklisted = new Set(blacklist.map((entry) => entry.guildId));
|
||||
return NextResponse.json({
|
||||
guilds: guilds.map((guild) => ({
|
||||
const enriched = await enrichOwnerGuildListItems(
|
||||
guilds.map((guild) => ({
|
||||
id: guild.id,
|
||||
locale: guild.settings?.locale ?? 'de',
|
||||
createdAt: guild.createdAt.toISOString(),
|
||||
blacklisted: blacklisted.has(guild.id)
|
||||
})),
|
||||
}))
|
||||
);
|
||||
|
||||
const filtered =
|
||||
q.length === 0
|
||||
? enriched
|
||||
: enriched.filter(
|
||||
(guild) =>
|
||||
guild.id.includes(q) || (guild.name?.toLowerCase().includes(q) ?? false)
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
guilds: filtered,
|
||||
blacklist
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -34,12 +46,31 @@ export async function GET(request: Request) {
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await requireOwner('ADMIN');
|
||||
const body = (await request.json()) as { action?: string; guildId?: string; reason?: string };
|
||||
if (!body.guildId || !/^\d{17,20}$/.test(body.guildId)) {
|
||||
return NextResponse.json({ error: 'Invalid guildId' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (body.action === 'createInvite') {
|
||||
const session = await requireOwner('SUPPORT');
|
||||
try {
|
||||
const invite = await createOwnerGuildInvite(body.guildId);
|
||||
await writeOwnerAudit(prisma, {
|
||||
actorUserId: session.user.id,
|
||||
action: 'guilds.createInvite',
|
||||
targetType: 'Guild',
|
||||
targetId: body.guildId,
|
||||
after: invite
|
||||
});
|
||||
return NextResponse.json({ ok: true, ...invite });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Invite creation failed';
|
||||
return NextResponse.json({ error: message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
const session = await requireOwner('ADMIN');
|
||||
|
||||
if (body.action === 'leave') {
|
||||
const response = await fetch(`https://discord.com/api/v10/users/@me/guilds/${body.guildId}`, {
|
||||
method: 'DELETE',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { GuildsManager } from '@/components/owner/owner-forms';
|
||||
import { getLocale, t } from '@/lib/i18n';
|
||||
import { enrichOwnerGuildListItems } from '@/lib/owner-guilds';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export default async function OwnerGuildsPage() {
|
||||
@@ -13,6 +14,14 @@ export default async function OwnerGuildsPage() {
|
||||
prisma.guildBlacklist.findMany()
|
||||
]);
|
||||
const blacklisted = new Set(blacklist.map((entry) => entry.guildId));
|
||||
const initialGuilds = await enrichOwnerGuildListItems(
|
||||
guilds.map((guild) => ({
|
||||
id: guild.id,
|
||||
locale: guild.settings?.locale ?? 'de',
|
||||
createdAt: guild.createdAt.toISOString(),
|
||||
blacklisted: blacklisted.has(guild.id)
|
||||
}))
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -20,14 +29,7 @@ export default async function OwnerGuildsPage() {
|
||||
<h1 className="text-2xl font-semibold">{t(locale, 'owner.guilds.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t(locale, 'owner.guilds.subtitle')}</p>
|
||||
</div>
|
||||
<GuildsManager
|
||||
initialGuilds={guilds.map((guild) => ({
|
||||
id: guild.id,
|
||||
locale: guild.settings?.locale ?? 'de',
|
||||
createdAt: guild.createdAt.toISOString(),
|
||||
blacklisted: blacklisted.has(guild.id)
|
||||
}))}
|
||||
/>
|
||||
<GuildsManager initialGuilds={initialGuilds} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMemo, useState, useTransition } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import type { BotPresenceConfig, OwnerRole } from '@nexumi/shared';
|
||||
import type { BotPresenceConfig, OwnerGuildListItem, OwnerRole } from '@nexumi/shared';
|
||||
import { useTranslations } from '@/components/locale-provider';
|
||||
import { SettingsForm } from '@/components/settings/settings-form';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -195,16 +196,23 @@ export function UsersManager({
|
||||
export function GuildsManager({
|
||||
initialGuilds
|
||||
}: {
|
||||
initialGuilds: Array<{ id: string; locale: string; createdAt: string; blacklisted: boolean }>;
|
||||
initialGuilds: OwnerGuildListItem[];
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const [query, setQuery] = useState('');
|
||||
const [pending, startTransition] = useTransition();
|
||||
const filtered = useMemo(
|
||||
() => initialGuilds.filter((guild) => guild.id.includes(query.trim())),
|
||||
[initialGuilds, query]
|
||||
);
|
||||
const filtered = useMemo(() => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (!needle) {
|
||||
return initialGuilds;
|
||||
}
|
||||
return initialGuilds.filter(
|
||||
(guild) =>
|
||||
guild.id.includes(needle) ||
|
||||
(guild.name?.toLowerCase().includes(needle) ?? false)
|
||||
);
|
||||
}, [initialGuilds, query]);
|
||||
|
||||
async function postAction(action: string, guildId: string, reason?: string) {
|
||||
const response = await fetch('/api/owner/guilds', {
|
||||
@@ -215,6 +223,7 @@ export function GuildsManager({
|
||||
if (!response.ok) {
|
||||
throw new Error(await readError(response));
|
||||
}
|
||||
return (await response.json()) as { ok?: boolean; url?: string };
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -225,58 +234,103 @@ export function GuildsManager({
|
||||
{filtered.length === 0 ? (
|
||||
<p className="p-4 text-sm text-muted-foreground">{t('owner.common.empty')}</p>
|
||||
) : (
|
||||
filtered.map((guild) => (
|
||||
<div key={guild.id} className="flex flex-col gap-3 px-4 py-3 text-sm sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="font-medium">{guild.id}</p>
|
||||
<p className="text-muted-foreground">
|
||||
{guild.locale} · {new Date(guild.createdAt).toLocaleDateString()}
|
||||
{guild.blacklisted ? ` · ${t('owner.guilds.blacklisted')}` : ''}
|
||||
</p>
|
||||
filtered.map((guild) => {
|
||||
const displayName = guild.name ?? t('owner.guilds.unknownName');
|
||||
const initials = displayName.slice(0, 2).toUpperCase();
|
||||
return (
|
||||
<div
|
||||
key={guild.id}
|
||||
className="flex flex-col gap-3 px-4 py-3 text-sm sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Avatar className="h-10 w-10 shrink-0">
|
||||
{guild.iconUrl ? <AvatarImage src={guild.iconUrl} alt="" /> : null}
|
||||
<AvatarFallback>{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{displayName}</p>
|
||||
<p className="truncate text-muted-foreground">
|
||||
<span className="font-mono text-xs">{guild.id}</span>
|
||||
{' · '}
|
||||
{guild.memberCount != null
|
||||
? t('owner.guilds.members', { count: String(guild.memberCount) })
|
||||
: t('owner.guilds.membersUnknown')}
|
||||
{' · '}
|
||||
{guild.locale} · {new Date(guild.createdAt).toLocaleDateString()}
|
||||
{guild.blacklisted ? ` · ${t('owner.guilds.blacklisted')}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await postAction('createInvite', guild.id);
|
||||
if (result.url) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(result.url);
|
||||
toast.success(t('owner.guilds.inviteCopied', { url: result.url }));
|
||||
} catch {
|
||||
toast.success(t('owner.guilds.inviteCreated', { url: result.url }));
|
||||
}
|
||||
} else {
|
||||
toast.success(t('common.saveSuccess'));
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t('common.saveError'));
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('owner.guilds.createInvite')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await postAction(guild.blacklisted ? 'unblacklist' : 'blacklist', guild.id, 'Owner panel');
|
||||
toast.success(t('common.saveSuccess'));
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t('common.saveError'));
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
{guild.blacklisted ? t('owner.guilds.unblacklist') : t('owner.guilds.blacklist')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
if (!window.confirm(t('owner.guilds.leaveConfirm'))) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await postAction('leave', guild.id);
|
||||
toast.success(t('common.saveSuccess'));
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t('common.saveError'));
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('owner.guilds.leave')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await postAction(guild.blacklisted ? 'unblacklist' : 'blacklist', guild.id, 'Owner panel');
|
||||
toast.success(t('common.saveSuccess'));
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t('common.saveError'));
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
{guild.blacklisted ? t('owner.guilds.unblacklist') : t('owner.guilds.blacklist')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
if (!window.confirm(t('owner.guilds.leaveConfirm'))) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await postAction('leave', guild.id);
|
||||
toast.success(t('common.saveSuccess'));
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t('common.saveError'));
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('owner.guilds.leave')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
);
|
||||
})
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -6,14 +6,26 @@ import { redis } from './redis';
|
||||
const DISCORD_API_BASE = 'https://discord.com/api/v10';
|
||||
const CACHE_TTL_SECONDS = 60;
|
||||
|
||||
async function discordBotFetch<T>(path: string): Promise<T> {
|
||||
export async function discordBotFetch<T>(
|
||||
path: string,
|
||||
init?: { method?: string; body?: unknown }
|
||||
): Promise<T> {
|
||||
const method = init?.method ?? 'GET';
|
||||
const response = await fetch(`${DISCORD_API_BASE}${path}`, {
|
||||
headers: { Authorization: `Bot ${env.BOT_TOKEN}` },
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bot ${env.BOT_TOKEN}`,
|
||||
...(init?.body !== undefined ? { 'Content-Type': 'application/json' } : {})
|
||||
},
|
||||
body: init?.body !== undefined ? JSON.stringify(init.body) : undefined,
|
||||
cache: 'no-store'
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Discord API request to ${path} failed with status ${response.status}`);
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
|
||||
145
apps/webui/src/lib/owner-guilds.ts
Normal file
145
apps/webui/src/lib/owner-guilds.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import type { OwnerGuildListItem } from '@nexumi/shared';
|
||||
import { discordBotFetch } from './discord-guild-resources';
|
||||
import { redis } from './redis';
|
||||
|
||||
const META_CACHE_TTL_SECONDS = 300;
|
||||
const INVITE_CHANNEL_TYPES = new Set([0, 2, 5, 13]); // text, voice, announcement, stage
|
||||
|
||||
interface DiscordGuildRest {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
system_channel_id?: string | null;
|
||||
approximate_member_count?: number;
|
||||
}
|
||||
|
||||
interface DiscordChannelRest {
|
||||
id: string;
|
||||
name: string;
|
||||
type: number;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
interface DiscordInviteRest {
|
||||
code: string;
|
||||
channel?: { id: string };
|
||||
}
|
||||
|
||||
export type OwnerGuildDiscordMeta = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
iconUrl: string | null;
|
||||
memberCount: number | null;
|
||||
};
|
||||
|
||||
function guildIconUrl(guildId: string, icon: string | null): string | null {
|
||||
if (!icon) {
|
||||
return null;
|
||||
}
|
||||
const ext = icon.startsWith('a_') ? 'gif' : 'png';
|
||||
return `https://cdn.discordapp.com/icons/${guildId}/${icon}.${ext}?size=64`;
|
||||
}
|
||||
|
||||
export async function getOwnerGuildDiscordMeta(guildId: string): Promise<OwnerGuildDiscordMeta | null> {
|
||||
const cacheKey = `owner:guild:${guildId}:meta:v1`;
|
||||
const cached = await redis.get(cacheKey);
|
||||
if (cached) {
|
||||
return JSON.parse(cached) as OwnerGuildDiscordMeta;
|
||||
}
|
||||
|
||||
try {
|
||||
const guild = await discordBotFetch<DiscordGuildRest>(`/guilds/${guildId}?with_counts=true`);
|
||||
const meta: OwnerGuildDiscordMeta = {
|
||||
id: guild.id,
|
||||
name: guild.name,
|
||||
icon: guild.icon,
|
||||
iconUrl: guildIconUrl(guild.id, guild.icon),
|
||||
memberCount: guild.approximate_member_count ?? null
|
||||
};
|
||||
await redis.set(cacheKey, JSON.stringify(meta), 'EX', META_CACHE_TTL_SECONDS);
|
||||
return meta;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function mapPool<T, R>(items: T[], concurrency: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
||||
const results = new Array<R>(items.length);
|
||||
let nextIndex = 0;
|
||||
|
||||
async function worker(): Promise<void> {
|
||||
while (nextIndex < items.length) {
|
||||
const index = nextIndex;
|
||||
nextIndex += 1;
|
||||
results[index] = await fn(items[index]!);
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array.from({ length: Math.min(concurrency, Math.max(items.length, 1)) }, () => worker());
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function enrichOwnerGuildListItems(
|
||||
base: Array<{
|
||||
id: string;
|
||||
locale: string;
|
||||
createdAt: string;
|
||||
blacklisted: boolean;
|
||||
}>
|
||||
): Promise<OwnerGuildListItem[]> {
|
||||
const metas = await mapPool(base, 8, (guild) => getOwnerGuildDiscordMeta(guild.id));
|
||||
return base.map((guild, index) => {
|
||||
const meta = metas[index];
|
||||
return {
|
||||
id: guild.id,
|
||||
name: meta?.name ?? null,
|
||||
iconUrl: meta?.iconUrl ?? null,
|
||||
memberCount: meta?.memberCount ?? null,
|
||||
locale: guild.locale,
|
||||
createdAt: guild.createdAt,
|
||||
blacklisted: guild.blacklisted
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function createOwnerGuildInvite(guildId: string): Promise<{
|
||||
url: string;
|
||||
code: string;
|
||||
channelId: string;
|
||||
}> {
|
||||
const guild = await discordBotFetch<DiscordGuildRest>(`/guilds/${guildId}?with_counts=true`);
|
||||
const channels = await discordBotFetch<DiscordChannelRest[]>(`/guilds/${guildId}/channels`);
|
||||
const candidates = channels
|
||||
.filter((channel) => INVITE_CHANNEL_TYPES.has(channel.type))
|
||||
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
|
||||
|
||||
const ordered = [
|
||||
...candidates.filter((channel) => channel.id === guild.system_channel_id),
|
||||
...candidates.filter((channel) => channel.id !== guild.system_channel_id)
|
||||
];
|
||||
|
||||
let lastError: Error | null = null;
|
||||
for (const channel of ordered) {
|
||||
try {
|
||||
const invite = await discordBotFetch<DiscordInviteRest>(`/channels/${channel.id}/invites`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
max_age: 86_400,
|
||||
max_uses: 5,
|
||||
unique: true
|
||||
}
|
||||
});
|
||||
return {
|
||||
code: invite.code,
|
||||
url: `https://discord.gg/${invite.code}`,
|
||||
channelId: channel.id
|
||||
};
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new Error('Could not create an invite for this guild');
|
||||
}
|
||||
@@ -990,7 +990,13 @@
|
||||
"guilds": {
|
||||
"title": "Server-Verwaltung",
|
||||
"subtitle": "Server suchen, blacklisten oder den Bot entfernen.",
|
||||
"search": "Server-ID suchen…",
|
||||
"search": "Servername oder ID suchen…",
|
||||
"unknownName": "Unbekannter Server",
|
||||
"members": "{count} Mitglieder",
|
||||
"membersUnknown": "Mitglieder unbekannt",
|
||||
"createInvite": "Invite erstellen",
|
||||
"inviteCopied": "Invite kopiert: {url}",
|
||||
"inviteCreated": "Invite erstellt: {url}",
|
||||
"blacklisted": "Blacklist",
|
||||
"blacklist": "Blacklisten",
|
||||
"unblacklist": "Blacklist entfernen",
|
||||
|
||||
@@ -990,7 +990,13 @@
|
||||
"guilds": {
|
||||
"title": "Guild management",
|
||||
"subtitle": "Search guilds, blacklist them, or leave with the bot.",
|
||||
"search": "Search guild ID…",
|
||||
"search": "Search guild name or ID…",
|
||||
"unknownName": "Unknown guild",
|
||||
"members": "{count} members",
|
||||
"membersUnknown": "Member count unknown",
|
||||
"createInvite": "Create invite",
|
||||
"inviteCopied": "Invite copied: {url}",
|
||||
"inviteCreated": "Invite created: {url}",
|
||||
"blacklisted": "Blacklisted",
|
||||
"blacklist": "Blacklist",
|
||||
"unblacklist": "Remove blacklist",
|
||||
|
||||
@@ -306,6 +306,20 @@
|
||||
- [ ] User-Autorollen gesetzt, Bot-Rolle über Autorolle, Manage Roles → Rolle bei Join
|
||||
- [ ] Dashboard Welcome-Embed-Vorschau zeigt eigenen Namen/Avatar und Servername/-icon
|
||||
|
||||
## Post-Phase – Owner Guild-Liste Meta + Invite (Status: implementiert)
|
||||
|
||||
### Abgeschlossen (Code)
|
||||
|
||||
- Owner `/owner/guilds`: Discord-Name, Icon, Member-Count, ID (Live-Meta via Bot-Token, Redis-Cache)
|
||||
- Suche nach Name und ID
|
||||
- Aktion „Invite erstellen“ (Systemkanal zuerst, max. 24h / 5 Uses, Clipboard + Owner-Audit)
|
||||
|
||||
### Manuell testen
|
||||
|
||||
- [ ] Serverliste zeigt Name/Logo/Mitglieder statt nur ID
|
||||
- [ ] Suche nach Servername filtert
|
||||
- [ ] Invite erstellen → Link im Toast/Clipboard; Bot braucht Create Instant Invite
|
||||
|
||||
## Post-Phase – Automod/Moderation Dashboard Ausbau (Status: implementiert)
|
||||
|
||||
### Abgeschlossen (Code)
|
||||
|
||||
@@ -79,6 +79,19 @@ export const GuildBlacklistSchema = z.object({
|
||||
|
||||
export type GuildBlacklist = z.infer<typeof GuildBlacklistSchema>;
|
||||
|
||||
/** Enriched guild row for the Owner panel guild manager. */
|
||||
export const OwnerGuildListItemSchema = z.object({
|
||||
id: snowflake,
|
||||
name: z.string().nullable(),
|
||||
iconUrl: z.string().url().nullable(),
|
||||
memberCount: z.number().int().nonnegative().nullable(),
|
||||
locale: z.string().min(1).max(16),
|
||||
createdAt: z.string(),
|
||||
blacklisted: z.boolean()
|
||||
});
|
||||
|
||||
export type OwnerGuildListItem = z.infer<typeof OwnerGuildListItemSchema>;
|
||||
|
||||
export const ChangelogEntrySchema = z.object({
|
||||
id: z.string().optional(),
|
||||
version: z.string().min(1).max(32),
|
||||
|
||||
Reference in New Issue
Block a user