diff --git a/apps/webui/src/app/api/guilds/[guildId]/commands/[commandName]/route.ts b/apps/webui/src/app/api/guilds/[guildId]/commands/[commandName]/route.ts new file mode 100644 index 0000000..2433240 --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/commands/[commandName]/route.ts @@ -0,0 +1,53 @@ +import { CommandOverrideDashboardUpsertSchema } from '@nexumi/shared'; +import { type NextRequest, NextResponse } from 'next/server'; +import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; +import { writeDashboardAudit } from '@/lib/audit'; +import { resetCommandOverrideDashboard, upsertCommandOverrideDashboard } from '@/lib/module-configs/commands'; +import { prisma } from '@/lib/prisma'; + +interface RouteParams { + params: Promise<{ guildId: string; commandName: string }>; +} + +export async function PATCH(request: NextRequest, { params }: RouteParams) { + const { guildId, commandName } = await params; + try { + const session = await requireGuildAccess(guildId); + const body = await request.json(); + const input = CommandOverrideDashboardUpsertSchema.parse({ ...body, commandName }); + + const updated = await upsertCommandOverrideDashboard(guildId, input); + + await writeDashboardAudit(prisma, { + guildId, + actorUserId: session.user.id, + action: 'commands.update', + path: `/dashboard/${guildId}/commands`, + after: updated + }); + + return NextResponse.json(updated); + } catch (error) { + return toApiErrorResponse(error); + } +} + +export async function DELETE(_request: NextRequest, { params }: RouteParams) { + const { guildId, commandName } = await params; + try { + const session = await requireGuildAccess(guildId); + const reset = await resetCommandOverrideDashboard(guildId, commandName); + + await writeDashboardAudit(prisma, { + guildId, + actorUserId: session.user.id, + action: 'commands.reset', + path: `/dashboard/${guildId}/commands`, + after: reset + }); + + return NextResponse.json(reset); + } catch (error) { + return toApiErrorResponse(error); + } +} diff --git a/apps/webui/src/app/api/guilds/[guildId]/commands/route.ts b/apps/webui/src/app/api/guilds/[guildId]/commands/route.ts new file mode 100644 index 0000000..b1597a9 --- /dev/null +++ b/apps/webui/src/app/api/guilds/[guildId]/commands/route.ts @@ -0,0 +1,18 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth'; +import { listCommandOverridesDashboard } from '@/lib/module-configs/commands'; + +interface RouteParams { + params: Promise<{ guildId: string }>; +} + +export async function GET(_request: NextRequest, { params }: RouteParams) { + const { guildId } = await params; + try { + await requireGuildAccess(guildId); + const commands = await listCommandOverridesDashboard(guildId); + return NextResponse.json({ commands }); + } catch (error) { + return toApiErrorResponse(error); + } +} diff --git a/apps/webui/src/app/dashboard/[guildId]/[module]/page.tsx b/apps/webui/src/app/dashboard/[guildId]/[module]/page.tsx deleted file mode 100644 index d26c3ed..0000000 --- a/apps/webui/src/app/dashboard/[guildId]/[module]/page.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { DASHBOARD_MODULES, type DashboardModuleId } from '@nexumi/shared'; -import { Construction } from 'lucide-react'; -import Link from 'next/link'; -import { notFound } from 'next/navigation'; -import { Card, CardContent } from '@/components/ui/card'; -import { getLocale, t } from '@/lib/i18n'; - -/** - * Batch A modules (Phase 7) got dedicated route folders (e.g. - * `dashboard/[guildId]/moderation/page.tsx`), which Next.js prefers over this - * catch-all - so they never reach this component. This placeholder now only - * serves the remaining Batch B/C modules until they get dedicated pages too. - */ -const REMAINING_MODULE_IDS: ReadonlySet = new Set([ - 'giveaways', - 'tickets', - 'selfroles', - 'tags', - 'starboard', - 'suggestions', - 'birthdays', - 'tempvoice', - 'stats', - 'feeds', - 'scheduler', - 'guildbackup' -]); - -interface ModulePlaceholderPageProps { - params: Promise<{ guildId: string; module: string }>; -} - -export default async function ModulePlaceholderPage({ params }: ModulePlaceholderPageProps) { - const { guildId, module: moduleHref } = await params; - const moduleEntry = DASHBOARD_MODULES.find((entry) => entry.href === moduleHref); - - if (!moduleEntry || !REMAINING_MODULE_IDS.has(moduleEntry.id)) { - notFound(); - } - - const locale = await getLocale(); - const moduleLabel = t(locale, `modules.${moduleEntry.id}.label`); - - return ( - - -
- -
-
-

{t(locale, 'modulePlaceholder.title', { module: moduleLabel })}

-

{t(locale, 'modulePlaceholder.message')}

-
- - {t(locale, 'modulePlaceholder.backToModules')} - -
-
- ); -} diff --git a/apps/webui/src/app/dashboard/[guildId]/backup/loading.tsx b/apps/webui/src/app/dashboard/[guildId]/backup/loading.tsx new file mode 100644 index 0000000..6667bf8 --- /dev/null +++ b/apps/webui/src/app/dashboard/[guildId]/backup/loading.tsx @@ -0,0 +1,14 @@ +import { Skeleton } from '@/components/ui/skeleton'; + +export default function BackupLoading() { + return ( +
+
+ + +
+ + +
+ ); +} diff --git a/apps/webui/src/app/dashboard/[guildId]/backup/page.tsx b/apps/webui/src/app/dashboard/[guildId]/backup/page.tsx new file mode 100644 index 0000000..d13cc17 --- /dev/null +++ b/apps/webui/src/app/dashboard/[guildId]/backup/page.tsx @@ -0,0 +1,30 @@ +import { GuildBackupManager } from '@/components/modules/guild-backup-manager'; +import { requireAuthOrRedirect } from '@/lib/auth'; +import { fetchDiscordUserGuildsCached } from '@/lib/discord-oauth'; +import { getLocale, t } from '@/lib/i18n'; +import { listGuildBackupsDashboard } from '@/lib/module-configs/guildbackup'; + +interface BackupPageProps { + params: Promise<{ guildId: string }>; +} + +export default async function BackupPage({ params }: BackupPageProps) { + const { guildId } = await params; + const session = await requireAuthOrRedirect(); + const [locale, backups, discordGuilds] = await Promise.all([ + getLocale(), + listGuildBackupsDashboard(guildId), + fetchDiscordUserGuildsCached(session.accessToken) + ]); + const isOwner = discordGuilds.find((guild) => guild.id === guildId)?.owner ?? false; + + return ( +
+
+

{t(locale, 'modules.guildbackup.label')}

+

{t(locale, 'modules.guildbackup.description')}

+
+ +
+ ); +} diff --git a/apps/webui/src/components/modules/guild-backup-manager.tsx b/apps/webui/src/components/modules/guild-backup-manager.tsx new file mode 100644 index 0000000..e78e838 --- /dev/null +++ b/apps/webui/src/components/modules/guild-backup-manager.tsx @@ -0,0 +1,215 @@ +'use client'; + +import type { GuildBackupDashboard } from '@nexumi/shared'; +import { Download, Info, Plus, RotateCcw, Trash2 } from 'lucide-react'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { useTranslations } from '@/components/locale-provider'; +import { Badge } from '@/components/ui/badge'; +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'; + +function formatDate(iso: string): string { + return new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }); +} + +interface GuildBackupManagerProps { + guildId: string; + initialBackups: GuildBackupDashboard[]; + isOwner: boolean; +} + +export function GuildBackupManager({ guildId, initialBackups, isOwner }: GuildBackupManagerProps) { + const t = useTranslations(); + const [backups, setBackups] = useState(initialBackups); + const [name, setName] = useState(''); + const [creating, setCreating] = useState(false); + const [pendingId, setPendingId] = useState(null); + const [infoId, setInfoId] = useState(null); + const [infoText, setInfoText] = useState(null); + + async function handleCreate() { + if (!name.trim()) { + toast.error(t('modulePages.guildbackup.nameRequired')); + return; + } + setCreating(true); + try { + const response = await fetch(`/api/guilds/${guildId}/guildbackup`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: name.trim() }) + }); + const body = (await response.json().catch(() => null)) as (GuildBackupDashboard & { error?: string }) | null; + if (!response.ok || !body) { + toast.error(body?.error ?? t('common.saveError')); + return; + } + setBackups((prev) => [body, ...prev]); + setName(''); + toast.success(t('modulePages.guildbackup.created')); + } catch { + toast.error(t('common.saveError')); + } finally { + setCreating(false); + } + } + + async function handleInfo(backup: GuildBackupDashboard) { + if (infoId === backup.id) { + setInfoId(null); + setInfoText(null); + return; + } + setInfoId(backup.id); + try { + const response = await fetch(`/api/guilds/${guildId}/guildbackup/${backup.id}`); + const body = (await response.json().catch(() => null)) as { + payload?: { guildName: string; roles: unknown[]; channels: unknown[] } | null; + error?: string; + } | null; + if (!response.ok || !body?.payload) { + setInfoText(t('modulePages.guildbackup.infoUnavailable')); + return; + } + setInfoText( + `${t('modulePages.guildbackup.infoGuildName')}: ${body.payload.guildName} · ${t('modulePages.guildbackup.infoRoles')}: ${body.payload.roles.length} · ${t('modulePages.guildbackup.infoChannels')}: ${body.payload.channels.length}` + ); + } catch { + setInfoText(t('modulePages.guildbackup.infoUnavailable')); + } + } + + function handleDownload(backup: GuildBackupDashboard) { + window.open(`/api/guilds/${guildId}/guildbackup/${backup.id}/download`, '_blank'); + } + + async function handleDelete(backup: GuildBackupDashboard) { + if (!window.confirm(t('modulePages.guildbackup.confirmDelete'))) { + return; + } + setPendingId(backup.id); + try { + const response = await fetch(`/api/guilds/${guildId}/guildbackup/${backup.id}`, { method: 'DELETE' }); + if (!response.ok) { + toast.error(t('common.saveError')); + return; + } + setBackups((prev) => prev.filter((entry) => entry.id !== backup.id)); + toast.success(t('common.saveSuccess')); + } catch { + toast.error(t('common.saveError')); + } finally { + setPendingId(null); + } + } + + async function handleRestore(backup: GuildBackupDashboard) { + if (!window.confirm(t('modulePages.guildbackup.confirmRestoreStep1'))) { + return; + } + if (!window.confirm(t('modulePages.guildbackup.confirmRestoreStep2'))) { + return; + } + setPendingId(backup.id); + try { + const response = await fetch(`/api/guilds/${guildId}/guildbackup/${backup.id}/restore`, { method: 'POST' }); + const body = (await response.json().catch(() => null)) as { error?: string } | null; + if (!response.ok) { + toast.error(body?.error ?? t('common.saveError')); + return; + } + toast.success(t('modulePages.guildbackup.restoreQueued')); + } catch { + toast.error(t('common.saveError')); + } finally { + setPendingId(null); + } + } + + return ( +
+ + + {t('modulePages.guildbackup.createTitle')} + {t('modulePages.guildbackup.createDescription')} + + +
+ + setName(event.target.value)} placeholder={t('modulePages.guildbackup.namePlaceholder')} /> +
+ +
+
+ + + + {t('modulePages.guildbackup.listTitle')} + {t('modulePages.guildbackup.listDescription')} + + + {backups.length === 0 && ( +

{t('modulePages.guildbackup.empty')}

+ )} + {backups.map((backup) => ( +
+
+
+

{backup.name}

+

+ {formatDate(backup.createdAt)} · {t('modulePages.guildbackup.infoRoles')}: {backup.roleCount} ·{' '} + {t('modulePages.guildbackup.infoChannels')}: {backup.channelCount} +

+
+ {!isOwner && ( + {t('modulePages.guildbackup.ownerOnlyBadge')} + )} +
+ {infoId === backup.id && infoText && ( +

{infoText}

+ )} +
+ + + {isOwner && ( + + )} + +
+
+ ))} +
+
+
+ ); +} diff --git a/apps/webui/src/lib/module-configs/commands.ts b/apps/webui/src/lib/module-configs/commands.ts new file mode 100644 index 0000000..d4f09a9 --- /dev/null +++ b/apps/webui/src/lib/module-configs/commands.ts @@ -0,0 +1,75 @@ +import { KNOWN_COMMAND_NAMES, type CommandOverrideDashboard, type CommandOverrideDashboardUpsert } from '@nexumi/shared'; +import type { CommandOverride } from '@prisma/client'; +import { prisma } from '../prisma'; + +function toDashboard(override: CommandOverride): CommandOverrideDashboard { + return { + commandName: override.commandName, + enabled: override.enabled, + allowedRoleIds: override.allowedRoleIds, + deniedRoleIds: override.deniedRoleIds, + allowedChannelIds: override.allowedChannelIds, + deniedChannelIds: override.deniedChannelIds, + cooldownSeconds: override.cooldownSeconds + }; +} + +function defaultDashboard(commandName: string): CommandOverrideDashboard { + return { + commandName, + enabled: true, + allowedRoleIds: [], + deniedRoleIds: [], + allowedChannelIds: [], + deniedChannelIds: [], + cooldownSeconds: null + }; +} + +/** + * Lists every known command with its dashboard override (or sensible + * defaults for commands that have never been overridden on this guild), + * sorted alphabetically for a stable table. + */ +export async function listCommandOverridesDashboard(guildId: string): Promise { + const overrides = await prisma.commandOverride.findMany({ where: { guildId } }); + const byName = new Map(overrides.map((entry) => [entry.commandName, entry])); + + return [...KNOWN_COMMAND_NAMES] + .sort((a, b) => a.localeCompare(b)) + .map((commandName) => { + const existing = byName.get(commandName); + return existing ? toDashboard(existing) : defaultDashboard(commandName); + }); +} + +export async function upsertCommandOverrideDashboard( + guildId: string, + input: CommandOverrideDashboardUpsert +): Promise { + await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } }); + + const data = { + enabled: input.enabled, + allowedRoleIds: input.allowedRoleIds, + deniedRoleIds: input.deniedRoleIds, + allowedChannelIds: input.allowedChannelIds, + deniedChannelIds: input.deniedChannelIds, + cooldownSeconds: input.cooldownSeconds ?? null + }; + + const updated = await prisma.commandOverride.upsert({ + where: { guildId_commandName: { guildId, commandName: input.commandName } }, + update: data, + create: { guildId, commandName: input.commandName, ...data } + }); + return toDashboard(updated); +} + +export async function resetCommandOverrideDashboard( + guildId: string, + commandName: string +): Promise { + await prisma.commandOverride.deleteMany({ where: { guildId, commandName } }); + return defaultDashboard(commandName); +}