diff --git a/config.json b/config.json index 212f32930..8fd03cad6 100644 --- a/config.json +++ b/config.json @@ -150,7 +150,8 @@ "github": "everyone", "rank": "everyone", "leaderboard": "everyone", - "profile": "everyone" + "profile": "everyone", + "showcase": "everyone" } }, "help": { @@ -165,6 +166,9 @@ "poll": { "enabled": false }, + "showcase": { + "enabled": false + }, "github": { "feed": { "enabled": false, diff --git a/migrations/009_showcases.cjs b/migrations/009_showcases.cjs new file mode 100644 index 000000000..02bd5ae96 --- /dev/null +++ b/migrations/009_showcases.cjs @@ -0,0 +1,45 @@ +/** + * Add showcases and showcase_votes tables for /showcase project showcase system. + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/50 + */ + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = (pgm) => { + pgm.sql(` + CREATE TABLE IF NOT EXISTS showcases ( + id SERIAL PRIMARY KEY, + guild_id TEXT NOT NULL, + author_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL, + tech_stack TEXT[] DEFAULT '{}', + repo_url TEXT, + live_url TEXT, + message_id TEXT, + channel_id TEXT, + upvotes INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW() + ) + `); + + pgm.sql('CREATE INDEX IF NOT EXISTS idx_showcases_guild ON showcases(guild_id)'); + pgm.sql( + 'CREATE INDEX IF NOT EXISTS idx_showcases_author ON showcases(guild_id, author_id)', + ); + + pgm.sql(` + CREATE TABLE IF NOT EXISTS showcase_votes ( + guild_id TEXT NOT NULL, + showcase_id INTEGER NOT NULL REFERENCES showcases(id) ON DELETE CASCADE, + user_id TEXT NOT NULL, + PRIMARY KEY (guild_id, showcase_id, user_id) + ) + `); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = (pgm) => { + pgm.sql('DROP TABLE IF EXISTS showcase_votes CASCADE'); + pgm.sql('DROP TABLE IF EXISTS showcases CASCADE'); +}; diff --git a/src/api/utils/configAllowlist.js b/src/api/utils/configAllowlist.js index 18cac4fe6..f60dcfa21 100644 --- a/src/api/utils/configAllowlist.js +++ b/src/api/utils/configAllowlist.js @@ -17,6 +17,7 @@ export const SAFE_CONFIG_KEYS = new Set([ 'announce', 'snippet', 'poll', + 'showcase', 'tldr', 'afk', 'reputation', diff --git a/src/commands/showcase.js b/src/commands/showcase.js new file mode 100644 index 000000000..172a953fa --- /dev/null +++ b/src/commands/showcase.js @@ -0,0 +1,626 @@ +/** + * Showcase Command + * Submit, browse, and upvote community projects via /showcase. + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/50 + */ + +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + ModalBuilder, + SlashCommandBuilder, + TextInputBuilder, + TextInputStyle, +} from 'discord.js'; +import { getPool } from '../db.js'; +import { info, warn } from '../logger.js'; +import { getConfig } from '../modules/config.js'; +import { safeEditReply, safeReply, safeSend } from '../utils/safeSend.js'; + +/** Embed colour for showcase responses. */ +const EMBED_COLOR = 0x5865f2; + +// ── Validation helpers ───────────────────────────────────────────── + +/** + * Basic URL validation — returns true for empty/null (optional fields). + * + * @param {string|null} str + * @returns {boolean} + */ +function isValidUrl(str) { + if (!str) return true; // optional fields + try { + const url = new URL(str); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +} + +/** Number of showcases per page in browse view. */ +const SHOWCASES_PER_PAGE = 5; + +export const data = new SlashCommandBuilder() + .setName('showcase') + .setDescription('Submit, browse, and upvote community projects') + .addSubcommand((sub) => + sub.setName('submit').setDescription('Submit a new project to the showcase'), + ) + .addSubcommand((sub) => + sub + .setName('browse') + .setDescription('Browse showcased projects') + .addStringOption((opt) => + opt + .setName('tag') + .setDescription('Filter by tech stack tag (e.g. "react", "postgres")') + .setRequired(false), + ) + .addIntegerOption((opt) => + opt.setName('page').setDescription('Page number').setRequired(false), + ), + ) + .addSubcommand((sub) => + sub.setName('top').setDescription('View the top 10 most upvoted projects'), + ) + .addSubcommand((sub) => + sub + .setName('view') + .setDescription('View a specific project by ID') + .addIntegerOption((opt) => opt.setName('id').setDescription('Project ID').setRequired(true)), + ); + +// ── Helpers ──────────────────────────────────────────────────────── + +/** + * Build the showcase embed for a project. + * + * @param {Object} showcase - Showcase row from the database. + * @returns {EmbedBuilder} + */ +export function buildShowcaseEmbed(showcase) { + const submittedTs = Math.floor(new Date(showcase.created_at).getTime() / 1000); + + const embed = new EmbedBuilder() + .setColor(EMBED_COLOR) + .setTitle(showcase.name.slice(0, 256)) + .setDescription(showcase.description.slice(0, 4096)) + .setFooter({ text: `ID: ${showcase.id}` }) + .addFields({ name: 'Submitted', value: ``, inline: true }); + + if (showcase.tech_stack && showcase.tech_stack.length > 0) { + embed.addFields({ name: 'Tech Stack', value: showcase.tech_stack.join(', ').slice(0, 1024) }); + } + if (showcase.repo_url) { + embed.addFields({ name: 'Repo URL', value: showcase.repo_url.slice(0, 1024) }); + } + if (showcase.live_url) { + embed.addFields({ name: 'Live URL', value: showcase.live_url.slice(0, 1024) }); + } + + embed.addFields( + { name: 'Author', value: `<@${showcase.author_id}>`, inline: true }, + { name: 'Upvotes', value: String(showcase.upvotes ?? 0), inline: true }, + ); + + return embed; +} + +/** + * Build the upvote action row for a showcase. + * + * @param {number} showcaseId + * @param {number} upvotes + * @returns {ActionRowBuilder} + */ +export function buildUpvoteRow(showcaseId, upvotes) { + const button = new ButtonBuilder() + .setCustomId(`showcase_upvote_${showcaseId}`) + .setLabel(`👍 ${upvotes}`) + .setStyle(ButtonStyle.Primary); + + return new ActionRowBuilder().addComponents(button); +} + +// ── Subcommand handlers ──────────────────────────────────────────── + +/** + * Handle /showcase submit — shows a modal. + * + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +async function handleSubmit(interaction) { + const modal = new ModalBuilder() + .setCustomId('showcase_submit_modal') + .setTitle('Submit Your Project'); + + const nameInput = new TextInputBuilder() + .setCustomId('showcase_name') + .setLabel('Project Name') + .setStyle(TextInputStyle.Short) + .setRequired(true) + .setMaxLength(100); + + const descInput = new TextInputBuilder() + .setCustomId('showcase_description') + .setLabel('Description') + .setStyle(TextInputStyle.Paragraph) + .setRequired(true) + .setMaxLength(1000); + + const techInput = new TextInputBuilder() + .setCustomId('showcase_tech') + .setLabel('Tech Stack (comma-separated, optional)') + .setStyle(TextInputStyle.Short) + .setRequired(false) + .setMaxLength(200) + .setPlaceholder('node, react, postgres'); + + const repoInput = new TextInputBuilder() + .setCustomId('showcase_repo') + .setLabel('Repo URL (optional)') + .setStyle(TextInputStyle.Short) + .setRequired(false) + .setMaxLength(500); + + const liveInput = new TextInputBuilder() + .setCustomId('showcase_live') + .setLabel('Live URL (optional)') + .setStyle(TextInputStyle.Short) + .setRequired(false) + .setMaxLength(500); + + modal.addComponents( + new ActionRowBuilder().addComponents(nameInput), + new ActionRowBuilder().addComponents(descInput), + new ActionRowBuilder().addComponents(techInput), + new ActionRowBuilder().addComponents(repoInput), + new ActionRowBuilder().addComponents(liveInput), + ); + + await interaction.showModal(modal); +} + +/** + * Handle the showcase_submit_modal submission. + * + * @param {import('discord.js').ModalSubmitInteraction} interaction + * @param {import('pg').Pool} pool + */ +export async function handleShowcaseModalSubmit(interaction, pool) { + const guildId = interaction.guildId; + if (!guildId) { + await safeReply(interaction, { + content: '❌ This command can only be used in a server.', + ephemeral: true, + }); + return; + } + + const guildConfig = getConfig(guildId); + if (guildConfig.showcase?.enabled === false) { + await safeReply(interaction, { + content: '❌ The showcase feature is disabled in this server.', + ephemeral: true, + }); + return; + } + + const name = interaction.fields.getTextInputValue('showcase_name').trim(); + const description = interaction.fields.getTextInputValue('showcase_description').trim(); + const techRaw = interaction.fields.getTextInputValue('showcase_tech').trim(); + const repoUrl = interaction.fields.getTextInputValue('showcase_repo').trim() || null; + const liveUrl = interaction.fields.getTextInputValue('showcase_live').trim() || null; + + const techStack = techRaw + ? techRaw + .split(',') + .map((t) => t.trim().toLowerCase()) + .filter((t) => t.length > 0) + : []; + + if (!isValidUrl(repoUrl)) { + await safeReply(interaction, { + content: '❌ Invalid Repo URL. Please provide a valid URL (e.g. https://github.com/...).', + ephemeral: true, + }); + return; + } + + if (!isValidUrl(liveUrl)) { + await safeReply(interaction, { + content: '❌ Invalid Live URL. Please provide a valid URL (e.g. https://myapp.com).', + ephemeral: true, + }); + return; + } + + await interaction.deferReply({ ephemeral: true }); + + const { rows } = await pool.query( + `INSERT INTO showcases (guild_id, author_id, name, description, tech_stack, repo_url, live_url, channel_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING *`, + [ + guildId, + interaction.user.id, + name, + description, + techStack, + repoUrl, + liveUrl, + interaction.channelId, + ], + ); + + const showcase = rows[0]; + const embed = buildShowcaseEmbed(showcase); + const row = buildUpvoteRow(showcase.id, 0); + + if (!interaction.channel) { + await safeEditReply(interaction, { content: '❌ Cannot post in this channel.' }); + return; + } + + const msg = await safeSend(interaction.channel, { embeds: [embed], components: [row] }); + + // Store message_id for future updates + await pool.query('UPDATE showcases SET message_id = $1 WHERE id = $2', [msg.id, showcase.id]); + + info('Showcase submitted', { + showcaseId: showcase.id, + guildId, + name, + authorId: interaction.user.id, + }); + + await safeEditReply(interaction, { + content: `✅ Project **${name}** submitted to the showcase! (ID: **#${showcase.id}**)`, + }); +} + +/** + * Handle /showcase browse [tag] [page] + * + * @param {import('discord.js').ChatInputCommandInteraction} interaction + * @param {import('pg').Pool} pool + */ +async function handleBrowse(interaction, pool) { + const tag = interaction.options.getString('tag')?.toLowerCase() ?? null; + const page = Math.max(1, interaction.options.getInteger('page') ?? 1); + + let countResult; + let rows; + + if (tag) { + countResult = await pool.query( + 'SELECT COUNT(*)::int AS total FROM showcases WHERE guild_id = $1 AND $2 = ANY(tech_stack)', + [interaction.guildId, tag], + ); + const total = countResult.rows[0].total; + + if (total === 0) { + await safeEditReply(interaction, { + content: `📭 No projects found with tag **${tag}**.`, + }); + return; + } + + const totalPages = Math.ceil(total / SHOWCASES_PER_PAGE); + const safePage = Math.min(page, totalPages); + const offset = (safePage - 1) * SHOWCASES_PER_PAGE; + + ({ rows } = await pool.query( + `SELECT id, name, author_id, tech_stack, upvotes, created_at + FROM showcases + WHERE guild_id = $1 AND $2 = ANY(tech_stack) + ORDER BY created_at DESC + LIMIT $3 OFFSET $4`, + [interaction.guildId, tag, SHOWCASES_PER_PAGE, offset], + )); + + await sendBrowseEmbed(interaction, rows, safePage, totalPages, total, tag); + } else { + countResult = await pool.query( + 'SELECT COUNT(*)::int AS total FROM showcases WHERE guild_id = $1', + [interaction.guildId], + ); + const total = countResult.rows[0].total; + + if (total === 0) { + await safeEditReply(interaction, { + content: '📭 No projects have been showcased yet. Be the first! Use `/showcase submit`.', + }); + return; + } + + const totalPages = Math.ceil(total / SHOWCASES_PER_PAGE); + const safePage = Math.min(page, totalPages); + const offset = (safePage - 1) * SHOWCASES_PER_PAGE; + + ({ rows } = await pool.query( + `SELECT id, name, author_id, tech_stack, upvotes, created_at + FROM showcases + WHERE guild_id = $1 + ORDER BY created_at DESC + LIMIT $2 OFFSET $3`, + [interaction.guildId, SHOWCASES_PER_PAGE, offset], + )); + + await sendBrowseEmbed(interaction, rows, safePage, totalPages, total, null); + } +} + +/** + * Send the browse embed with paginated results. + */ +async function sendBrowseEmbed(interaction, rows, page, totalPages, total, tag) { + const embed = new EmbedBuilder() + .setColor(EMBED_COLOR) + .setTitle(tag ? `🔍 Projects tagged "${tag}"` : '🚀 Community Showcase') + .setFooter({ text: `Page ${page} of ${totalPages} • ${total} project(s)` }); + + for (const row of rows) { + const tech = row.tech_stack?.length > 0 ? row.tech_stack.join(', ') : 'None listed'; + const value = `By <@${row.author_id}> • Tech: ${tech} • 👍 ${row.upvotes}`; + embed.addFields({ + name: `#${row.id} — ${row.name}`.slice(0, 256), + value: value.slice(0, 1024), + }); + } + + await safeEditReply(interaction, { embeds: [embed] }); +} + +/** + * Handle /showcase top + * + * @param {import('discord.js').ChatInputCommandInteraction} interaction + * @param {import('pg').Pool} pool + */ +async function handleTop(interaction, pool) { + const { rows } = await pool.query( + `SELECT id, name, author_id, tech_stack, upvotes + FROM showcases + WHERE guild_id = $1 + ORDER BY upvotes DESC, created_at DESC + LIMIT 10`, + [interaction.guildId], + ); + + if (rows.length === 0) { + await safeEditReply(interaction, { + content: '📭 No projects have been showcased yet. Be the first! Use `/showcase submit`.', + }); + return; + } + + const embed = new EmbedBuilder() + .setColor(EMBED_COLOR) + .setTitle('🏆 Top 10 Showcase Projects') + .setFooter({ text: 'Sorted by upvotes' }); + + rows.forEach((row, i) => { + const tech = row.tech_stack?.length > 0 ? row.tech_stack.join(', ') : 'None listed'; + const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${i + 1}.`; + const value = `By <@${row.author_id}> • Tech: ${tech} • 👍 ${row.upvotes}`; + embed.addFields({ + name: `${medal} #${row.id} — ${row.name}`.slice(0, 256), + value: value.slice(0, 1024), + }); + }); + + await safeEditReply(interaction, { embeds: [embed] }); +} + +/** + * Handle /showcase view + * + * @param {import('discord.js').ChatInputCommandInteraction} interaction + * @param {import('pg').Pool} pool + */ +async function handleView(interaction, pool) { + const id = interaction.options.getInteger('id'); + + const { rows } = await pool.query('SELECT * FROM showcases WHERE id = $1 AND guild_id = $2', [ + id, + interaction.guildId, + ]); + + if (rows.length === 0) { + await safeEditReply(interaction, { + content: `❌ No project with ID **#${id}** found in this server.`, + }); + return; + } + + const showcase = rows[0]; + const embed = buildShowcaseEmbed(showcase); + const row = buildUpvoteRow(showcase.id, showcase.upvotes); + + await safeEditReply(interaction, { embeds: [embed], components: [row] }); +} + +// ── Execute ──────────────────────────────────────────────────────── + +/** + * Execute the /showcase command. + * + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +export async function execute(interaction) { + if (!interaction.guildId) { + await safeReply(interaction, { + content: '❌ This command can only be used in a server.', + ephemeral: true, + }); + return; + } + + const guildConfig = getConfig(interaction.guildId); + if (guildConfig.showcase?.enabled === false) { + await safeReply(interaction, { + content: '❌ The showcase feature is disabled in this server.', + ephemeral: true, + }); + return; + } + + const subcommand = interaction.options.getSubcommand(); + + // submit opens a modal — no defer needed + if (subcommand === 'submit') { + await handleSubmit(interaction); + return; + } + + await interaction.deferReply({ ephemeral: false }); + + let pool; + try { + pool = getPool(); + } catch { + return safeEditReply(interaction, { content: '❌ Database is not available.' }); + } + + try { + if (subcommand === 'browse') { + await handleBrowse(interaction, pool); + } else if (subcommand === 'top') { + await handleTop(interaction, pool); + } else if (subcommand === 'view') { + await handleView(interaction, pool); + } + } catch (err) { + warn('Showcase command failed', { error: err.message, stack: err.stack, subcommand }); + await safeEditReply(interaction, { content: '❌ Failed to execute showcase command.' }); + } +} + +/** + * Handle the showcase upvote button interaction. + * + * @param {import('discord.js').ButtonInteraction} interaction + * @param {import('pg').Pool} pool + */ +export async function handleShowcaseUpvote(interaction, pool) { + const showcaseId = parseInt(interaction.customId.replace('showcase_upvote_', ''), 10); + + // Guard against malformed customId + if (Number.isNaN(showcaseId)) { + await safeReply(interaction, { content: '❌ Invalid showcase ID.', ephemeral: true }); + return; + } + + const userId = interaction.user.id; + const guildId = interaction.guildId; + + if (!guildId) { + await safeReply(interaction, { + content: '❌ This can only be used in a server.', + ephemeral: true, + }); + return; + } + + const guildConfig = getConfig(guildId); + if (guildConfig.showcase?.enabled === false) { + await safeReply(interaction, { + content: '❌ The showcase feature is disabled in this server.', + ephemeral: true, + }); + return; + } + + // Fetch the showcase (outside transaction — read-only pre-check) + const { rows: showcaseRows } = await pool.query( + 'SELECT * FROM showcases WHERE id = $1 AND guild_id = $2', + [showcaseId, guildId], + ); + + if (showcaseRows.length === 0) { + await safeReply(interaction, { content: '❌ This project no longer exists.', ephemeral: true }); + return; + } + + const showcase = showcaseRows[0]; + + // Prevent self-upvote + if (showcase.author_id === userId) { + await safeReply(interaction, { + content: "❌ You can't upvote your own project.", + ephemeral: true, + }); + return; + } + + // Atomically toggle vote using a transaction + let newUpvotes; + let removed; + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + const { rows: voteRows } = await client.query( + 'SELECT 1 FROM showcase_votes WHERE guild_id = $1 AND showcase_id = $2 AND user_id = $3', + [guildId, showcaseId, userId], + ); + + if (voteRows.length > 0) { + // Toggle off — remove vote + await client.query( + 'DELETE FROM showcase_votes WHERE guild_id = $1 AND showcase_id = $2 AND user_id = $3', + [guildId, showcaseId, userId], + ); + const { rows: updated } = await client.query( + 'UPDATE showcases SET upvotes = upvotes - 1 WHERE id = $1 RETURNING upvotes', + [showcaseId], + ); + newUpvotes = updated[0].upvotes; + removed = true; + } else { + // Add vote + await client.query( + 'INSERT INTO showcase_votes (guild_id, showcase_id, user_id) VALUES ($1, $2, $3)', + [guildId, showcaseId, userId], + ); + const { rows: updated } = await client.query( + 'UPDATE showcases SET upvotes = upvotes + 1 WHERE id = $1 RETURNING upvotes', + [showcaseId], + ); + newUpvotes = updated[0].upvotes; + removed = false; + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + + if (removed) { + info('Showcase upvote removed', { showcaseId, userId, guildId, newUpvotes }); + await safeReply(interaction, { + content: `👎 Removed your upvote from **${showcase.name}**.`, + ephemeral: true, + }); + } else { + info('Showcase upvoted', { showcaseId, userId, guildId, newUpvotes }); + await safeReply(interaction, { content: `👍 Upvoted **${showcase.name}**!`, ephemeral: true }); + } + + // Update the button on the message + try { + const updatedRow = buildUpvoteRow(showcaseId, newUpvotes); + await interaction.message.edit({ components: [updatedRow] }); + } catch { + // Non-critical — ignore edit failures + } +} diff --git a/src/modules/events.js b/src/modules/events.js index ecd4bc476..0c7cb8d5f 100644 --- a/src/modules/events.js +++ b/src/modules/events.js @@ -4,12 +4,13 @@ */ import { Client, Events } from 'discord.js'; +import { handleShowcaseModalSubmit, handleShowcaseUpvote } from '../commands/showcase.js'; import { info, error as logError, warn } from '../logger.js'; import { getUserFriendlyMessage } from '../utils/errors.js'; // safeReply works with both Interactions (.reply()) and Messages (.reply()). // Both accept the same options shape including allowedMentions, so the // safe wrapper applies identically to either target type. -import { safeReply } from '../utils/safeSend.js'; +import { safeEditReply, safeReply } from '../utils/safeSend.js'; import { handleAfkMentions } from './afkHandler.js'; import { getConfig } from './config.js'; import { trackMessage, trackReaction } from './engagement.js'; @@ -351,6 +352,91 @@ export function registerPollButtonHandler(client) { }); } +/** + * Register an interactionCreate handler for showcase upvote buttons. + * Listens for button clicks with customId matching `showcase_upvote_`. + * + * @param {Client} client - Discord client instance + */ +export function registerShowcaseButtonHandler(client) { + client.on(Events.InteractionCreate, async (interaction) => { + if (!interaction.isButton()) return; + if (!interaction.customId.startsWith('showcase_upvote_')) return; + + let pool; + try { + pool = (await import('../db.js')).getPool(); + } catch { + try { + await safeReply(interaction, { + content: '❌ Database is not available.', + ephemeral: true, + }); + } catch { + // Ignore + } + return; + } + + try { + await handleShowcaseUpvote(interaction, pool); + } catch (err) { + logError('Showcase upvote handler failed', { + customId: interaction.customId, + userId: interaction.user?.id, + error: err.message, + }); + + if (!interaction.replied && !interaction.deferred) { + try { + await safeReply(interaction, { + content: '❌ Something went wrong processing your upvote.', + ephemeral: true, + }); + } catch { + // Ignore — we tried + } + } + } + }); +} + +/** + * Register an interactionCreate handler for showcase modal submissions. + * Listens for modal submits with customId `showcase_submit_modal`. + * + * @param {Client} client - Discord client instance + */ +export function registerShowcaseModalHandler(client) { + client.on(Events.InteractionCreate, async (interaction) => { + if (!interaction.isModalSubmit()) return; + if (interaction.customId !== 'showcase_submit_modal') return; + + let pool; + try { + pool = (await import('../db.js')).getPool(); + } catch { + try { + await safeReply(interaction, { + content: '❌ Database is not available.', + ephemeral: true, + }); + } catch { + // Ignore + } + return; + } + + try { + await handleShowcaseModalSubmit(interaction, pool); + } catch (err) { + logError('Showcase modal error', { error: err.message }); + const reply = interaction.deferred ? safeEditReply : safeReply; + await reply(interaction, { content: '❌ Something went wrong.' }); + } + }); +} + /** * Register error event handlers * @param {Client} client - Discord client @@ -380,5 +466,7 @@ export function registerEventHandlers(client, config, healthMonitor) { registerMessageCreateHandler(client, config, healthMonitor); registerReactionHandlers(client, config); registerPollButtonHandler(client); + registerShowcaseButtonHandler(client); + registerShowcaseModalHandler(client); registerErrorHandlers(client); } diff --git a/tests/commands/showcase.test.js b/tests/commands/showcase.test.js new file mode 100644 index 000000000..593dae4e1 --- /dev/null +++ b/tests/commands/showcase.test.js @@ -0,0 +1,885 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock dependencies +vi.mock('../../src/db.js', () => ({ + getPool: vi.fn(), +})); + +vi.mock('../../src/logger.js', () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); + +vi.mock('../../src/modules/config.js', () => ({ + getConfig: vi.fn().mockReturnValue({ + showcase: { enabled: true }, + permissions: { enabled: true, adminRoleId: null, usePermissions: true }, + }), +})); + +vi.mock('../../src/utils/safeSend.js', () => ({ + safeSend: (channel, opts) => channel.send(opts), + safeReply: (t, opts) => t.reply(opts), + safeEditReply: (t, opts) => t.editReply(opts), +})); + +vi.mock('discord.js', () => { + function chainable() { + const proxy = new Proxy(() => proxy, { + get: () => () => proxy, + apply: () => proxy, + }); + return proxy; + } + + class MockSlashCommandBuilder { + constructor() { + this.name = ''; + this.description = ''; + } + setName(name) { + this.name = name; + return this; + } + setDescription(desc) { + this.description = desc; + return this; + } + addSubcommand(fn) { + const sub = { + setName: () => ({ + setDescription: () => ({ + addStringOption: function self(fn2) { + fn2(chainable()); + return { + addStringOption: self, + addIntegerOption: self, + addBooleanOption: self, + }; + }, + addIntegerOption: function self(fn2) { + fn2(chainable()); + return { + addStringOption: self, + addIntegerOption: self, + addBooleanOption: self, + }; + }, + addBooleanOption: function self(fn2) { + fn2(chainable()); + return { + addStringOption: self, + addIntegerOption: self, + addBooleanOption: self, + }; + }, + }), + }), + }; + fn(sub); + return this; + } + toJSON() { + return { name: this.name, description: this.description }; + } + } + + class MockEmbedBuilder { + constructor() { + this.data = { fields: [] }; + } + setTitle(t) { + this.data.title = t; + return this; + } + setDescription(d) { + this.data.description = d; + return this; + } + setColor(c) { + this.data.color = c; + return this; + } + setFooter(f) { + this.data.footer = f; + return this; + } + addFields(...fields) { + const flat = fields.flat(); + this.data.fields.push(...flat); + return this; + } + } + + class MockButtonBuilder { + constructor() { + this.data = {}; + } + setCustomId(id) { + this.data.customId = id; + return this; + } + setLabel(l) { + this.data.label = l; + return this; + } + setStyle(s) { + this.data.style = s; + return this; + } + setDisabled(d) { + this.data.disabled = d; + return this; + } + } + + class MockActionRowBuilder { + constructor() { + this.components = []; + } + addComponents(...items) { + this.components.push(...items.flat()); + return this; + } + } + + class MockModalBuilder { + constructor() { + this.data = { components: [] }; + } + setCustomId(id) { + this.data.customId = id; + return this; + } + setTitle(t) { + this.data.title = t; + return this; + } + addComponents(...rows) { + this.data.components.push(...rows.flat()); + return this; + } + } + + class MockTextInputBuilder { + constructor() { + this.data = {}; + } + setCustomId(id) { + this.data.customId = id; + return this; + } + setLabel(l) { + this.data.label = l; + return this; + } + setStyle(s) { + this.data.style = s; + return this; + } + setRequired(r) { + this.data.required = r; + return this; + } + setMaxLength(m) { + this.data.maxLength = m; + return this; + } + setPlaceholder(p) { + this.data.placeholder = p; + return this; + } + } + + return { + SlashCommandBuilder: MockSlashCommandBuilder, + EmbedBuilder: MockEmbedBuilder, + ButtonBuilder: MockButtonBuilder, + ActionRowBuilder: MockActionRowBuilder, + ModalBuilder: MockModalBuilder, + TextInputBuilder: MockTextInputBuilder, + ButtonStyle: { Primary: 1, Secondary: 2, Danger: 4 }, + TextInputStyle: { Short: 1, Paragraph: 2 }, + }; +}); + +import { + buildShowcaseEmbed, + buildUpvoteRow, + data, + execute, + handleShowcaseModalSubmit, + handleShowcaseUpvote, +} from '../../src/commands/showcase.js'; +import { getPool } from '../../src/db.js'; +import { getConfig } from '../../src/modules/config.js'; + +/** Create a mock slash command interaction. */ +function createMockInteraction(subcommand, options = {}) { + const optionValues = { + tag: null, + page: null, + id: null, + ...options, + }; + + return { + guildId: 'guild-123', + channelId: 'ch-456', + user: { id: 'user-789', tag: 'TestUser#0001' }, + member: { id: 'user-789' }, + channel: { + send: vi.fn().mockResolvedValue({ id: 'msg-001' }), + }, + options: { + getSubcommand: vi.fn().mockReturnValue(subcommand), + getString: vi.fn((name) => optionValues[name] ?? null), + getInteger: vi.fn((name) => optionValues[name] ?? null), + getBoolean: vi.fn((name) => optionValues[name] ?? null), + }, + reply: vi.fn(), + editReply: vi.fn(), + deferReply: vi.fn(), + showModal: vi.fn(), + }; +} + +/** Create a mock modal submit interaction. */ +function createMockModalInteraction(fields = {}) { + const defaults = { + showcase_name: 'My Awesome Project', + showcase_description: 'A cool project I built.', + showcase_tech: 'node, react, postgres', + showcase_repo: 'https://github.com/user/repo', + showcase_live: 'https://myproject.dev', + ...fields, + }; + + return { + guildId: 'guild-123', + channelId: 'ch-456', + user: { id: 'user-789', tag: 'TestUser#0001' }, + customId: 'showcase_submit_modal', + fields: { + getTextInputValue: vi.fn((key) => defaults[key] ?? ''), + }, + channel: { + send: vi.fn().mockResolvedValue({ id: 'msg-002' }), + }, + reply: vi.fn(), + editReply: vi.fn(), + deferReply: vi.fn(), + }; +} + +/** Create a mock button interaction. */ +function createMockButtonInteraction(customId, userId = 'voter-001') { + return { + customId, + guildId: 'guild-123', + user: { id: userId }, + message: { edit: vi.fn() }, + reply: vi.fn(), + replied: false, + deferred: false, + }; +} + +// ── Core export tests ────────────────────────────────────────────── + +describe('showcase command exports', () => { + it('should export data with name "showcase"', () => { + expect(data.name).toBe('showcase'); + }); + + it('should export buildShowcaseEmbed as a function', () => { + expect(typeof buildShowcaseEmbed).toBe('function'); + }); + + it('should export buildUpvoteRow as a function', () => { + expect(typeof buildUpvoteRow).toBe('function'); + }); +}); + +// ── buildShowcaseEmbed ───────────────────────────────────────────── + +describe('buildShowcaseEmbed', () => { + const baseShowcase = { + id: 1, + name: 'Cool Project', + description: 'A very cool project.', + tech_stack: ['node', 'react'], + repo_url: 'https://github.com/user/cool', + live_url: 'https://cool.dev', + author_id: 'user-123', + upvotes: 5, + created_at: new Date('2025-01-01T00:00:00Z'), + }; + + it('should set title to project name', () => { + const embed = buildShowcaseEmbed(baseShowcase); + expect(embed.data.title).toBe('Cool Project'); + }); + + it('should set description to project description', () => { + const embed = buildShowcaseEmbed(baseShowcase); + expect(embed.data.description).toBe('A very cool project.'); + }); + + it('should include tech stack field', () => { + const embed = buildShowcaseEmbed(baseShowcase); + const techField = embed.data.fields.find((f) => f.name === 'Tech Stack'); + expect(techField).toBeDefined(); + expect(techField.value).toContain('node'); + expect(techField.value).toContain('react'); + }); + + it('should include repo URL field when provided', () => { + const embed = buildShowcaseEmbed(baseShowcase); + const repoField = embed.data.fields.find((f) => f.name === 'Repo URL'); + expect(repoField).toBeDefined(); + expect(repoField.value).toBe('https://github.com/user/cool'); + }); + + it('should include live URL field when provided', () => { + const embed = buildShowcaseEmbed(baseShowcase); + const liveField = embed.data.fields.find((f) => f.name === 'Live URL'); + expect(liveField).toBeDefined(); + }); + + it('should include author and upvotes fields', () => { + const embed = buildShowcaseEmbed(baseShowcase); + const authorField = embed.data.fields.find((f) => f.name === 'Author'); + const upvotesField = embed.data.fields.find((f) => f.name === 'Upvotes'); + expect(authorField).toBeDefined(); + expect(upvotesField).toBeDefined(); + expect(upvotesField.value).toBe('5'); + }); + + it('should include ID in footer', () => { + const embed = buildShowcaseEmbed(baseShowcase); + expect(embed.data.footer.text).toContain('ID: 1'); + }); + + it('should skip tech stack field when empty', () => { + const embed = buildShowcaseEmbed({ ...baseShowcase, tech_stack: [] }); + const techField = embed.data.fields.find((f) => f.name === 'Tech Stack'); + expect(techField).toBeUndefined(); + }); + + it('should skip repo URL field when null', () => { + const embed = buildShowcaseEmbed({ ...baseShowcase, repo_url: null }); + const repoField = embed.data.fields.find((f) => f.name === 'Repo URL'); + expect(repoField).toBeUndefined(); + }); + + it('should skip live URL field when null', () => { + const embed = buildShowcaseEmbed({ ...baseShowcase, live_url: null }); + const liveField = embed.data.fields.find((f) => f.name === 'Live URL'); + expect(liveField).toBeUndefined(); + }); +}); + +// ── buildUpvoteRow ───────────────────────────────────────────────── + +describe('buildUpvoteRow', () => { + it('should create a button with correct customId', () => { + const row = buildUpvoteRow(42, 7); + expect(row.components).toHaveLength(1); + expect(row.components[0].data.customId).toBe('showcase_upvote_42'); + }); + + it('should show upvote count in label', () => { + const row = buildUpvoteRow(1, 13); + expect(row.components[0].data.label).toBe('👍 13'); + }); +}); + +// ── /showcase execute ────────────────────────────────────────────── + +describe('showcase execute', () => { + let mockPool; + + beforeEach(() => { + mockPool = { query: vi.fn().mockResolvedValue({ rows: [] }) }; + getPool.mockReturnValue(mockPool); + getConfig.mockReturnValue({ showcase: { enabled: true } }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should reject when no guild', async () => { + const interaction = createMockInteraction('browse'); + interaction.guildId = null; + await execute(interaction); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('server') }), + ); + }); + + it('should reject when showcase is disabled', async () => { + getConfig.mockReturnValueOnce({ showcase: { enabled: false } }); + const interaction = createMockInteraction('browse'); + await execute(interaction); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('disabled') }), + ); + }); + + it('should return error when pool unavailable for browse', async () => { + getPool.mockImplementationOnce(() => { + throw new Error('Database not initialized'); + }); + const interaction = createMockInteraction('browse'); + await execute(interaction); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('Database is not available') }), + ); + }); +}); + +// ── /showcase submit ─────────────────────────────────────────────── + +describe('showcase submit subcommand', () => { + afterEach(() => vi.clearAllMocks()); + + it('should show a modal when submit is called', async () => { + getConfig.mockReturnValue({ showcase: { enabled: true } }); + const interaction = createMockInteraction('submit'); + await execute(interaction); + expect(interaction.showModal).toHaveBeenCalledOnce(); + }); +}); + +// ── handleShowcaseModalSubmit ────────────────────────────────────── + +describe('handleShowcaseModalSubmit', () => { + let mockPool; + + beforeEach(() => { + mockPool = { query: vi.fn().mockResolvedValue({ rows: [] }) }; + getConfig.mockReturnValue({ showcase: { enabled: true } }); + }); + + afterEach(() => vi.clearAllMocks()); + + it('should reject when no guild', async () => { + const interaction = createMockModalInteraction(); + interaction.guildId = null; + await handleShowcaseModalSubmit(interaction, mockPool); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('server') }), + ); + }); + + it('should reject when showcase is disabled', async () => { + getConfig.mockReturnValueOnce({ showcase: { enabled: false } }); + const interaction = createMockModalInteraction(); + await handleShowcaseModalSubmit(interaction, mockPool); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('disabled') }), + ); + }); + + it('should save project and send embed to channel', async () => { + const showcase = { + id: 1, + guild_id: 'guild-123', + author_id: 'user-789', + name: 'My Awesome Project', + description: 'A cool project I built.', + tech_stack: ['node', 'react', 'postgres'], + repo_url: 'https://github.com/user/repo', + live_url: 'https://myproject.dev', + upvotes: 0, + created_at: new Date(), + }; + + mockPool.query + .mockResolvedValueOnce({ rows: [showcase] }) // INSERT + .mockResolvedValueOnce({ rows: [] }); // UPDATE message_id + + const interaction = createMockModalInteraction(); + await handleShowcaseModalSubmit(interaction, mockPool); + + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('INSERT INTO showcases'), + expect.arrayContaining(['guild-123', 'user-789', 'My Awesome Project']), + ); + expect(interaction.channel.send).toHaveBeenCalledWith( + expect.objectContaining({ + embeds: expect.any(Array), + components: expect.any(Array), + }), + ); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('My Awesome Project'), + }), + ); + }); + + it('should handle empty optional fields', async () => { + const showcase = { + id: 2, + guild_id: 'guild-123', + author_id: 'user-789', + name: 'Minimal Project', + description: 'Just a description.', + tech_stack: [], + repo_url: null, + live_url: null, + upvotes: 0, + created_at: new Date(), + }; + + mockPool.query.mockResolvedValueOnce({ rows: [showcase] }).mockResolvedValueOnce({ rows: [] }); + + const interaction = createMockModalInteraction({ + showcase_name: 'Minimal Project', + showcase_description: 'Just a description.', + showcase_tech: '', + showcase_repo: '', + showcase_live: '', + }); + + await handleShowcaseModalSubmit(interaction, mockPool); + + // Should call INSERT with null for optional fields + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('INSERT INTO showcases'), + expect.arrayContaining([null, null]), + ); + }); +}); + +// ── /showcase browse ─────────────────────────────────────────────── + +describe('showcase browse subcommand', () => { + let mockPool; + + beforeEach(() => { + mockPool = { query: vi.fn().mockResolvedValue({ rows: [] }) }; + getPool.mockReturnValue(mockPool); + getConfig.mockReturnValue({ showcase: { enabled: true } }); + }); + + afterEach(() => vi.clearAllMocks()); + + it('should show empty message when no projects', async () => { + mockPool.query.mockResolvedValueOnce({ rows: [{ total: 0 }] }); + const interaction = createMockInteraction('browse'); + await execute(interaction); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('No projects') }), + ); + }); + + it('should list projects', async () => { + const project = { + id: 1, + name: 'Test Project', + author_id: 'user-789', + tech_stack: ['node'], + upvotes: 3, + created_at: new Date(), + }; + mockPool.query + .mockResolvedValueOnce({ rows: [{ total: 1 }] }) + .mockResolvedValueOnce({ rows: [project] }); + + const interaction = createMockInteraction('browse'); + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ embeds: expect.any(Array) }), + ); + }); + + it('should filter by tag when provided', async () => { + const project = { + id: 2, + name: 'React App', + author_id: 'user-789', + tech_stack: ['react'], + upvotes: 1, + created_at: new Date(), + }; + mockPool.query + .mockResolvedValueOnce({ rows: [{ total: 1 }] }) + .mockResolvedValueOnce({ rows: [project] }); + + const interaction = createMockInteraction('browse', { tag: 'react' }); + await execute(interaction); + + // First query should include the tag filter + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('ANY(tech_stack)'), + expect.arrayContaining(['guild-123', 'react']), + ); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ embeds: expect.any(Array) }), + ); + }); + + it('should show empty message when no projects match tag', async () => { + mockPool.query.mockResolvedValueOnce({ rows: [{ total: 0 }] }); + const interaction = createMockInteraction('browse', { tag: 'cobol' }); + await execute(interaction); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('cobol') }), + ); + }); +}); + +// ── /showcase top ────────────────────────────────────────────────── + +describe('showcase top subcommand', () => { + let mockPool; + + beforeEach(() => { + mockPool = { query: vi.fn().mockResolvedValue({ rows: [] }) }; + getPool.mockReturnValue(mockPool); + getConfig.mockReturnValue({ showcase: { enabled: true } }); + }); + + afterEach(() => vi.clearAllMocks()); + + it('should show empty message when no projects', async () => { + mockPool.query.mockResolvedValueOnce({ rows: [] }); + const interaction = createMockInteraction('top'); + await execute(interaction); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('No projects') }), + ); + }); + + it('should show top 10 projects sorted by upvotes', async () => { + const projects = Array.from({ length: 10 }, (_, i) => ({ + id: i + 1, + name: `Project ${i + 1}`, + author_id: 'user-789', + tech_stack: ['node'], + upvotes: 10 - i, + })); + mockPool.query.mockResolvedValueOnce({ rows: projects }); + + const interaction = createMockInteraction('top'); + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ embeds: expect.any(Array) }), + ); + // Should query ORDER BY upvotes DESC + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('ORDER BY upvotes DESC'), + expect.any(Array), + ); + }); +}); + +// ── /showcase view ───────────────────────────────────────────────── + +describe('showcase view subcommand', () => { + let mockPool; + + beforeEach(() => { + mockPool = { query: vi.fn().mockResolvedValue({ rows: [] }) }; + getPool.mockReturnValue(mockPool); + getConfig.mockReturnValue({ showcase: { enabled: true } }); + }); + + afterEach(() => vi.clearAllMocks()); + + it('should show not found for missing project', async () => { + mockPool.query.mockResolvedValueOnce({ rows: [] }); + const interaction = createMockInteraction('view', { id: 999 }); + await execute(interaction); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('#999') }), + ); + }); + + it('should show project details', async () => { + const showcase = { + id: 5, + guild_id: 'guild-123', + author_id: 'user-789', + name: 'My Project', + description: 'Description here.', + tech_stack: ['rust'], + repo_url: null, + live_url: null, + upvotes: 2, + created_at: new Date(), + }; + mockPool.query.mockResolvedValueOnce({ rows: [showcase] }); + + const interaction = createMockInteraction('view', { id: 5 }); + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ + embeds: expect.any(Array), + components: expect.any(Array), + }), + ); + }); +}); + +// ── handleShowcaseUpvote ─────────────────────────────────────────── + +describe('handleShowcaseUpvote', () => { + let mockPool; + let mockClient; + + /** Build a pool mock with a transactional client. */ + function makePool(poolQueryResponses = [], clientQueryResponses = []) { + mockClient = { + query: vi.fn(), + release: vi.fn(), + }; + + // Wire up client query responses + let clientIdx = 0; + mockClient.query.mockImplementation(async (sql) => { + // BEGIN / COMMIT / ROLLBACK always succeed + if (/^(BEGIN|COMMIT|ROLLBACK)$/i.test(sql)) return { rows: [] }; + const res = clientQueryResponses[clientIdx++]; + return res ?? { rows: [] }; + }); + + const pool = { query: vi.fn(), connect: vi.fn().mockResolvedValue(mockClient) }; + let poolIdx = 0; + pool.query.mockImplementation(async () => { + return poolQueryResponses[poolIdx++] ?? { rows: [] }; + }); + return pool; + } + + afterEach(() => vi.clearAllMocks()); + + it('should reject upvote on own project', async () => { + const showcase = { + id: 1, + guild_id: 'guild-123', + author_id: 'voter-001', // same as voter + name: 'My Project', + upvotes: 3, + }; + mockPool = makePool([{ rows: [showcase] }]); + + const interaction = createMockButtonInteraction('showcase_upvote_1', 'voter-001'); + await handleShowcaseUpvote(interaction, mockPool); + + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining("can't upvote your own") }), + ); + }); + + it('should reject when project does not exist', async () => { + mockPool = makePool([{ rows: [] }]); + + const interaction = createMockButtonInteraction('showcase_upvote_999', 'voter-001'); + await handleShowcaseUpvote(interaction, mockPool); + + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('no longer exists') }), + ); + }); + + it('should add upvote when not yet voted', async () => { + const showcase = { + id: 2, + guild_id: 'guild-123', + author_id: 'author-123', + name: 'Awesome App', + upvotes: 4, + }; + + // pool.query: SELECT showcase + // client.query (non-BEGIN/COMMIT): SELECT vote (none), INSERT vote, UPDATE upvotes + mockPool = makePool( + [{ rows: [showcase] }], + [{ rows: [] }, { rows: [] }, { rows: [{ upvotes: 5 }] }], + ); + + const interaction = createMockButtonInteraction('showcase_upvote_2', 'voter-001'); + await handleShowcaseUpvote(interaction, mockPool); + + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('Upvoted') }), + ); + // Should update message button + expect(interaction.message.edit).toHaveBeenCalledWith( + expect.objectContaining({ components: expect.any(Array) }), + ); + }); + + it('should toggle off upvote when already voted', async () => { + const showcase = { + id: 3, + guild_id: 'guild-123', + author_id: 'author-123', + name: 'Another App', + upvotes: 6, + }; + + // pool.query: SELECT showcase + // client.query (non-BEGIN/COMMIT): SELECT vote (exists), DELETE vote, UPDATE upvotes + mockPool = makePool( + [{ rows: [showcase] }], + [{ rows: [{ 1: 1 }] }, { rows: [] }, { rows: [{ upvotes: 5 }] }], + ); + + const interaction = createMockButtonInteraction('showcase_upvote_3', 'voter-001'); + await handleShowcaseUpvote(interaction, mockPool); + + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('Removed your upvote') }), + ); + // Should call DELETE inside the transaction client + expect(mockClient.query).toHaveBeenCalledWith( + expect.stringContaining('DELETE FROM showcase_votes'), + expect.any(Array), + ); + }); + + it('should update upvotes count in showcases table', async () => { + const showcase = { + id: 4, + guild_id: 'guild-123', + author_id: 'author-123', + name: 'Count App', + upvotes: 0, + }; + + // pool.query: SELECT showcase + // client.query (non-BEGIN/COMMIT): SELECT vote (none), INSERT vote, UPDATE upvotes + mockPool = makePool( + [{ rows: [showcase] }], + [{ rows: [] }, { rows: [] }, { rows: [{ upvotes: 1 }] }], + ); + + const interaction = createMockButtonInteraction('showcase_upvote_4', 'voter-002'); + await handleShowcaseUpvote(interaction, mockPool); + + expect(mockClient.query).toHaveBeenCalledWith( + expect.stringContaining('UPDATE showcases SET upvotes = upvotes + 1'), + expect.arrayContaining([4]), + ); + }); + + it('should reject when no guild', async () => { + mockPool = makePool(); + const interaction = createMockButtonInteraction('showcase_upvote_1', 'voter-001'); + interaction.guildId = null; + await handleShowcaseUpvote(interaction, mockPool); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('server') }), + ); + }); +}); diff --git a/web/src/components/dashboard/config-editor.tsx b/web/src/components/dashboard/config-editor.tsx index 135f53483..243cd61ef 100644 --- a/web/src/components/dashboard/config-editor.tsx +++ b/web/src/components/dashboard/config-editor.tsx @@ -48,7 +48,7 @@ function parseNumberInput(raw: string, min?: number, max?: number): number | und function isGuildConfig(data: unknown): data is GuildConfig { if (typeof data !== "object" || data === null || Array.isArray(data)) return false; const obj = data as Record; - const knownSections = ["ai", "welcome", "spam", "moderation", "triage", "starboard", "permissions", "memory", "help", "announce", "snippet", "poll", "tldr", "reputation", "afk", "engagement", "github"] as const; + const knownSections = ["ai", "welcome", "spam", "moderation", "triage", "starboard", "permissions", "memory", "help", "announce", "snippet", "poll", "showcase", "tldr", "reputation", "afk", "engagement", "github"] as const; const hasKnownSection = knownSections.some((key) => key in obj); if (!hasKnownSection) return false; for (const key of knownSections) { @@ -1200,6 +1200,7 @@ export function ConfigEditor() { { key: "announce", label: "Announcements", desc: "/announce for scheduled messages" }, { key: "snippet", label: "Code Snippets", desc: "/snippet for saving and sharing code" }, { key: "poll", label: "Polls", desc: "/poll for community voting" }, + { key: "showcase", label: "Project Showcase", desc: "/showcase to submit, browse, and upvote projects" }, { key: "tldr", label: "TL;DR Summaries", desc: "/tldr for AI channel summaries" }, { key: "afk", label: "AFK System", desc: "/afk auto-respond when members are away" }, { key: "engagement", label: "Engagement Tracking", desc: "/profile stats — messages, reactions, days active" },