From e217c67a4bccd313c382a84ef194977fa0e76c50 Mon Sep 17 00:00:00 2001 From: PadBro Date: Fri, 25 Jul 2025 11:53:03 +0200 Subject: [PATCH 1/5] feat(application): add history --- commands/utils/apply/requests.js | 18 ++++---- commands/utils/paginate.js | 15 +++++-- events/application.js | 73 ++++++++++++++++++++++++++++++++ utils/loadMessage.js | 21 +++++++++ 4 files changed, 113 insertions(+), 14 deletions(-) create mode 100644 utils/loadMessage.js diff --git a/commands/utils/apply/requests.js b/commands/utils/apply/requests.js index 7dc3da0..d31da92 100644 --- a/commands/utils/apply/requests.js +++ b/commands/utils/apply/requests.js @@ -138,17 +138,15 @@ export const denyApplicationSubmission = async ( } }; -export const getAllApplicationSubmissions = async ( - applicationId, - discordId +export const getApplicationSubmissionHistory = async ( + applicationSubmissionid ) => { - const response = await apiFetch('/application-submission', { - method: 'GET', - query: { - 'filter[application_id]': applicationId, - 'filter[discord_id]': discordId, - }, - }); + const response = await apiFetch( + `/application-submission/${applicationSubmissionid}/history`, + { + method: 'GET', + } + ); if (!response.ok) { throw new Error( `Failed to retrieve users applications: ${await response.text()}` diff --git a/commands/utils/paginate.js b/commands/utils/paginate.js index 156fd19..1d357bf 100644 --- a/commands/utils/paginate.js +++ b/commands/utils/paginate.js @@ -36,10 +36,17 @@ export default class Paginate { async paginate() { if (this.#embeds.length === 0) { - this.#interaction.reply({ - content: 'No data found!', - ephemeral: true, - }); + if (this.#interaction.replied || this.#interaction.deferred) { + this.#interaction.editReply({ + content: 'No data found!', + ephemeral: true, + }); + } else { + this.#interaction.reply({ + content: 'No data found!', + ephemeral: true, + }); + } return this; } const message = await this.#render(); diff --git a/events/application.js b/events/application.js index de321e0..fd23083 100644 --- a/events/application.js +++ b/events/application.js @@ -3,11 +3,16 @@ import { TextInputBuilder, ActionRowBuilder, TextInputStyle, + EmbedBuilder, } from 'discord.js'; import { acceptApplicationSubmission, denyApplicationSubmission, + getApplicationSubmissionHistory, } from '../commands/utils/apply/requests.js'; +import { loadMessage } from '../utils/loadMessage.js'; +import Paginate from '../commands/utils/paginate.js'; +import dayjs from 'dayjs'; export const applicationHandler = async (interaction) => { if (interaction.isModalSubmit()) { @@ -106,6 +111,74 @@ const handleButtons = async (interaction) => { modal.addComponents(answerActionRow); await interaction.showModal(modal); } + + if (action === 'history') { + await interaction.deferReply({ ephemeral: true }); + + const history = await getApplicationSubmissionHistory(id); + + const embeds = []; + for (const submission of history) { + const message = await loadMessage(interaction, submission); + const name = + submission.member?.nick ?? + submission.member?.user?.global_name ?? + submission.member?.user?.username ?? + submission.discord_id; + + const state = + { + 0: 'In Progress', + 1: 'Pending', + 2: 'Accepted', + 3: 'Denied', + }[submission.state] ?? 'Cancelled'; + + const response = + submission.custom_response ?? + submission.application_response?.response ?? + (submission.state === 2 + ? submission.application?.accept_message + : submission.state === 3 + ? submission.application?.deny_message + : '---'); + const createdAt = ``; + const submittedAt = submission.submitted_at + ? `` + : '---'; + + embeds.push( + new EmbedBuilder() + .setTitle( + `Application from ${name} for ${submission.application.name}` + ) + .addFields({ + name: `Status`, + value: state, + }) + .addFields({ + name: `Message`, + value: `${message?.url ?? 'message not found'}`, + }) + .addFields({ + name: `Response`, + value: response, + }) + .addFields({ + name: `Created at`, + value: createdAt, + }) + .addFields({ + name: `Submitted at`, + value: submittedAt, + }) + .setTimestamp() + ); + } + + const pagination = new Paginate(interaction, embeds); + await pagination.paginate(); + } }; const handleSelectMenu = async (interaction) => { diff --git a/utils/loadMessage.js b/utils/loadMessage.js new file mode 100644 index 0000000..2499e09 --- /dev/null +++ b/utils/loadMessage.js @@ -0,0 +1,21 @@ +import Logger from '../utils/logger.js'; + +export const loadMessage = async (interaction, model) => { + if (!model.channel_id || !model.message_id) { + return; + } + try { + const channel = await interaction.guild.channels.fetch(model.channel_id); + return await channel.messages.fetch(model.message_id); + } catch (e) { + // Unknown message || Unknown channel + if (e.code === 10008 || e.code === 10003) { + Logger.warning( + `Message/Channel not found for Reaction role ${model.id}, ${e}` + ); + } else { + Logger.error(`An error occoured while fetching the message: ${e}`); + } + return; + } +}; From 8ce051111b6b70e36a2aa5fa4ad11eade55c5fc4 Mon Sep 17 00:00:00 2001 From: PadBro Date: Fri, 25 Jul 2025 11:53:49 +0200 Subject: [PATCH 2/5] feat(application): add required role --- commands/apply.js | 10 ++++++++++ commands/utils/apply/requests.js | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/commands/apply.js b/commands/apply.js index 625283c..7385fd1 100644 --- a/commands/apply.js +++ b/commands/apply.js @@ -66,6 +66,16 @@ export const execute = async (interaction) => { } } + for (const requiredRole of application.required_roles) { + if (!member.roles.cache.has(requiredRole.role_id)) { + await interaction.reply({ + content: 'You do not have the permission to execute that command.', + ephemeral: true, + }); + return; + } + } + const channel = await member.createDM(); let originalMessage = null; try { diff --git a/commands/utils/apply/requests.js b/commands/utils/apply/requests.js index d31da92..20e22d5 100644 --- a/commands/utils/apply/requests.js +++ b/commands/utils/apply/requests.js @@ -6,7 +6,7 @@ export const getApplicationById = async (applicationId) => { method: 'GET', query: { 'filter[id]': applicationId, - include: 'restrictedRoles', + include: 'restrictedRoles,requiredRoles', }, }); if (!response.ok) { From 751e92e3939ac8a2152dfa4aa1c71ee09cdc8aea Mon Sep 17 00:00:00 2001 From: PadBro Date: Fri, 25 Jul 2025 11:55:24 +0200 Subject: [PATCH 3/5] feat(application): cancel on timeout --- commands/apply.js | 2 ++ commands/utils/apply/requests.js | 15 +++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/commands/apply.js b/commands/apply.js index 7385fd1..7363b6a 100644 --- a/commands/apply.js +++ b/commands/apply.js @@ -11,6 +11,7 @@ import { getApplicationQuestions, submitApplicationSubmission, getApplicationById, + cancelApplicationSubmission, } from './utils/apply/requests.js'; import { apiFetch } from '../utils/apiFetch.js'; @@ -193,6 +194,7 @@ const handleQuestionAnswer = async ( 'You did not provide an answer within the time limit. The application process has been cancelled.' ) .setColor('#ce361e'); + cancelApplicationSubmission(applicationSubmissionId); await channel.send({ embeds: [embed], }); diff --git a/commands/utils/apply/requests.js b/commands/utils/apply/requests.js index 20e22d5..914a629 100644 --- a/commands/utils/apply/requests.js +++ b/commands/utils/apply/requests.js @@ -138,6 +138,21 @@ export const denyApplicationSubmission = async ( } }; +export const cancelApplicationSubmission = async (applicationSubmissionid) => { + const response = await apiFetch( + `/application-submission/${applicationSubmissionid}`, + { + method: 'PUT', + body: { + state: 4, + }, + } + ); + if (!response.ok) { + throw new Error(`Failed to submit application: ${await response.text()}`); + } +}; + export const getApplicationSubmissionHistory = async ( applicationSubmissionid ) => { From b81d695875ba98a410e9ab5059d67da987c8cd4f Mon Sep 17 00:00:00 2001 From: PadBro Date: Fri, 25 Jul 2025 15:15:44 +0200 Subject: [PATCH 4/5] feat(application): improve cancel messages --- commands/apply.js | 3 ++- commands/utils/confirmActionDm.js | 13 ++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/commands/apply.js b/commands/apply.js index 7363b6a..0cf3975 100644 --- a/commands/apply.js +++ b/commands/apply.js @@ -99,14 +99,15 @@ export const execute = async (interaction) => { const confirmed = await confirmActionDm( channel, + "Your Application for `" + application.name + "`", application.confirmation_message, 'Yes', `Thank you for applying.\nYour application process for \`${application.name}\` has started.`, + "Application cancelled", originalMessage ); if (!confirmed) { - await channel.send('Application process cancelled'); return; } diff --git a/commands/utils/confirmActionDm.js b/commands/utils/confirmActionDm.js index 3c19226..07ae223 100644 --- a/commands/utils/confirmActionDm.js +++ b/commands/utils/confirmActionDm.js @@ -7,9 +7,11 @@ import { export const confirmActionDm = async ( channel, + title, confirmMessage, confirmLabel, confirmationMessage, + cancelMessage = "Action cancelled", originalMessage = null ) => { const confirm = new ButtonBuilder() @@ -25,7 +27,7 @@ export const confirmActionDm = async ( const row = new ActionRowBuilder().addComponents(confirm, cancel); const embed = new EmbedBuilder() - .setTitle(`Your Application`) + .setTitle(title) .setDescription(confirmMessage) .setColor('#f0833a'); @@ -55,7 +57,8 @@ export const confirmActionDm = async ( }); return true; } else if (confirmation.customId === 'cancel') { - embed.setDescription('Action cancelled'); + embed.setDescription(cancelMessage) + .setColor('#ce361e'); await confirmation.update({ embeds: [embed], components: [], @@ -63,8 +66,12 @@ export const confirmActionDm = async ( return false; } } catch { + const embed = new EmbedBuilder() + .setTitle(title) + .setDescription('Confirmation not received within 1 minute, cancelling') + .setColor('#ce361e'); await response.edit({ - content: 'Confirmation not received within 1 minute, cancelling', + embeds: [embed], components: [], }); return false; From 3c6c7c89d00b9af1f55d0ba471676c73db1ada4d Mon Sep 17 00:00:00 2001 From: PadBro Date: Fri, 25 Jul 2025 16:33:08 +0200 Subject: [PATCH 5/5] feat(application): add button --- commands/apply.js | 205 +----------------------------- commands/utils/apply/index.js | 202 +++++++++++++++++++++++++++++ commands/utils/confirmActionDm.js | 5 +- events/application.js | 5 + 4 files changed, 212 insertions(+), 205 deletions(-) create mode 100644 commands/utils/apply/index.js diff --git a/commands/apply.js b/commands/apply.js index 0cf3975..883558d 100644 --- a/commands/apply.js +++ b/commands/apply.js @@ -1,19 +1,6 @@ -import { EmbedBuilder, SlashCommandBuilder } from 'discord.js'; -import { confirmActionDm } from './utils/confirmActionDm.js'; -import { Logger } from '../utils/index.js'; -import { - applicationStartedDmEmbed, - closedDmEmbed, -} from './utils/apply/embeds.js'; -import { - submitAnswer, - createApplicationSubmission, - getApplicationQuestions, - submitApplicationSubmission, - getApplicationById, - cancelApplicationSubmission, -} from './utils/apply/requests.js'; +import { SlashCommandBuilder } from 'discord.js'; import { apiFetch } from '../utils/apiFetch.js'; +import { handleApplication } from './utils/apply/index.js'; export const data = new SlashCommandBuilder() .setName('apply') @@ -45,192 +32,6 @@ export const autocomplete = async (interaction) => { }; export const execute = async (interaction) => { - const member = interaction.member; const applicationId = interaction.options.getString('application'); - const application = await getApplicationById(applicationId); - if (!application) { - await interaction.reply({ - content: - 'The application was not found. Please use the autocomplete. If this issue pressists contact the staff team.', - ephemeral: true, - }); - return; - } - - for (const restrictedRole of application.restricted_roles) { - if (member.roles.cache.has(restrictedRole.role_id)) { - await interaction.reply({ - content: 'You do not have the permission to execute that command.', - ephemeral: true, - }); - return; - } - } - - for (const requiredRole of application.required_roles) { - if (!member.roles.cache.has(requiredRole.role_id)) { - await interaction.reply({ - content: 'You do not have the permission to execute that command.', - ephemeral: true, - }); - return; - } - } - - const channel = await member.createDM(); - let originalMessage = null; - try { - originalMessage = await channel.send( - 'Thank you for starting an application process' - ); - } catch (e) { - Logger.error('cannot send direct message: ' + e); - await interaction.reply({ - embeds: [closedDmEmbed], - ephemeral: true, - }); - return; - } - try { - await interaction.reply({ - embeds: [applicationStartedDmEmbed], - ephemeral: true, - }); - - const confirmed = await confirmActionDm( - channel, - "Your Application for `" + application.name + "`", - application.confirmation_message, - 'Yes', - `Thank you for applying.\nYour application process for \`${application.name}\` has started.`, - "Application cancelled", - originalMessage - ); - - if (!confirmed) { - return; - } - - const applicationSubmission = await createApplicationSubmission( - application.id, - member.id - ); - const questions = await getApplicationQuestions(application.id); - - const answerList = []; - for (let i = 0; i < questions.length; i++) { - const answer = await handleQuestionAnswer( - application, - channel, - questions[i], - applicationSubmission.id, - i, - questions.length - ); - if (answer == null) { - return; - } - answerList.push({ question: questions[i].question, answer }); - } - - await submitApplicationSubmission(applicationSubmission.id); - - const embed = new EmbedBuilder() - .setTitle(`Application for \`${application.name}\` completed`) - .setDescription(application.completion_message) - .setColor('#f0833a'); - channel.send({ - embeds: [embed], - }); - } catch (error) { - let message = - 'An error occurred during the application process. Please try again later or contact the staff team.'; - let logError = true; - if (error.message === 'Application was cancelled.') { - message = - 'Your prevoius application was cancelled in favor for your new application'; - logError = false; - } - await handleError( - channel, - message, - `Application error: ${error.message}`, - logError - ); - } -}; - -const handleQuestionAnswer = async ( - application, - channel, - question, - applicationSubmissionId, - questionNr, - questionsLength -) => { - const embed = new EmbedBuilder() - .setTitle( - `Application for \`${application.name}\` question ${questionNr + 1}/${questionsLength}` - ) - .setDescription(question.question) - .setColor('#ffdbe5'); - - await channel.send({ - embeds: [embed], - }); - const collected = await channel.awaitMessages({ - max: 1, - filter: (message) => { - return ( - !message.author.bot && (message.content || message.attachments.size) - ); - }, - time: 600_000, - }); - - if (collected.size === 0) { - const embed = new EmbedBuilder() - .setTitle(`Application timeout`) - .setDescription( - 'You did not provide an answer within the time limit. The application process has been cancelled.' - ) - .setColor('#ce361e'); - cancelApplicationSubmission(applicationSubmissionId); - await channel.send({ - embeds: [embed], - }); - return null; - } - - let answer = collected.first().content; - if (answer.length != 0) { - answer += ' '; - } - let attachments = ''; - collected.first().attachments.forEach((attachment) => { - attachments += attachment.url + ' '; - }); - - answer += attachments; - - await submitAnswer(applicationSubmissionId, question.id, answer, attachments); - return answer; -}; - -const handleError = async ( - channel, - errorMessage, - logMessage, - logError = true -) => { - const embed = new EmbedBuilder() - .setTitle(`Error`) - .setDescription(errorMessage) - .setColor('#ce361e'); - await channel.send({ - embeds: [embed], - }); - if (logError) { - Logger.error(logMessage); - } + handleApplication(interaction, applicationId); }; diff --git a/commands/utils/apply/index.js b/commands/utils/apply/index.js new file mode 100644 index 0000000..43415e7 --- /dev/null +++ b/commands/utils/apply/index.js @@ -0,0 +1,202 @@ +import { EmbedBuilder } from 'discord.js'; +import { confirmActionDm } from '../confirmActionDm.js'; +import { Logger } from '../../../utils/index.js'; +import { applicationStartedDmEmbed, closedDmEmbed } from './embeds.js'; +import { + submitAnswer, + createApplicationSubmission, + getApplicationQuestions, + submitApplicationSubmission, + getApplicationById, + cancelApplicationSubmission, +} from './requests.js'; + +export const handleApplication = async (interaction, applicationId) => { + const member = interaction.member; + const application = await getApplicationById(applicationId); + if (!application) { + await interaction.reply({ + content: + 'The application was not found. Please use the autocomplete. If this issue pressists contact the staff team.', + ephemeral: true, + }); + return; + } + + for (const restrictedRole of application.restricted_roles) { + if (member.roles.cache.has(restrictedRole.role_id)) { + await interaction.reply({ + content: 'You do not have the permission to execute that command.', + ephemeral: true, + }); + return; + } + } + + for (const requiredRole of application.required_roles) { + if (!member.roles.cache.has(requiredRole.role_id)) { + await interaction.reply({ + content: 'You do not have the permission to execute that command.', + ephemeral: true, + }); + return; + } + } + + const channel = await member.createDM(); + let originalMessage = null; + try { + originalMessage = await channel.send( + 'Thank you for starting an application process' + ); + } catch (e) { + Logger.error('cannot send direct message: ' + e); + await interaction.reply({ + embeds: [closedDmEmbed], + ephemeral: true, + }); + return; + } + try { + await interaction.reply({ + embeds: [applicationStartedDmEmbed], + ephemeral: true, + }); + + const confirmed = await confirmActionDm( + channel, + 'Your Application for `' + application.name + '`', + application.confirmation_message, + 'Yes', + `Thank you for applying.\nYour application process for \`${application.name}\` has started.`, + 'Application cancelled', + originalMessage + ); + + if (!confirmed) { + return; + } + + const applicationSubmission = await createApplicationSubmission( + application.id, + member.id + ); + const questions = await getApplicationQuestions(application.id); + + const answerList = []; + for (let i = 0; i < questions.length; i++) { + const answer = await handleQuestionAnswer( + application, + channel, + questions[i], + applicationSubmission.id, + i, + questions.length + ); + if (answer == null) { + return; + } + answerList.push({ question: questions[i].question, answer }); + } + + await submitApplicationSubmission(applicationSubmission.id); + + const embed = new EmbedBuilder() + .setTitle(`Application for \`${application.name}\` completed`) + .setDescription(application.completion_message) + .setColor('#f0833a'); + channel.send({ + embeds: [embed], + }); + } catch (error) { + let message = + 'An error occurred during the application process. Please try again later or contact the staff team.'; + let logError = true; + if (error.message === 'Application was cancelled.') { + message = + 'Your prevoius application was cancelled in favor for your new application'; + logError = false; + } + await handleError( + channel, + message, + `Application error: ${error.message}`, + logError + ); + } +}; + +const handleQuestionAnswer = async ( + application, + channel, + question, + applicationSubmissionId, + questionNr, + questionsLength +) => { + const embed = new EmbedBuilder() + .setTitle( + `Application for \`${application.name}\` question ${questionNr + 1}/${questionsLength}` + ) + .setDescription(question.question) + .setColor('#ffdbe5'); + + await channel.send({ + embeds: [embed], + }); + const collected = await channel.awaitMessages({ + max: 1, + filter: (message) => { + return ( + !message.author.bot && (message.content || message.attachments.size) + ); + }, + time: 600_000, + }); + + if (collected.size === 0) { + const embed = new EmbedBuilder() + .setTitle(`Application timeout`) + .setDescription( + 'You did not provide an answer within the time limit. The application process has been cancelled.' + ) + .setColor('#ce361e'); + cancelApplicationSubmission(applicationSubmissionId); + await channel.send({ + embeds: [embed], + }); + return null; + } + + let answer = collected.first().content; + if (answer.length != 0) { + answer += ' '; + } + let attachments = ''; + collected.first().attachments.forEach((attachment) => { + attachments += attachment.url + ' '; + }); + + answer += attachments; + + await submitAnswer(applicationSubmissionId, question.id, answer, attachments); + return answer; +}; + +const handleError = async ( + channel, + errorMessage, + logMessage, + logError = true +) => { + const embed = new EmbedBuilder() + .setTitle(`Error`) + .setDescription(errorMessage) + .setColor('#ce361e'); + await channel.send({ + embeds: [embed], + }); + if (logError) { + Logger.error(logMessage); + } +}; diff --git a/commands/utils/confirmActionDm.js b/commands/utils/confirmActionDm.js index 07ae223..fa74cb3 100644 --- a/commands/utils/confirmActionDm.js +++ b/commands/utils/confirmActionDm.js @@ -11,7 +11,7 @@ export const confirmActionDm = async ( confirmMessage, confirmLabel, confirmationMessage, - cancelMessage = "Action cancelled", + cancelMessage = 'Action cancelled', originalMessage = null ) => { const confirm = new ButtonBuilder() @@ -57,8 +57,7 @@ export const confirmActionDm = async ( }); return true; } else if (confirmation.customId === 'cancel') { - embed.setDescription(cancelMessage) - .setColor('#ce361e'); + embed.setDescription(cancelMessage).setColor('#ce361e'); await confirmation.update({ embeds: [embed], components: [], diff --git a/events/application.js b/events/application.js index fd23083..bc2af85 100644 --- a/events/application.js +++ b/events/application.js @@ -13,6 +13,7 @@ import { import { loadMessage } from '../utils/loadMessage.js'; import Paginate from '../commands/utils/paginate.js'; import dayjs from 'dayjs'; +import { handleApplication } from '../commands/utils/apply/index.js'; export const applicationHandler = async (interaction) => { if (interaction.isModalSubmit()) { @@ -112,6 +113,10 @@ const handleButtons = async (interaction) => { await interaction.showModal(modal); } + if (action === 'start') { + handleApplication(interaction, id); + } + if (action === 'history') { await interaction.deferReply({ ephemeral: true });