diff --git a/docs/PHASE-TRACKING.md b/docs/PHASE-TRACKING.md index 89b2306..4a7d5c4 100644 --- a/docs/PHASE-TRACKING.md +++ b/docs/PHASE-TRACKING.md @@ -9,12 +9,22 @@ ## Phase 7 – WebUI Modul-Seiten + Owner-Panel (Status: in Arbeit) -### Geplant +### Abgeschlossen (Batch A) -- Funktionierende Dashboard-Seiten je Modul (Settings-Framework + Zod-API, keine Platzhalter) -- Owner-Panel: Übersicht, Guilds, User-Blacklist, Feature-Flags, Präsenz, Team, Jobs, Changelog, Audit -- Prisma: Owner-/Command-Override-Modelle +- Prisma-Grundlage für Owner-Features: `CommandOverride`, `OwnerTeamMember`, `GlobalUserBlacklist`, `GuildBlacklist`, `FeatureFlag`, `BotPresenceConfig`, `ChangelogEntry`, `OwnerAuditLog` inkl. Migration `20260722190000_phase7_owner` +- Shared Zod-Schemas für Dashboard-Module in `packages/shared/src/dashboard.ts` (Moderation, Automod, Logging, Welcome, Verification, Leveling, Economy, Fun, Cases, Warnings) + Tests +- `OWNER_USER_IDS`-Env-Variable (WebUI-Env-Schema + `.env.example`), noch ohne Owner-Panel-Nutzung +- API-Routen (GET+PATCH) für: `moderation`, `automod`, `logging`, `welcome`, `verification`, `leveling`, `economy`, `fun`; zusätzlich GET `cases` und `warnings` +- Funktionierende Dashboard-Seiten (Formular + Speichern über SettingsForm/SaveBar, echte Daten, keine Platzhalter) für alle acht Batch-A-Module, inkl. Cases-Tabelle auf der Moderation-Seite +- i18n-Keys `modulePages.*` in `de.json` und `en.json` ergänzt +- Platzhalter-Route `dashboard/[guildId]/[module]/page.tsx` greift nur noch für die verbleibenden Batch-B/C-Module (Batch-A-Routen haben eigene Ordner mit Vorrang) +- Verifiziert: `pnpm --filter @nexumi/shared build+test`, `pnpm --filter @nexumi/webui typecheck+lint`, `pnpm --filter @nexumi/bot typecheck+lint+test` grün; `next build` kompiliert und rendert alle Seiten (inkl. neue Batch-A-Routen) erfolgreich – einzige Auffälligkeit ist ein bekanntes Windows-only `EPERM`-Symlink-Problem bei `output: standalone` außerhalb von Docker, ohne Auswirkung auf den Linux-Container-Build + +### Offen (Batch B/C) + +- Batch B: Tickets, Giveaways, Reaction-Roles, Custom-Commands, Polls (eigene Module + Seiten, aktuell noch über `[module]`-Platzhalter) +- Batch C: Owner-Panel-UI (Übersicht, Guilds, User-Blacklist, Feature-Flags, Präsenz, Team, Jobs, Changelog, Audit) auf Basis der jetzt vorhandenen Prisma-Modelle ## Nächster geplanter Schritt -- Phase 7 fertigstellen, manuell freigeben, danach Phase 8 (Landing, Status, Rechtsseiten). +- Batch B (Restmodule) und Batch C (Owner-Panel-UI) umsetzen, danach Phase 7 komplett manuell freigeben, anschließend Phase 8 (Landing, Status, Rechtsseiten). diff --git a/packages/shared/src/dashboard.test.ts b/packages/shared/src/dashboard.test.ts index 38ed8d8..21df457 100644 --- a/packages/shared/src/dashboard.test.ts +++ b/packages/shared/src/dashboard.test.ts @@ -4,9 +4,18 @@ import { CaseListQuerySchema, EconomyConfigDashboardPatchSchema, FunConfigDashboardSchema, + GiveawayCreateDashboardSchema, LoggingConfigDashboardPatchSchema, ModerationDashboardPatchSchema, OptionalSnowflakeSchema, + ScheduledMessageDashboardCreateSchema, + SelfRolePanelDashboardCreateSchema, + SocialFeedDashboardCreateSchema, + StarboardConfigDashboardPatchSchema, + SuggestionActionSchema, + TagDashboardCreateSchema, + TicketCategoryDashboardCreateSchema, + TicketConfigDashboardPatchSchema, WelcomeConfigDashboardPatchSchema } from './dashboard.js'; @@ -74,4 +83,98 @@ describe('dashboard schemas', () => { const parsed = CaseListQuerySchema.parse({}); expect(parsed.limit).toBe(25); }); + + it('validates ticket config patches', () => { + const parsed = TicketConfigDashboardPatchSchema.parse({ mode: 'THREAD', inactivityHours: 24 }); + expect(parsed.mode).toBe('THREAD'); + }); + + it('validates ticket category creation', () => { + const parsed = TicketCategoryDashboardCreateSchema.parse({ + name: 'Support', + description: null, + emoji: '🎫', + supportRoleIds: ['123456789012345678'] + }); + expect(parsed.supportRoleIds).toHaveLength(1); + }); + + it('validates giveaway creation input', () => { + const parsed = GiveawayCreateDashboardSchema.parse({ + channelId: '123456789012345678', + prize: 'Nitro', + winnerCount: 1, + endsAt: new Date(Date.now() + 60_000).toISOString() + }); + expect(parsed.prize).toBe('Nitro'); + }); + + it('rejects giveaway creation with invalid date', () => { + expect(() => + GiveawayCreateDashboardSchema.parse({ + channelId: '123456789012345678', + prize: 'Nitro', + winnerCount: 1, + endsAt: 'not-a-date' + }) + ).toThrow(); + }); + + it('validates self role panel creation', () => { + const parsed = SelfRolePanelDashboardCreateSchema.parse({ + channelId: '123456789012345678', + title: 'Colors', + mode: 'BUTTONS', + behavior: 'NORMAL', + roles: [{ roleId: '123456789012345678', label: 'Red' }] + }); + expect(parsed.roles).toHaveLength(1); + }); + + it('validates tag creation', () => { + const parsed = TagDashboardCreateSchema.parse({ + name: 'rules', + content: 'Please read the rules', + responseType: 'TEXT', + allowedRoleIds: [], + allowedChannelIds: [] + }); + expect(parsed.name).toBe('rules'); + }); + + it('validates starboard patches', () => { + const parsed = StarboardConfigDashboardPatchSchema.parse({ threshold: 5 }); + expect(parsed.threshold).toBe(5); + }); + + it('validates suggestion staff actions', () => { + const parsed = SuggestionActionSchema.parse({ status: 'APPROVED', staffReason: 'Great idea' }); + expect(parsed.status).toBe('APPROVED'); + }); + + it('validates social feed creation', () => { + const parsed = SocialFeedDashboardCreateSchema.parse({ + type: 'TWITCH', + sourceId: 'somechannel', + channelId: '123456789012345678', + enabled: true + }); + expect(parsed.type).toBe('TWITCH'); + }); + + it('validates scheduled message creation requires cron or runAt', () => { + expect(() => + ScheduledMessageDashboardCreateSchema.parse({ + channelId: '123456789012345678', + content: 'Hello' + }) + ).toThrow(); + + const parsed = ScheduledMessageDashboardCreateSchema.parse({ + channelId: '123456789012345678', + content: 'Hello', + runAt: new Date(Date.now() + 60_000).toISOString() + }); + expect(parsed.content).toBe('Hello'); + }); }); diff --git a/packages/shared/src/dashboard.ts b/packages/shared/src/dashboard.ts index 083b21d..7a7bff2 100644 --- a/packages/shared/src/dashboard.ts +++ b/packages/shared/src/dashboard.ts @@ -1,4 +1,13 @@ import { z } from 'zod'; +import { + SelfRoleBehaviorSchema, + SelfRoleEntrySchema, + SelfRoleModeSchema, + SuggestionStatusSchema, + TagResponseTypeSchema, + TicketModeSchema +} from './phase4.js'; +import { SocialFeedTypeSchema } from './phase5.js'; /** * Discord snowflake ID: 17-20 digit numeric string. @@ -277,3 +286,352 @@ export type FunConfigDashboard = z.infer; export const FunConfigDashboardPatchSchema = nonEmptyPatch(FunConfigDashboardSchema); export type FunConfigDashboardPatch = z.infer; + +// --------------------------------------------------------------------------- +// Tickets +// --------------------------------------------------------------------------- + +export const TicketConfigDashboardSchema = z.object({ + enabled: z.boolean(), + mode: TicketModeSchema, + categoryId: OptionalSnowflakeSchema, + logChannelId: OptionalSnowflakeSchema, + transcriptDm: z.boolean(), + inactivityHours: z.number().int().positive(), + ratingEnabled: z.boolean() +}); +export type TicketConfigDashboard = z.infer; + +export const TicketConfigDashboardPatchSchema = nonEmptyPatch(TicketConfigDashboardSchema); +export type TicketConfigDashboardPatch = z.infer; + +export const TicketCategoryDashboardSchema = z.object({ + id: z.string().optional(), + name: z.string().min(1).max(100), + description: z.string().max(500).nullable().optional(), + emoji: z.string().max(32).nullable().optional(), + supportRoleIds: z.array(z.string()) +}); +export type TicketCategoryDashboard = z.infer; + +export const TicketCategoryDashboardCreateSchema = TicketCategoryDashboardSchema.omit({ id: true }); +export type TicketCategoryDashboardCreate = z.infer; + +export const TicketCategoryDashboardUpdateSchema = nonEmptyPatch(TicketCategoryDashboardCreateSchema); +export type TicketCategoryDashboardUpdate = z.infer; + +// --------------------------------------------------------------------------- +// Giveaways +// --------------------------------------------------------------------------- + +export const GiveawayDashboardSchema = z.object({ + id: z.string(), + guildId: z.string(), + channelId: z.string(), + messageId: z.string().nullable(), + prize: z.string(), + winnerCount: z.number().int(), + endsAt: z.string(), + requiredRoleId: z.string().nullable(), + requiredLevel: z.number().int().nullable(), + requiredMemberDays: z.number().int().nullable(), + paused: z.boolean(), + endedAt: z.string().nullable(), + winners: z.array(z.string()), + entrants: z.array(z.string()), + hostId: z.string(), + createdAt: z.string() +}); +export type GiveawayDashboard = z.infer; + +export const GiveawayCreateDashboardSchema = z.object({ + channelId: SnowflakeSchema, + prize: z.string().min(1).max(200), + winnerCount: z.number().int().positive().max(50), + endsAt: z.string().refine((value) => !Number.isNaN(Date.parse(value)), 'Invalid date'), + requiredRoleId: OptionalSnowflakeSchema.optional(), + requiredLevel: z.number().int().nonnegative().nullable().optional(), + requiredMemberDays: z.number().int().nonnegative().nullable().optional() +}); +export type GiveawayCreateDashboard = z.infer; + +export const GiveawayActionSchema = z.enum(['end', 'pause', 'unpause']); +export type GiveawayAction = z.infer; + +// --------------------------------------------------------------------------- +// Self Roles +// --------------------------------------------------------------------------- + +export const SelfRolePanelDashboardSchema = z.object({ + id: z.string().optional(), + channelId: SnowflakeSchema, + title: z.string().min(1).max(200), + description: z.string().max(1000).nullable().optional(), + mode: SelfRoleModeSchema, + behavior: SelfRoleBehaviorSchema, + roles: z.array(SelfRoleEntrySchema).min(1), + expiresAt: z.string().nullable().optional() +}); +export type SelfRolePanelDashboard = z.infer; + +export const SelfRolePanelDashboardCreateSchema = SelfRolePanelDashboardSchema.omit({ id: true }); +export type SelfRolePanelDashboardCreate = z.infer; + +export const SelfRolePanelDashboardUpdateSchema = nonEmptyPatch(SelfRolePanelDashboardCreateSchema); +export type SelfRolePanelDashboardUpdate = z.infer; + +// --------------------------------------------------------------------------- +// Tags +// --------------------------------------------------------------------------- + +export const TagDashboardSchema = z.object({ + id: z.string().optional(), + name: z.string().min(1).max(50), + content: z.string().max(2000).nullable().optional(), + responseType: TagResponseTypeSchema, + triggerWord: z.string().max(100).nullable().optional(), + allowedRoleIds: z.array(z.string()), + allowedChannelIds: z.array(z.string()) +}); +export type TagDashboard = z.infer; + +export const TagDashboardCreateSchema = TagDashboardSchema.omit({ id: true }); +export type TagDashboardCreate = z.infer; + +export const TagDashboardUpdateSchema = nonEmptyPatch(TagDashboardCreateSchema); +export type TagDashboardUpdate = z.infer; + +// --------------------------------------------------------------------------- +// Starboard +// --------------------------------------------------------------------------- + +export const StarboardConfigDashboardSchema = z.object({ + enabled: z.boolean(), + channelId: OptionalSnowflakeSchema, + emoji: z.string().min(1).max(32), + threshold: z.number().int().positive(), + allowSelfStar: z.boolean(), + ignoredChannelIds: z.array(z.string()) +}); +export type StarboardConfigDashboard = z.infer; + +export const StarboardConfigDashboardPatchSchema = nonEmptyPatch(StarboardConfigDashboardSchema); +export type StarboardConfigDashboardPatch = z.infer; + +// --------------------------------------------------------------------------- +// Suggestions +// --------------------------------------------------------------------------- + +export const SuggestionConfigDashboardSchema = z.object({ + enabled: z.boolean(), + openChannelId: OptionalSnowflakeSchema, + approvedChannelId: OptionalSnowflakeSchema, + deniedChannelId: OptionalSnowflakeSchema, + createThread: z.boolean() +}); +export type SuggestionConfigDashboard = z.infer; + +export const SuggestionConfigDashboardPatchSchema = nonEmptyPatch(SuggestionConfigDashboardSchema); +export type SuggestionConfigDashboardPatch = z.infer; + +export const SuggestionDashboardSchema = z.object({ + id: z.string(), + authorId: z.string(), + content: z.string(), + status: SuggestionStatusSchema, + upvotes: z.number().int(), + downvotes: z.number().int(), + staffReason: z.string().nullable(), + createdAt: z.string() +}); +export type SuggestionDashboard = z.infer; + +export const SuggestionListQuerySchema = z.object({ + status: SuggestionStatusSchema.optional(), + limit: z.coerce.number().int().positive().max(100).default(25) +}); +export type SuggestionListQuery = z.infer; + +export const SuggestionActionSchema = z.object({ + status: SuggestionStatusSchema, + staffReason: z.string().max(500).nullable().optional() +}); +export type SuggestionAction = z.infer; + +// --------------------------------------------------------------------------- +// Birthdays +// --------------------------------------------------------------------------- + +export const BirthdayConfigDashboardSchema = z.object({ + enabled: z.boolean(), + channelId: OptionalSnowflakeSchema, + roleId: OptionalSnowflakeSchema, + message: z.string().max(500).nullable().optional(), + timezone: z.string().min(1).max(64) +}); +export type BirthdayConfigDashboard = z.infer; + +export const BirthdayConfigDashboardPatchSchema = nonEmptyPatch(BirthdayConfigDashboardSchema); +export type BirthdayConfigDashboardPatch = z.infer; + +// --------------------------------------------------------------------------- +// Temp-Voice +// --------------------------------------------------------------------------- + +export const TempVoiceConfigDashboardSchema = z.object({ + enabled: z.boolean(), + hubChannelId: OptionalSnowflakeSchema, + categoryId: OptionalSnowflakeSchema, + defaultName: z.string().min(1).max(100), + defaultLimit: z.number().int().nonnegative().max(99) +}); +export type TempVoiceConfigDashboard = z.infer; + +export const TempVoiceConfigDashboardPatchSchema = nonEmptyPatch(TempVoiceConfigDashboardSchema); +export type TempVoiceConfigDashboardPatch = z.infer; + +// --------------------------------------------------------------------------- +// Server Stats +// --------------------------------------------------------------------------- + +export const StatsConfigDashboardSchema = z.object({ + enabled: z.boolean(), + membersChannelId: OptionalSnowflakeSchema, + onlineChannelId: OptionalSnowflakeSchema, + boostsChannelId: OptionalSnowflakeSchema, + membersTemplate: z.string().min(1).max(100), + onlineTemplate: z.string().min(1).max(100), + boostsTemplate: z.string().min(1).max(100) +}); +export type StatsConfigDashboard = z.infer; + +export const StatsConfigDashboardPatchSchema = nonEmptyPatch(StatsConfigDashboardSchema); +export type StatsConfigDashboardPatch = z.infer; + +export const ActivityStatSummarySchema = z.object({ + messageCount: z.number().int().nonnegative(), + voiceMinutes: z.number().int().nonnegative(), + activeUserCount: z.number().int().nonnegative() +}); +export type ActivityStatSummary = z.infer; + +// --------------------------------------------------------------------------- +// Social Feeds +// --------------------------------------------------------------------------- + +export const SocialFeedDashboardSchema = z.object({ + id: z.string().optional(), + type: SocialFeedTypeSchema, + sourceId: z.string().min(1).max(200), + channelId: SnowflakeSchema, + rolePingId: OptionalSnowflakeSchema.optional(), + template: z.string().max(500).nullable().optional(), + enabled: z.boolean() +}); +export type SocialFeedDashboard = z.infer; + +export const SocialFeedDashboardCreateSchema = SocialFeedDashboardSchema.omit({ id: true }); +export type SocialFeedDashboardCreate = z.infer; + +export const SocialFeedDashboardUpdateSchema = nonEmptyPatch(SocialFeedDashboardCreateSchema); +export type SocialFeedDashboardUpdate = z.infer; + +// --------------------------------------------------------------------------- +// Scheduler +// --------------------------------------------------------------------------- + +export const ScheduledMessageDashboardSchema = z.object({ + id: z.string(), + channelId: z.string(), + creatorId: z.string(), + content: z.string().nullable(), + rolePingId: z.string().nullable(), + cron: z.string().nullable(), + runAt: z.string().nullable(), + enabled: z.boolean(), + createdAt: z.string() +}); +export type ScheduledMessageDashboard = z.infer; + +export const ScheduledMessageDashboardCreateSchema = z + .object({ + channelId: SnowflakeSchema, + content: z.string().max(2000).nullable().optional(), + rolePingId: OptionalSnowflakeSchema.optional(), + cron: z.string().min(1).max(100).nullable().optional(), + runAt: z + .string() + .refine((value) => !Number.isNaN(Date.parse(value)), 'Invalid date') + .nullable() + .optional() + }) + .refine((value) => Boolean(value.cron?.trim()) || Boolean(value.runAt), { + message: 'Either cron or runAt is required' + }) + .refine((value) => Boolean(value.content?.trim()), { + message: 'Content is required' + }); +export type ScheduledMessageDashboardCreate = z.infer; + +// --------------------------------------------------------------------------- +// Guild Backups +// --------------------------------------------------------------------------- + +export const GuildBackupDashboardSchema = z.object({ + id: z.string(), + name: z.string(), + createdById: z.string(), + createdAt: z.string(), + roleCount: z.number().int().nonnegative(), + channelCount: z.number().int().nonnegative() +}); +export type GuildBackupDashboard = z.infer; + +export const GuildBackupCreateDashboardSchema = z.object({ + name: z.string().min(1).max(100) +}); +export type GuildBackupCreateDashboard = z.infer; + +// --------------------------------------------------------------------------- +// Command Overrides +// --------------------------------------------------------------------------- + +export const CommandOverrideDashboardSchema = z.object({ + commandName: z.string().min(1).max(100), + enabled: z.boolean(), + allowedRoleIds: z.array(z.string()), + deniedRoleIds: z.array(z.string()), + allowedChannelIds: z.array(z.string()), + deniedChannelIds: z.array(z.string()), + cooldownSeconds: z.number().int().nonnegative().nullable() +}); +export type CommandOverrideDashboard = z.infer; + +export const CommandOverrideDashboardUpsertSchema = z.object({ + commandName: z.string().min(1).max(100), + enabled: z.boolean().default(true), + allowedRoleIds: z.array(z.string()).default([]), + deniedRoleIds: z.array(z.string()).default([]), + allowedChannelIds: z.array(z.string()).default([]), + deniedChannelIds: z.array(z.string()).default([]), + cooldownSeconds: z.number().int().nonnegative().nullable().optional() +}); +export type CommandOverrideDashboardUpsert = z.infer; + +/** + * Known slash command names, aggregated from every module's + * `command-definitions.ts` (`SlashCommandBuilder#setName`). Used by the + * dashboard's command-management page to list overridable commands without + * requiring a live bot connection. + */ +export const KNOWN_COMMAND_NAMES = [ + 'ban', 'unban', 'kick', 'timeout', 'untimeout', 'warn', 'purge', 'slowmode', 'lock', 'unlock', + 'nick', 'case', 'modnote', 'automod', 'welcome', 'verify', 'rank', 'leaderboard', 'xp', 'balance', + 'daily', 'weekly', 'work', 'pay', 'gamble', 'slots', 'blackjack', 'coinflip', 'shop', 'inventory', + 'eco', 'userinfo', 'serverinfo', 'roleinfo', 'channelinfo', 'avatar', 'banner', 'poll', 'remindme', + 'reminders', 'afk', 'emoji', 'sticker', 'timestamp', 'snipe', 'editsnipe', 'embed', '8ball', 'dice', + 'flip', 'rps', 'choose', 'trivia', 'tictactoe', 'connect4', 'hangman', 'meme', 'cat', 'dog', + 'giveaway', 'ticket', 'selfroles', 'tag', 'starboard', 'suggest', 'suggestion', 'birthday', 'voice', + 'stats', 'invites', 'feeds', 'schedule', 'announce', 'backup' +] as const; +export type KnownCommandName = (typeof KNOWN_COMMAND_NAMES)[number];