From 211403d54d721ac62e0ca10e0410b587850050cd Mon Sep 17 00:00:00 2001 From: smueller Date: Fri, 24 Jul 2026 10:59:07 +0200 Subject: [PATCH] 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. --- apps/webui/src/app/api/owner/guilds/route.ts | 45 ++++- apps/webui/src/app/owner/guilds/page.tsx | 18 +- .../src/components/owner/owner-forms.tsx | 168 ++++++++++++------ apps/webui/src/lib/discord-guild-resources.ts | 16 +- apps/webui/src/lib/owner-guilds.ts | 145 +++++++++++++++ apps/webui/src/messages/de.json | 8 +- apps/webui/src/messages/en.json | 8 +- docs/PHASE-TRACKING.md | 14 ++ packages/shared/src/owner.ts | 13 ++ 9 files changed, 359 insertions(+), 76 deletions(-) create mode 100644 apps/webui/src/lib/owner-guilds.ts diff --git a/apps/webui/src/app/api/owner/guilds/route.ts b/apps/webui/src/app/api/owner/guilds/route.ts index d582ff4..6d7b909 100644 --- a/apps/webui/src/app/api/owner/guilds/route.ts +++ b/apps/webui/src/app/api/owner/guilds/route.ts @@ -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', diff --git a/apps/webui/src/app/owner/guilds/page.tsx b/apps/webui/src/app/owner/guilds/page.tsx index 7055a53..37d8bf2 100644 --- a/apps/webui/src/app/owner/guilds/page.tsx +++ b/apps/webui/src/app/owner/guilds/page.tsx @@ -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 (
@@ -20,14 +29,7 @@ export default async function OwnerGuildsPage() {

{t(locale, 'owner.guilds.title')}

{t(locale, 'owner.guilds.subtitle')}

- ({ - id: guild.id, - locale: guild.settings?.locale ?? 'de', - createdAt: guild.createdAt.toISOString(), - blacklisted: blacklisted.has(guild.id) - }))} - /> + ); } diff --git a/apps/webui/src/components/owner/owner-forms.tsx b/apps/webui/src/components/owner/owner-forms.tsx index 2d5907f..89517be 100644 --- a/apps/webui/src/components/owner/owner-forms.tsx +++ b/apps/webui/src/components/owner/owner-forms.tsx @@ -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 ? (

{t('owner.common.empty')}

) : ( - filtered.map((guild) => ( -
-
-

{guild.id}

-

- {guild.locale} · {new Date(guild.createdAt).toLocaleDateString()} - {guild.blacklisted ? ` · ${t('owner.guilds.blacklisted')}` : ''} -

+ filtered.map((guild) => { + const displayName = guild.name ?? t('owner.guilds.unknownName'); + const initials = displayName.slice(0, 2).toUpperCase(); + return ( +
+
+ + {guild.iconUrl ? : null} + {initials} + +
+

{displayName}

+

+ {guild.id} + {' · '} + {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')}` : ''} +

+
+
+
+ + + +
-
- - -
-
- )) + ); + }) )} diff --git a/apps/webui/src/lib/discord-guild-resources.ts b/apps/webui/src/lib/discord-guild-resources.ts index fbd96a3..e67b836 100644 --- a/apps/webui/src/lib/discord-guild-resources.ts +++ b/apps/webui/src/lib/discord-guild-resources.ts @@ -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(path: string): Promise { +export async function discordBotFetch( + path: string, + init?: { method?: string; body?: unknown } +): Promise { + 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; } diff --git a/apps/webui/src/lib/owner-guilds.ts b/apps/webui/src/lib/owner-guilds.ts new file mode 100644 index 0000000..10d969d --- /dev/null +++ b/apps/webui/src/lib/owner-guilds.ts @@ -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 { + 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(`/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(items: T[], concurrency: number, fn: (item: T) => Promise): Promise { + const results = new Array(items.length); + let nextIndex = 0; + + async function worker(): Promise { + 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 { + 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(`/guilds/${guildId}?with_counts=true`); + const channels = await discordBotFetch(`/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(`/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'); +} diff --git a/apps/webui/src/messages/de.json b/apps/webui/src/messages/de.json index 6a76f60..142caf8 100644 --- a/apps/webui/src/messages/de.json +++ b/apps/webui/src/messages/de.json @@ -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", diff --git a/apps/webui/src/messages/en.json b/apps/webui/src/messages/en.json index f8b2d74..1fb186a 100644 --- a/apps/webui/src/messages/en.json +++ b/apps/webui/src/messages/en.json @@ -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", diff --git a/docs/PHASE-TRACKING.md b/docs/PHASE-TRACKING.md index 4d420ce..0fbcf18 100644 --- a/docs/PHASE-TRACKING.md +++ b/docs/PHASE-TRACKING.md @@ -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) diff --git a/packages/shared/src/owner.ts b/packages/shared/src/owner.ts index 33158e5..ea36d96 100644 --- a/packages/shared/src/owner.ts +++ b/packages/shared/src/owner.ts @@ -79,6 +79,19 @@ export const GuildBlacklistSchema = z.object({ export type GuildBlacklist = z.infer; +/** 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; + export const ChangelogEntrySchema = z.object({ id: z.string().optional(), version: z.string().min(1).max(32),