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:
smueller
2026-07-24 10:59:07 +02:00
parent 0ebe540c3f
commit 211403d54d
9 changed files with 359 additions and 76 deletions

View File

@@ -4,27 +4,39 @@ import { toApiErrorResponse } from '@/lib/auth';
import { env } from '@/lib/env'; import { env } from '@/lib/env';
import { writeOwnerAudit } from '@/lib/owner-audit'; import { writeOwnerAudit } from '@/lib/owner-audit';
import { requireOwner } from '@/lib/owner-auth'; import { requireOwner } from '@/lib/owner-auth';
import { createOwnerGuildInvite, enrichOwnerGuildListItems } from '@/lib/owner-guilds';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
export async function GET(request: Request) { export async function GET(request: Request) {
try { try {
await requireOwner('VIEWER'); 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({ const guilds = await prisma.guild.findMany({
where: q ? { id: { contains: q } } : undefined,
include: { settings: true }, include: { settings: true },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: 100 take: 200
}); });
const blacklist = await prisma.guildBlacklist.findMany(); const blacklist = await prisma.guildBlacklist.findMany();
const blacklisted = new Set(blacklist.map((entry) => entry.guildId)); const blacklisted = new Set(blacklist.map((entry) => entry.guildId));
return NextResponse.json({ const enriched = await enrichOwnerGuildListItems(
guilds: guilds.map((guild) => ({ guilds.map((guild) => ({
id: guild.id, id: guild.id,
locale: guild.settings?.locale ?? 'de', locale: guild.settings?.locale ?? 'de',
createdAt: guild.createdAt.toISOString(), createdAt: guild.createdAt.toISOString(),
blacklisted: blacklisted.has(guild.id) 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 blacklist
}); });
} catch (error) { } catch (error) {
@@ -34,12 +46,31 @@ export async function GET(request: Request) {
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const session = await requireOwner('ADMIN');
const body = (await request.json()) as { action?: string; guildId?: string; reason?: string }; const body = (await request.json()) as { action?: string; guildId?: string; reason?: string };
if (!body.guildId || !/^\d{17,20}$/.test(body.guildId)) { if (!body.guildId || !/^\d{17,20}$/.test(body.guildId)) {
return NextResponse.json({ error: 'Invalid guildId' }, { status: 400 }); 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') { if (body.action === 'leave') {
const response = await fetch(`https://discord.com/api/v10/users/@me/guilds/${body.guildId}`, { const response = await fetch(`https://discord.com/api/v10/users/@me/guilds/${body.guildId}`, {
method: 'DELETE', method: 'DELETE',

View File

@@ -1,5 +1,6 @@
import { GuildsManager } from '@/components/owner/owner-forms'; import { GuildsManager } from '@/components/owner/owner-forms';
import { getLocale, t } from '@/lib/i18n'; import { getLocale, t } from '@/lib/i18n';
import { enrichOwnerGuildListItems } from '@/lib/owner-guilds';
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
export default async function OwnerGuildsPage() { export default async function OwnerGuildsPage() {
@@ -13,6 +14,14 @@ export default async function OwnerGuildsPage() {
prisma.guildBlacklist.findMany() prisma.guildBlacklist.findMany()
]); ]);
const blacklisted = new Set(blacklist.map((entry) => entry.guildId)); 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 ( return (
<div className="space-y-6"> <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> <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> <p className="text-sm text-muted-foreground">{t(locale, 'owner.guilds.subtitle')}</p>
</div> </div>
<GuildsManager <GuildsManager initialGuilds={initialGuilds} />
initialGuilds={guilds.map((guild) => ({
id: guild.id,
locale: guild.settings?.locale ?? 'de',
createdAt: guild.createdAt.toISOString(),
blacklisted: blacklisted.has(guild.id)
}))}
/>
</div> </div>
); );
} }

View File

@@ -3,9 +3,10 @@
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useMemo, useState, useTransition } from 'react'; import { useMemo, useState, useTransition } from 'react';
import { toast } from 'sonner'; 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 { useTranslations } from '@/components/locale-provider';
import { SettingsForm } from '@/components/settings/settings-form'; import { SettingsForm } from '@/components/settings/settings-form';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -195,16 +196,23 @@ export function UsersManager({
export function GuildsManager({ export function GuildsManager({
initialGuilds initialGuilds
}: { }: {
initialGuilds: Array<{ id: string; locale: string; createdAt: string; blacklisted: boolean }>; initialGuilds: OwnerGuildListItem[];
}) { }) {
const t = useTranslations(); const t = useTranslations();
const router = useRouter(); const router = useRouter();
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
const filtered = useMemo( const filtered = useMemo(() => {
() => initialGuilds.filter((guild) => guild.id.includes(query.trim())), const needle = query.trim().toLowerCase();
[initialGuilds, query] 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) { async function postAction(action: string, guildId: string, reason?: string) {
const response = await fetch('/api/owner/guilds', { const response = await fetch('/api/owner/guilds', {
@@ -215,6 +223,7 @@ export function GuildsManager({
if (!response.ok) { if (!response.ok) {
throw new Error(await readError(response)); throw new Error(await readError(response));
} }
return (await response.json()) as { ok?: boolean; url?: string };
} }
return ( return (
@@ -225,16 +234,60 @@ export function GuildsManager({
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<p className="p-4 text-sm text-muted-foreground">{t('owner.common.empty')}</p> <p className="p-4 text-sm text-muted-foreground">{t('owner.common.empty')}</p>
) : ( ) : (
filtered.map((guild) => ( 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"> const displayName = guild.name ?? t('owner.guilds.unknownName');
<div> const initials = displayName.slice(0, 2).toUpperCase();
<p className="font-medium">{guild.id}</p> return (
<p className="text-muted-foreground"> <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.locale} · {new Date(guild.createdAt).toLocaleDateString()}
{guild.blacklisted ? ` · ${t('owner.guilds.blacklisted')}` : ''} {guild.blacklisted ? ` · ${t('owner.guilds.blacklisted')}` : ''}
</p> </p>
</div> </div>
</div>
<div className="flex flex-wrap gap-2"> <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 <Button
size="sm" size="sm"
variant="secondary" variant="secondary"
@@ -276,7 +329,8 @@ export function GuildsManager({
</Button> </Button>
</div> </div>
</div> </div>
)) );
})
)} )}
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -6,14 +6,26 @@ import { redis } from './redis';
const DISCORD_API_BASE = 'https://discord.com/api/v10'; const DISCORD_API_BASE = 'https://discord.com/api/v10';
const CACHE_TTL_SECONDS = 60; 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}`, { 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' cache: 'no-store'
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`Discord API request to ${path} failed with status ${response.status}`); 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; return (await response.json()) as T;
} }

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

View File

@@ -990,7 +990,13 @@
"guilds": { "guilds": {
"title": "Server-Verwaltung", "title": "Server-Verwaltung",
"subtitle": "Server suchen, blacklisten oder den Bot entfernen.", "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", "blacklisted": "Blacklist",
"blacklist": "Blacklisten", "blacklist": "Blacklisten",
"unblacklist": "Blacklist entfernen", "unblacklist": "Blacklist entfernen",

View File

@@ -990,7 +990,13 @@
"guilds": { "guilds": {
"title": "Guild management", "title": "Guild management",
"subtitle": "Search guilds, blacklist them, or leave with the bot.", "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", "blacklisted": "Blacklisted",
"blacklist": "Blacklist", "blacklist": "Blacklist",
"unblacklist": "Remove blacklist", "unblacklist": "Remove blacklist",

View File

@@ -306,6 +306,20 @@
- [ ] User-Autorollen gesetzt, Bot-Rolle über Autorolle, Manage Roles → Rolle bei Join - [ ] User-Autorollen gesetzt, Bot-Rolle über Autorolle, Manage Roles → Rolle bei Join
- [ ] Dashboard Welcome-Embed-Vorschau zeigt eigenen Namen/Avatar und Servername/-icon - [ ] 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) ## Post-Phase Automod/Moderation Dashboard Ausbau (Status: implementiert)
### Abgeschlossen (Code) ### Abgeschlossen (Code)

View File

@@ -79,6 +79,19 @@ export const GuildBlacklistSchema = z.object({
export type GuildBlacklist = z.infer<typeof GuildBlacklistSchema>; 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({ export const ChangelogEntrySchema = z.object({
id: z.string().optional(), id: z.string().optional(),
version: z.string().min(1).max(32), version: z.string().min(1).max(32),