From ebb16a55f3e63d56a4a349fe8f7ce0df499edfef Mon Sep 17 00:00:00 2001 From: shni Date: Fri, 3 Oct 2025 20:48:33 -0500 Subject: [PATCH] feat: implement admin point management system with user selection and modal input --- LEADERBOARD_ADMIN_SYSTEM.md | 196 ++++++++++++++++++ src/commands/messages/alliaces/leaderboard.ts | 25 ++- src/components/buttons/ldManagePoints.ts | 90 ++++++++ src/components/buttons/ldRefresh.ts | 9 +- src/components/modals/ldPointsModal.ts | 167 +++++++++++++++ src/components/selectmenus/ldSelectUser.ts | 83 ++++++++ 6 files changed, 563 insertions(+), 7 deletions(-) create mode 100644 LEADERBOARD_ADMIN_SYSTEM.md create mode 100644 src/components/buttons/ldManagePoints.ts create mode 100644 src/components/modals/ldPointsModal.ts create mode 100644 src/components/selectmenus/ldSelectUser.ts diff --git a/LEADERBOARD_ADMIN_SYSTEM.md b/LEADERBOARD_ADMIN_SYSTEM.md new file mode 100644 index 0000000..cb5038f --- /dev/null +++ b/LEADERBOARD_ADMIN_SYSTEM.md @@ -0,0 +1,196 @@ +# Sistema de Gestión de Puntos del Leaderboard + +## 📋 Descripción + +Se ha implementado un sistema completo de gestión de puntos administrativos para el comando `leaderboard`. Los administradores ahora pueden modificar los puntos de cualquier usuario directamente desde el leaderboard. + +## 🎯 Características Implementadas + +### 1. **Botón de Gestión de Puntos** (Solo para Administradores) +- Aparece únicamente para usuarios con permiso `ManageGuild` +- Se muestra junto al botón "Refrescar" en el leaderboard +- Emoji: ⚙️ +- Label: "Gestionar Puntos" + +### 2. **Select Menu de Usuarios** +- Muestra hasta 25 usuarios con más puntos en el servidor +- Cada opción muestra: + - Nombre del usuario + - Puntos totales, semanales y mensuales actuales +- Ordenado por puntos totales (descendente) + +### 3. **Modal de Modificación de Puntos** +- Tres campos de entrada opcionales: + - **Puntos Totales** + - **Puntos Semanales** + - **Puntos Mensuales** + +#### Sintaxis de Modificación: +- `+50` → Añade 50 puntos +- `-25` → Quita 25 puntos +- `=100` → Establece exactamente 100 puntos +- `100` → Establece exactamente 100 puntos (sin símbolo) + +### 4. **Confirmación Visual** +- Embed con código de color verde +- Muestra los valores antes y después del cambio +- Incluye timestamp y nombre del administrador que hizo el cambio +- Mensaje efímero (solo visible para el administrador) + +## 📁 Archivos Creados + +``` +src/components/ +├── buttons/ +│ ├── ldManagePoints.ts ← Botón principal de gestión +│ └── ldRefresh.ts ← Actualizado para mostrar botón admin +├── selectmenus/ +│ └── ldSelectUser.ts ← Select menu para elegir usuario +└── modals/ + └── ldPointsModal.ts ← Modal para modificar puntos +``` + +## 🔒 Seguridad + +### Verificaciones de Permisos: +1. **En el leaderboard**: Solo muestra el botón si el usuario tiene `ManageGuild` +2. **En el botón**: Verifica permisos antes de mostrar el select menu +3. **En el modal**: Verifica permisos antes de modificar la base de datos + +### Validaciones: +- Los puntos no pueden ser negativos (mínimo: 0) +- Se requiere al menos un campo con valor para procesar +- Manejo de errores en todas las etapas +- Logs detallados de errores + +## 🚀 Cómo Usar + +### Para Administradores: + +1. **Ejecuta el comando leaderboard:** + ``` + !leaderboard + ``` + o + ``` + !ld + ``` + +2. **Verás el botón "⚙️ Gestionar Puntos"** + - Click en el botón + +3. **Selecciona el usuario del menú desplegable** + - Muestra nombre y estadísticas actuales + +4. **Ingresa los cambios en el modal:** + - Ejemplos: + - Puntos Totales: `+100` (añade 100) + - Puntos Semanales: `-50` (quita 50) + - Puntos Mensuales: `=75` (establece a 75) + +5. **Confirma el cambio** + - Verás un embed con los valores actualizados + - El leaderboard se puede refrescar para ver los cambios + +### Para Usuarios Normales: +- Solo verán el botón "Refrescar" +- No tienen acceso a la gestión de puntos + +## 🔄 Flujo del Sistema + +``` +Usuario Admin presiona "Gestionar Puntos" + ↓ +Sistema verifica permisos + ↓ +Muestra lista de usuarios con puntos (Select Menu) + ↓ +Admin selecciona un usuario + ↓ +Se abre modal con 3 campos de entrada + ↓ +Admin ingresa modificaciones (+/-/=) + ↓ +Sistema actualiza la base de datos + ↓ +Muestra embed de confirmación + ↓ +Admin puede refrescar el leaderboard para ver cambios +``` + +## 💾 Cambios en Base de Datos + +### Modelo `PartnershipStats`: +- Se modifica directamente el registro existente +- Si no existe, se crea uno nuevo con valores base en 0 +- Campos modificables: + - `totalPoints` + - `weeklyPoints` + - `monthlyPoints` + +## 📊 Ejemplo de Uso + +### Caso 1: Añadir puntos de bonificación +``` +Usuario: "Juan" +Puntos actuales: 150 +Acción: +50 en Puntos Totales +Resultado: 200 puntos totales +``` + +### Caso 2: Corregir error de conteo +``` +Usuario: "María" +Puntos semanales: 85 +Acción: =80 en Puntos Semanales +Resultado: 80 puntos semanales +``` + +### Caso 3: Penalización +``` +Usuario: "Pedro" +Puntos mensuales: 120 +Acción: -30 en Puntos Mensuales +Resultado: 90 puntos mensuales +``` + +## ⚠️ Notas Importantes + +1. **Los cambios son inmediatos** y afectan todas las tablas del leaderboard +2. **No hay sistema de deshacer** - confirma antes de aplicar cambios +3. **Los puntos mínimos son 0** - no pueden ser negativos +4. **Límite de 25 usuarios** en el select menu (limitación de Discord) +5. **Todos los mensajes son efímeros** - solo el admin los ve + +## 🧪 Testing + +Para probar el sistema: +1. Asegúrate de tener permisos de `ManageGuild` +2. Ejecuta `!leaderboard` +3. Verifica que aparezca el botón de gestión +4. Prueba modificar puntos de un usuario de prueba +5. Refresca el leaderboard para ver los cambios + +## 🐛 Troubleshooting + +**Problema:** No veo el botón de gestión +- **Solución:** Verifica que tengas permisos de administrador del servidor + +**Problema:** El select menu está vacío +- **Solución:** Asegúrate de que haya al menos un usuario con puntos en el servidor + +**Problema:** Los cambios no se reflejan +- **Solución:** Presiona el botón "Refrescar" para actualizar el leaderboard + +## 📝 Logs + +Todos los errores se registran con: +```typescript +logger.error({ err: e }, 'Descripción del error') +``` + +Los logs incluyen: +- Errores al cargar usuarios +- Errores al procesar selecciones +- Errores al actualizar puntos en la base de datos + diff --git a/src/commands/messages/alliaces/leaderboard.ts b/src/commands/messages/alliaces/leaderboard.ts index d8f1e17..ba999d6 100644 --- a/src/commands/messages/alliaces/leaderboard.ts +++ b/src/commands/messages/alliaces/leaderboard.ts @@ -3,6 +3,7 @@ import { CommandMessage } from "../../../core/types/commands"; import { prisma } from "../../../core/database/prisma"; import type { Message } from "discord.js"; +import { PermissionFlagsBits } from "discord.js"; const MAX_ENTRIES = 10; @@ -40,7 +41,7 @@ function codeBlock(lines: string[]): string { ].join('\n'); } -export async function buildLeaderboardPanel(message: Message) { +export async function buildLeaderboardPanel(message: Message, isAdmin: boolean = false) { const guild = message.guild!; const guildId = guild.id; const userId = message.author.id; @@ -99,6 +100,18 @@ export async function buildLeaderboardPanel(message: Message) { const now = new Date(); const ts = now.toISOString().replace('T', ' ').split('.')[0]; + // Botón base que todos ven + const buttons: any[] = [ + { type: 2, style: 2, emoji: '1420539242643193896', label: 'Refrescar', custom_id: 'ld_refresh' } + ]; + + // Si es admin, añadir botón de gestión + if (isAdmin) { + buttons.push( + { type: 2, style: 1, emoji: '⚙️', label: 'Gestionar Puntos', custom_id: 'ld_manage_points' } + ); + } + // @ts-ignore - estructura de Display Components V2 const panel = { type: 17, @@ -126,9 +139,7 @@ export async function buildLeaderboardPanel(message: Message) { { type: 14, divider: false, spacing: 1 }, { type: 1, - components: [ - { type: 2, style: 2, emoji: '1420539242643193896', label: 'Refrescar', custom_id: 'ld_refresh' } - ] + components: buttons } ] }; @@ -150,7 +161,11 @@ export const command: CommandMessage = { return; } - const panel = await buildLeaderboardPanel(message); + // Verificar si el usuario es administrador + const member = await message.guild.members.fetch(message.author.id); + const isAdmin = member.permissions.has(PermissionFlagsBits.ManageGuild); + + const panel = await buildLeaderboardPanel(message, isAdmin); await message.reply({ // @ts-ignore Flag de componentes V2 flags: 32768, diff --git a/src/components/buttons/ldManagePoints.ts b/src/components/buttons/ldManagePoints.ts new file mode 100644 index 0000000..877c581 --- /dev/null +++ b/src/components/buttons/ldManagePoints.ts @@ -0,0 +1,90 @@ +import logger from "../../core/lib/logger"; +import { + ButtonInteraction, + MessageFlags, + PermissionFlagsBits, + StringSelectMenuBuilder, + ActionRowBuilder +} from 'discord.js'; +import { prisma } from '../../core/database/prisma'; + +export default { + customId: 'ld_manage_points', + run: async (interaction: ButtonInteraction) => { + if (!interaction.guild) { + return interaction.reply({ + content: '❌ Solo disponible en servidores.', + flags: MessageFlags.Ephemeral + }); + } + + // Verificar permisos de administrador + const member = await interaction.guild.members.fetch(interaction.user.id); + if (!member.permissions.has(PermissionFlagsBits.ManageGuild)) { + return interaction.reply({ + content: '❌ Solo los administradores pueden gestionar puntos.', + flags: MessageFlags.Ephemeral + }); + } + + try { + // Obtener todos los usuarios con puntos en este servidor + const stats = await prisma.partnershipStats.findMany({ + where: { guildId: interaction.guild.id }, + orderBy: { totalPoints: 'desc' }, + take: 25 // Discord limita a 25 opciones en select menus + }); + + if (stats.length === 0) { + return interaction.reply({ + content: '❌ No hay usuarios con puntos en este servidor todavía.', + flags: MessageFlags.Ephemeral + }); + } + + // Construir opciones del select menu + const options = await Promise.all( + stats.map(async (stat) => { + let displayName = 'Usuario desconocido'; + try { + const member = await interaction.guild!.members.fetch(stat.userId); + displayName = member.displayName || member.user.username; + } catch { + try { + const user = await interaction.client.users.fetch(stat.userId); + displayName = user.username; + } catch { + // Mantener el nombre por defecto + } + } + + return { + label: displayName, + description: `Total: ${stat.totalPoints} | Semanal: ${stat.weeklyPoints} | Mensual: ${stat.monthlyPoints}`, + value: stat.userId + }; + }) + ); + + const selectMenu = new StringSelectMenuBuilder() + .setCustomId('ld_select_user') + .setPlaceholder('Selecciona un usuario para gestionar sus puntos') + .addOptions(options); + + const row = new ActionRowBuilder() + .addComponents(selectMenu); + + await interaction.reply({ + content: '### ⚙️ Gestión de Puntos\nSelecciona el usuario al que deseas modificar los puntos:', + components: [row], + flags: MessageFlags.Ephemeral + }); + } catch (e) { + logger.error({ err: e }, 'Error en ldManagePoints'); + await interaction.reply({ + content: '❌ Error al cargar la lista de usuarios.', + flags: MessageFlags.Ephemeral + }); + } + } +}; diff --git a/src/components/buttons/ldRefresh.ts b/src/components/buttons/ldRefresh.ts index 816ae41..a1f4411 100644 --- a/src/components/buttons/ldRefresh.ts +++ b/src/components/buttons/ldRefresh.ts @@ -1,5 +1,5 @@ import logger from "../../core/lib/logger"; -import { ButtonInteraction, MessageFlags } from 'discord.js'; +import { ButtonInteraction, MessageFlags, PermissionFlagsBits } from 'discord.js'; import { buildLeaderboardPanel } from '../../commands/messages/alliaces/leaderboard'; export default { @@ -10,9 +10,14 @@ export default { } try { await interaction.deferUpdate(); + + // Verificar si el usuario es administrador + const member = await interaction.guild.members.fetch(interaction.user.id); + const isAdmin = member.permissions.has(PermissionFlagsBits.ManageGuild); + // Reusar el builder esperando un objeto con guild y author const fakeMessage: any = { guild: interaction.guild, author: interaction.user }; - const panel = await buildLeaderboardPanel(fakeMessage); + const panel = await buildLeaderboardPanel(fakeMessage, isAdmin); await interaction.message.edit({ components: [panel] }); } catch (e) { // @ts-ignore diff --git a/src/components/modals/ldPointsModal.ts b/src/components/modals/ldPointsModal.ts new file mode 100644 index 0000000..2b0f905 --- /dev/null +++ b/src/components/modals/ldPointsModal.ts @@ -0,0 +1,167 @@ +import logger from "../../core/lib/logger"; +import { + ModalSubmitInteraction, + MessageFlags, + PermissionFlagsBits, + EmbedBuilder +} from 'discord.js'; +import { prisma } from '../../core/database/prisma'; + +export default { + customId: 'ld_points_modal', + run: async (interaction: ModalSubmitInteraction) => { + if (!interaction.guild) { + return interaction.reply({ + content: '❌ Solo disponible en servidores.', + flags: MessageFlags.Ephemeral + }); + } + + // Verificar permisos + const member = await interaction.guild.members.fetch(interaction.user.id); + if (!member.permissions.has(PermissionFlagsBits.ManageGuild)) { + return interaction.reply({ + content: '❌ Solo los administradores pueden gestionar puntos.', + flags: MessageFlags.Ephemeral + }); + } + + try { + // Extraer el userId del customId (formato: ld_points_modal:userId) + const userId = interaction.customId.split(':')[1]; + if (!userId) { + return interaction.reply({ + content: '❌ Error al identificar el usuario.', + flags: MessageFlags.Ephemeral + }); + } + + // Obtener valores del modal + const totalInput = interaction.fields.getTextInputValue('total_points').trim(); + const weeklyInput = interaction.fields.getTextInputValue('weekly_points').trim(); + const monthlyInput = interaction.fields.getTextInputValue('monthly_points').trim(); + + // Si no se ingresó nada, retornar + if (!totalInput && !weeklyInput && !monthlyInput) { + return interaction.reply({ + content: '❌ Debes ingresar al menos un valor para modificar.', + flags: MessageFlags.Ephemeral + }); + } + + // Obtener o crear el registro de stats del usuario + let stats = await prisma.partnershipStats.findUnique({ + where: { + userId_guildId: { + userId, + guildId: interaction.guild.id + } + } + }); + + if (!stats) { + // Crear nuevo registro si no existe + stats = await prisma.partnershipStats.create({ + data: { + userId, + guildId: interaction.guild.id, + totalPoints: 0, + weeklyPoints: 0, + monthlyPoints: 0 + } + }); + } + + // Función para parsear el input y calcular el nuevo valor + const calculateNewValue = (input: string, currentValue: number): number => { + if (!input) return currentValue; + + const firstChar = input[0]; + const numValue = parseInt(input.substring(1)) || 0; + + if (firstChar === '+') { + return Math.max(0, currentValue + numValue); + } else if (firstChar === '-') { + return Math.max(0, currentValue - numValue); + } else if (firstChar === '=') { + return Math.max(0, numValue); + } else { + // Si no tiene símbolo, tratar como valor absoluto + const parsedValue = parseInt(input); + return isNaN(parsedValue) ? currentValue : Math.max(0, parsedValue); + } + }; + + // Calcular nuevos valores + const newTotalPoints = calculateNewValue(totalInput, stats.totalPoints); + const newWeeklyPoints = calculateNewValue(weeklyInput, stats.weeklyPoints); + const newMonthlyPoints = calculateNewValue(monthlyInput, stats.monthlyPoints); + + // Actualizar en base de datos + const updatedStats = await prisma.partnershipStats.update({ + where: { + userId_guildId: { + userId, + guildId: interaction.guild.id + } + }, + data: { + totalPoints: newTotalPoints, + weeklyPoints: newWeeklyPoints, + monthlyPoints: newMonthlyPoints + } + }); + + // Obtener nombre del usuario + let userName = 'Usuario'; + try { + const targetMember = await interaction.guild.members.fetch(userId); + userName = targetMember.displayName || targetMember.user.username; + } catch { + try { + const user = await interaction.client.users.fetch(userId); + userName = user.username; + } catch { + userName = userId; + } + } + + // Crear embed de confirmación + const embed = new EmbedBuilder() + .setColor(0x00ff00) + .setTitle('✅ Puntos Actualizados') + .setDescription(`Se han actualizado los puntos de **${userName}**`) + .addFields( + { + name: '📊 Puntos Totales', + value: `${stats.totalPoints} → **${newTotalPoints}**`, + inline: true + }, + { + name: '📅 Puntos Semanales', + value: `${stats.weeklyPoints} → **${newWeeklyPoints}**`, + inline: true + }, + { + name: '🗓️ Puntos Mensuales', + value: `${stats.monthlyPoints} → **${newMonthlyPoints}**`, + inline: true + } + ) + .setFooter({ text: `Modificado por ${interaction.user.username}` }) + .setTimestamp(); + + await interaction.reply({ + embeds: [embed], + flags: MessageFlags.Ephemeral + }); + + } catch (e) { + logger.error({ err: e }, 'Error en ldPointsModal'); + await interaction.reply({ + content: '❌ Error al actualizar los puntos.', + flags: MessageFlags.Ephemeral + }); + } + } +}; diff --git a/src/components/selectmenus/ldSelectUser.ts b/src/components/selectmenus/ldSelectUser.ts new file mode 100644 index 0000000..15ead52 --- /dev/null +++ b/src/components/selectmenus/ldSelectUser.ts @@ -0,0 +1,83 @@ +import logger from "../../core/lib/logger"; +import { + StringSelectMenuInteraction, + MessageFlags, + ModalBuilder, + TextInputBuilder, + TextInputStyle, + ActionRowBuilder +} from 'discord.js'; + +export default { + customId: 'ld_select_user', + run: async (interaction: StringSelectMenuInteraction) => { + if (!interaction.guild) { + return interaction.reply({ + content: '❌ Solo disponible en servidores.', + flags: MessageFlags.Ephemeral + }); + } + + try { + const selectedUserId = interaction.values[0]; + + // Obtener información del usuario seleccionado para mostrar en el modal + let userName = 'Usuario'; + try { + const member = await interaction.guild.members.fetch(selectedUserId); + userName = member.displayName || member.user.username; + } catch { + try { + const user = await interaction.client.users.fetch(selectedUserId); + userName = user.username; + } catch { + userName = selectedUserId; + } + } + + // Crear modal para ingresar la cantidad de puntos + const modal = new ModalBuilder() + .setCustomId(`ld_points_modal:${selectedUserId}`) + .setTitle(`Gestionar puntos de ${userName}`); + + // Input para puntos totales + const totalInput = new TextInputBuilder() + .setCustomId('total_points') + .setLabel('Puntos Totales (+ para añadir, - para quitar)') + .setPlaceholder('Ejemplo: +50 o -25 o =100 (para establecer)') + .setStyle(TextInputStyle.Short) + .setRequired(false); + + // Input para puntos semanales + const weeklyInput = new TextInputBuilder() + .setCustomId('weekly_points') + .setLabel('Puntos Semanales (+ para añadir, - para quitar)') + .setPlaceholder('Ejemplo: +10 o -5 o =50 (para establecer)') + .setStyle(TextInputStyle.Short) + .setRequired(false); + + // Input para puntos mensuales + const monthlyInput = new TextInputBuilder() + .setCustomId('monthly_points') + .setLabel('Puntos Mensuales (+ para añadir, - para quitar)') + .setPlaceholder('Ejemplo: +20 o -10 o =75 (para establecer)') + .setStyle(TextInputStyle.Short) + .setRequired(false); + + // Añadir los inputs al modal + modal.addComponents( + new ActionRowBuilder().addComponents(totalInput), + new ActionRowBuilder().addComponents(weeklyInput), + new ActionRowBuilder().addComponents(monthlyInput) + ); + + await interaction.showModal(modal); + } catch (e) { + logger.error({ err: e }, 'Error en ldSelectUser'); + await interaction.reply({ + content: '❌ Error al procesar la selección.', + flags: MessageFlags.Ephemeral + }); + } + } +};