From 0d37f35d49532a4f61282d701c80ef8d81fe0eb8 Mon Sep 17 00:00:00 2001 From: Musiker15 Date: Sat, 20 Jun 2026 18:06:02 +0200 Subject: [PATCH] feat(bot): Accept/Reject buttons + role grant on accept MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the Discord review loop (concept §5.1 step 8 / §11). The new-submission embed now carries Accept/Reject buttons; staff act without leaving Discord. - review embed gains Accept/Reject buttons (customId sub::) alongside the existing dashboard link. - review-actions.ts handles the button: Manage Server check, then mirrors the web events route — updates status (accepted/rejected), writes a public status_change event, and queues a status_change DM to the applicant via the outbox (reusing 6b). - on accept, grants the guild's configured acceptedRole to the applicant (members.fetch by their Discord id → roles.add); best-effort, logs and continues on missing-permission/hierarchy errors. - the message is edited to show the decision and disable the buttons. No new gateway intent needed (single-member fetch is REST). --- apps/bot/src/index.ts | 5 ++ apps/bot/src/notifications.ts | 8 ++ apps/bot/src/review-actions.ts | 154 +++++++++++++++++++++++++++++++++ 3 files changed, 167 insertions(+) create mode 100644 apps/bot/src/review-actions.ts diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 18ef3e8..8705478 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -5,6 +5,7 @@ import { assertConfig, config } from "./config.js"; import { handleFormsAutocomplete, handleFormsCommand } from "./forms.js"; import { syncAllGuilds, syncGuild } from "./guilds.js"; import { deliverPendingNotifications } from "./notifications.js"; +import { handleReviewButton, isReviewButton } from "./review-actions.js"; /** * MSK Forms Discord bot — multi-tenant (concept §11). @@ -39,6 +40,10 @@ export function createClient(): Client { await handleFormsAutocomplete(interaction); return; } + if (interaction.isButton() && isReviewButton(interaction.customId)) { + await handleReviewButton(interaction); + return; + } if (interaction.isChatInputCommand() && interaction.commandName === "forms") { await handleFormsCommand(interaction); } diff --git a/apps/bot/src/notifications.ts b/apps/bot/src/notifications.ts index c988eeb..2dbb532 100644 --- a/apps/bot/src/notifications.ts +++ b/apps/bot/src/notifications.ts @@ -105,6 +105,14 @@ async function deliverReview(client: Client, row: PendingRow): Promise .setFooter({ text: "MSK Forms" }); const buttons = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`sub:accept:${payload.submissionId}`) + .setStyle(ButtonStyle.Success) + .setLabel("Accept"), + new ButtonBuilder() + .setCustomId(`sub:reject:${payload.submissionId}`) + .setStyle(ButtonStyle.Danger) + .setLabel("Reject"), new ButtonBuilder().setStyle(ButtonStyle.Link).setLabel("Open in dashboard").setURL(url), ); diff --git a/apps/bot/src/review-actions.ts b/apps/bot/src/review-actions.ts new file mode 100644 index 0000000..cbdd309 --- /dev/null +++ b/apps/bot/src/review-actions.ts @@ -0,0 +1,154 @@ +import { Prisma, prisma } from "@msk-forms/db"; +import { + DEFAULT_STATUSES, + parseBotConfig, + type StatusChangeNotification, +} from "@msk-forms/shared"; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + DiscordAPIError, + EmbedBuilder, + MessageFlags, + PermissionFlagsBits, + type ButtonInteraction, +} from "discord.js"; + +import { config } from "./config.js"; +import { dashboardSubmissionUrl } from "./urls.js"; + +const ACTION_STATUS = { accept: "accepted", reject: "rejected" } as const; +type Action = keyof typeof ACTION_STATUS; + +const statusLabel = (key: string) => + DEFAULT_STATUSES.find((s) => s.key === key)?.label ?? key; + +/** True if this button belongs to the review workflow. */ +export function isReviewButton(customId: string): boolean { + return customId.startsWith("sub:"); +} + +/** + * Handle an Accept/Reject button on a review embed: change the submission's + * status, DM the applicant (via the outbox), grant the accepted role, and + * update the message. Mirrors the web events route's status-change logic. + */ +export async function handleReviewButton(interaction: ButtonInteraction): Promise { + const [, rawAction, submissionId] = interaction.customId.split(":"); + if ((rawAction !== "accept" && rawAction !== "reject") || !submissionId) return; + const action = rawAction as Action; + + if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { + await interaction.reply({ + content: "You need the **Manage Server** permission to review submissions.", + flags: MessageFlags.Ephemeral, + }); + return; + } + + const submission = await prisma.submission.findUnique({ + where: { id: submissionId }, + select: { + status: true, + userId: true, + guildId: true, + form: { select: { title: true } }, + guild: { select: { discordGuildId: true, botConfig: true } }, + }, + }); + if (!submission || submission.guild.discordGuildId !== interaction.guildId) { + await interaction.reply({ content: "Submission not found.", flags: MessageFlags.Ephemeral }); + return; + } + + const toStatus = ACTION_STATUS[action]; + + if (submission.status !== toStatus) { + const ops: Prisma.PrismaPromise[] = [ + prisma.submission.update({ where: { id: submissionId }, data: { status: toStatus } }), + prisma.submissionEvent.create({ + data: { + submissionId, + type: "status_change", + fromStatus: submission.status, + toStatus, + visibility: "public", + }, + }), + ]; + if (submission.userId) { + const payload: StatusChangeNotification = { + submissionId, + formTitle: submission.form.title, + toStatus, + toStatusLabel: statusLabel(toStatus), + }; + ops.push( + prisma.notification.create({ + data: { + userId: submission.userId, + type: "status_change", + payload: payload as unknown as Prisma.InputJsonValue, + }, + }), + ); + } + await prisma.$transaction(ops); + + if (action === "accept" && submission.userId) { + const roleId = parseBotConfig(submission.guild.botConfig).acceptedRoleId; + if (roleId) await grantRole(interaction, submission.userId, roleId); + } + } + + await updateMessage(interaction, submission.guildId, submissionId, toStatus); +} + +/** Grant the accepted role to the applicant. Best-effort — never throws. */ +async function grantRole( + interaction: ButtonInteraction, + applicantUserId: string, + roleId: string, +): Promise { + try { + const user = await prisma.user.findUnique({ + where: { id: applicantUserId }, + select: { discordId: true }, + }); + if (!user?.discordId || !interaction.guild) return; + const member = await interaction.guild.members.fetch(user.discordId); + await member.roles.add(roleId); + } catch (err) { + const code = err instanceof DiscordAPIError ? ` (${err.code})` : ""; + console.error(`[bot] could not grant role ${roleId}${code}:`, (err as Error).message); + } +} + +/** Edit the review message: show the decision and disable the action buttons. */ +async function updateMessage( + interaction: ButtonInteraction, + guildId: string, + submissionId: string, + toStatus: string, +): Promise { + const accepted = toStatus === "accepted"; + const base = interaction.message.embeds[0]; + const embed = (base ? EmbedBuilder.from(base) : new EmbedBuilder()) + .setColor(accepted ? 0x00e676 : 0xff5252) + .addFields({ name: "Decision", value: accepted ? "✅ Accepted" : "❌ Rejected" }); + + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId("sub:decided") + .setStyle(accepted ? ButtonStyle.Success : ButtonStyle.Danger) + .setLabel(accepted ? "Accepted" : "Rejected") + .setDisabled(true), + new ButtonBuilder() + .setStyle(ButtonStyle.Link) + .setLabel("Open in dashboard") + .setURL(dashboardSubmissionUrl(config.apiBaseUrl, guildId, submissionId)), + ); + + await interaction.update({ embeds: [embed], components: [row] }); +}