diff --git a/bot/commands/admin/featureflags.ts b/bot/commands/admin/featureflags.ts new file mode 100644 index 0000000..a5c85fc --- /dev/null +++ b/bot/commands/admin/featureflags.ts @@ -0,0 +1,297 @@ +import { createCommand } from "@shared/lib/utils"; +import { SlashCommandBuilder, PermissionFlagsBits, Colors, userMention, roleMention, ChatInputCommandInteraction } from "discord.js"; +import { featureFlagsService } from "@shared/modules/feature-flags/feature-flags.service"; +import { createBaseEmbed, createErrorEmbed, createSuccessEmbed } from "@lib/embeds"; +import { UserError } from "@shared/lib/errors"; + +export const featureflags = createCommand({ + data: new SlashCommandBuilder() + .setName("featureflags") + .setDescription("Manage feature flags for beta testing") + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .addSubcommand(sub => + sub.setName("list") + .setDescription("List all feature flags") + ) + .addSubcommand(sub => + sub.setName("create") + .setDescription("Create a new feature flag") + .addStringOption(opt => + opt.setName("name") + .setDescription("Name of the feature flag") + .setRequired(true) + ) + .addStringOption(opt => + opt.setName("description") + .setDescription("Description of the feature flag") + .setRequired(false) + ) + ) + .addSubcommand(sub => + sub.setName("delete") + .setDescription("Delete a feature flag") + .addStringOption(opt => + opt.setName("name") + .setDescription("Name of the feature flag") + .setRequired(true) + .setAutocomplete(true) + ) + ) + .addSubcommand(sub => + sub.setName("enable") + .setDescription("Enable a feature flag") + .addStringOption(opt => + opt.setName("name") + .setDescription("Name of the feature flag") + .setRequired(true) + .setAutocomplete(true) + ) + ) + .addSubcommand(sub => + sub.setName("disable") + .setDescription("Disable a feature flag") + .addStringOption(opt => + opt.setName("name") + .setDescription("Name of the feature flag") + .setRequired(true) + .setAutocomplete(true) + ) + ) + .addSubcommand(sub => + sub.setName("grant") + .setDescription("Grant access to a feature flag") + .addStringOption(opt => + opt.setName("name") + .setDescription("Name of the feature flag") + .setRequired(true) + .setAutocomplete(true) + ) + .addUserOption(opt => + opt.setName("user") + .setDescription("User to grant access to") + .setRequired(false) + ) + .addRoleOption(opt => + opt.setName("role") + .setDescription("Role to grant access to") + .setRequired(false) + ) + ) + .addSubcommand(sub => + sub.setName("revoke") + .setDescription("Revoke access from a feature flag") + .addIntegerOption(opt => + opt.setName("id") + .setDescription("Access record ID to revoke") + .setRequired(true) + ) + ) + .addSubcommand(sub => + sub.setName("access") + .setDescription("List access records for a feature flag") + .addStringOption(opt => + opt.setName("name") + .setDescription("Name of the feature flag") + .setRequired(true) + .setAutocomplete(true) + ) + ), + autocomplete: async (interaction) => { + const focused = interaction.options.getFocused(true); + + if (focused.name === "name") { + const flags = await featureFlagsService.listFlags(); + const filtered = flags + .filter(f => f.name.toLowerCase().includes(focused.value.toLowerCase())) + .slice(0, 25); + + await interaction.respond( + filtered.map(f => ({ name: `${f.name} (${f.enabled ? "enabled" : "disabled"})`, value: f.name })) + ); + } + }, + execute: async (interaction) => { + await interaction.deferReply(); + + const subcommand = interaction.options.getSubcommand(); + + try { + switch (subcommand) { + case "list": + await handleList(interaction); + break; + case "create": + await handleCreate(interaction); + break; + case "delete": + await handleDelete(interaction); + break; + case "enable": + await handleEnable(interaction); + break; + case "disable": + await handleDisable(interaction); + break; + case "grant": + await handleGrant(interaction); + break; + case "revoke": + await handleRevoke(interaction); + break; + case "access": + await handleAccess(interaction); + break; + } + } catch (error) { + if (error instanceof UserError) { + await interaction.editReply({ embeds: [createErrorEmbed(error.message)] }); + } else { + throw error; + } + } + }, +}); + +async function handleList(interaction: ChatInputCommandInteraction) { + const flags = await featureFlagsService.listFlags(); + + if (flags.length === 0) { + await interaction.editReply({ embeds: [createBaseEmbed("Feature Flags", "No feature flags have been created yet.", Colors.Blue)] }); + return; + } + + const embed = createBaseEmbed("Feature Flags", undefined, Colors.Blue) + .addFields( + flags.map(f => ({ + name: f.name, + value: `${f.enabled ? "✅ Enabled" : "❌ Disabled"}\n${f.description || "*No description*"}`, + inline: false, + })) + ); + + await interaction.editReply({ embeds: [embed] }); +} + +async function handleCreate(interaction: ChatInputCommandInteraction) { + const name = interaction.options.getString("name", true); + const description = interaction.options.getString("description"); + + const flag = await featureFlagsService.createFlag(name, description ?? undefined); + + if (!flag) { + await interaction.editReply({ embeds: [createErrorEmbed("Failed to create feature flag.")] }); + return; + } + + await interaction.editReply({ + embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" created successfully. Use \`/featureflags enable\` to enable it.`)] + }); +} + +async function handleDelete(interaction: ChatInputCommandInteraction) { + const name = interaction.options.getString("name", true); + + const flag = await featureFlagsService.deleteFlag(name); + + await interaction.editReply({ + embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been deleted.`)] + }); +} + +async function handleEnable(interaction: ChatInputCommandInteraction) { + const name = interaction.options.getString("name", true); + + const flag = await featureFlagsService.setFlagEnabled(name, true); + + await interaction.editReply({ + embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been enabled.`)] + }); +} + +async function handleDisable(interaction: ChatInputCommandInteraction) { + const name = interaction.options.getString("name", true); + + const flag = await featureFlagsService.setFlagEnabled(name, false); + + await interaction.editReply({ + embeds: [createSuccessEmbed(`Feature flag "**${flag.name}**" has been disabled.`)] + }); +} + +async function handleGrant(interaction: ChatInputCommandInteraction) { + const name = interaction.options.getString("name", true); + const user = interaction.options.getUser("user"); + const role = interaction.options.getRole("role"); + + if (!user && !role) { + await interaction.editReply({ + embeds: [createErrorEmbed("You must specify either a user or a role to grant access to.")] + }); + return; + } + + const access = await featureFlagsService.grantAccess(name, { + userId: user?.id, + roleId: role?.id, + guildId: interaction.guildId!, + }); + + if (!access) { + await interaction.editReply({ embeds: [createErrorEmbed("Failed to grant access.")] }); + return; + } + + let target: string; + if (user) { + target = userMention(user.id); + } else if (role) { + target = roleMention(role.id); + } else { + target = "Unknown"; + } + + await interaction.editReply({ + embeds: [createSuccessEmbed(`Access to "**${name}**" granted to ${target} (ID: ${access.id})`)] + }); +} + +async function handleRevoke(interaction: ChatInputCommandInteraction) { + const id = interaction.options.getInteger("id", true); + + const access = await featureFlagsService.revokeAccess(id); + + await interaction.editReply({ + embeds: [createSuccessEmbed(`Access record #${access.id} has been revoked.`)] + }); +} + +async function handleAccess(interaction: ChatInputCommandInteraction) { + const name = interaction.options.getString("name", true); + + const accessRecords = await featureFlagsService.listAccess(name); + + if (accessRecords.length === 0) { + await interaction.editReply({ + embeds: [createBaseEmbed("Feature Flag Access", `No access records for "**${name}**".`, Colors.Blue)] + }); + return; + } + + const fields = accessRecords.map(a => { + let target = "Unknown"; + if (a.userId) target = `User: ${userMention(a.userId.toString())}`; + else if (a.roleId) target = `Role: ${roleMention(a.roleId.toString())}`; + else if (a.guildId) target = `Guild: ${a.guildId.toString()}`; + + return { + name: `ID: ${a.id}`, + value: target, + inline: true, + }; + }); + + const embed = createBaseEmbed(`Feature Flag Access: ${name}`, undefined, Colors.Blue) + .addFields(fields); + + await interaction.editReply({ embeds: [embed] }); +}