From 80f892babf4cb7fb5398b4da26627a592b4541f0 Mon Sep 17 00:00:00 2001 From: Illya Gerasymchuk Date: Sat, 24 Aug 2024 19:06:12 +0200 Subject: [PATCH 01/12] #funding-round-init feat: allow comittee to CRUD FRs + extract TopicLogic --- src/Errors.ts | 4 + src/channels/DiscordStatus.ts | 3 +- .../admin/screens/FundingRoundLogic.ts | 56 ++- .../screens/ManageFundingRoundsScreen.ts | 7 +- .../admin/screens/ManageTopicLogicScreen.ts | 316 +--------------- .../CommitteeDeliberationHomeScreen.ts | 4 +- .../screens/FundingRoundInitScreen.ts | 11 +- .../vote/screens/FundingRoundVotingScreen.ts | 4 +- src/logic/TopicLogic.ts | 342 ++++++++++++++++++ 9 files changed, 410 insertions(+), 337 deletions(-) diff --git a/src/Errors.ts b/src/Errors.ts index 7eac3a8..7401b7f 100644 --- a/src/Errors.ts +++ b/src/Errors.ts @@ -23,4 +23,8 @@ export class NotFoundEndUserError extends EndUserError { export class EndUserInfo extends GovBotError { +} + +export class NotFoundEndUserInfo extends EndUserInfo { + } \ No newline at end of file diff --git a/src/channels/DiscordStatus.ts b/src/channels/DiscordStatus.ts index b610a08..09777aa 100644 --- a/src/channels/DiscordStatus.ts +++ b/src/channels/DiscordStatus.ts @@ -1,7 +1,6 @@ import { Message } from "discord.js"; import { TrackedInteraction } from "../core/BaseClasses"; -import { EndUserError, EndUserInfo, GovBotError } from "../Errors"; -import logger from "../logging"; +import { EndUserError, EndUserInfo} from "../Errors"; export class DiscordStatus { diff --git a/src/channels/admin/screens/FundingRoundLogic.ts b/src/channels/admin/screens/FundingRoundLogic.ts index fc38385..d2b7d8e 100644 --- a/src/channels/admin/screens/FundingRoundLogic.ts +++ b/src/channels/admin/screens/FundingRoundLogic.ts @@ -1,7 +1,8 @@ import sequelize from '../../../config/database'; +import { TrackedInteraction } from '../../../core/BaseClasses'; import { EndUserError } from '../../../Errors'; import logger from '../../../logging'; -import { FundingRound, Topic, ConsiderationPhase, DeliberationPhase, FundingVotingPhase, SMEGroup, SMEGroupMembership, FundingRoundDeliberationCommitteeSelection, FundingRoundApprovalVote, TopicSMEGroupProposalCreationLimiter, Proposal } from '../../../models'; +import { FundingRound, Topic, ConsiderationPhase, DeliberationPhase, FundingVotingPhase, SMEGroup, SMEGroupMembership, FundingRoundDeliberationCommitteeSelection, FundingRoundApprovalVote, TopicSMEGroupProposalCreationLimiter, Proposal, TopicCommittee } from '../../../models'; import { FundingRoundMI, FundingRoundMIPhase, FundingRoundMIPhaseValue } from '../../../models/Interface'; import { FundingRoundAttributes, FundingRoundStatus, FundingRoundPhase, ProposalStatus } from '../../../types'; import { Op, Transaction } from 'sequelize'; @@ -351,7 +352,9 @@ export class FundingRoundLogic { }); } - static async getEligibleVotingRounds(): Promise { + static async getEligibleVotingRounds(interaction: TrackedInteraction): Promise { + const duid: string = interaction.discordUserId; + const now = new Date(); const allFindingRoundsInVoting = await FundingRound.findAll({ where: { @@ -361,8 +364,11 @@ export class FundingRoundLogic { }, }, }); - const onlyReadyFundingRounds = allFindingRoundsInVoting.filter( value => value.isReady()) - return onlyReadyFundingRounds; + const onlyReadyFundingRounds = allFindingRoundsInVoting.filter( value => value.isReady()); + + const onlyFundingRoundsWhereUserIsSME = onlyReadyFundingRounds.filter(value => value.isSMEGroupMember(duid)); + + return onlyFundingRoundsWhereUserIsSME; } static async hasUserVotedOnFundingRound(userId: string, fundingRoundId: number): Promise { @@ -846,12 +852,50 @@ export class FundingRoundLogic { } static async setTopic(fundingRoundId: number, topicId: number): Promise { - const { TopicLogic } = await import('../../admin/screens/ManageTopicLogicScreen'); + const { TopicLogic } = await import("../../../logic/TopicLogic"); const fundingRound = await this.getFundingRoundByIdOrError(fundingRoundId); const topic = await TopicLogic.getByIdOrError(topicId); return await fundingRound.update({ topicId: topic.id }); - } + } + + static async getFundingRoundsForUser(duid: string): Promise { + // Get all SMEGroups the user belongs to + const userSMEGroups = await SMEGroupMembership.findAll({ + where: { duid }, + attributes: ['smeGroupId'] + }); + + const userSMEGroupIds = userSMEGroups.map(group => group.smeGroupId); + + // Find all TopicCommittees associated with the user's SMEGroups + const relevantTopicCommittees = await TopicCommittee.findAll({ + where: { + smeGroupId: { + [Op.in]: userSMEGroupIds + } + }, + attributes: ['topicId'] + }); + const relevantTopicIds = relevantTopicCommittees.map(committee => committee.topicId); + + // Find all FundingRounds associated with these Topics + const fundingRounds = await FundingRound.findAll({ + where: { + topicId: { + [Op.in]: relevantTopicIds + } + }, + include: [ + { + model: Topic, + as: 'topic' + } + ] + }); + + return fundingRounds; + } } \ No newline at end of file diff --git a/src/channels/admin/screens/ManageFundingRoundsScreen.ts b/src/channels/admin/screens/ManageFundingRoundsScreen.ts index a91bd0f..016f10f 100644 --- a/src/channels/admin/screens/ManageFundingRoundsScreen.ts +++ b/src/channels/admin/screens/ManageFundingRoundsScreen.ts @@ -5,15 +5,13 @@ import { ArgumentOracle, CustomIDOracle } from '../../../CustomIDOracle'; import { ConsiderationPhase, DeliberationPhase, FundingRound, FundingVotingPhase, SMEGroup, Topic, TopicCommittee } from '../../../models'; import { InteractionProperties } from '../../../core/Interaction'; import { PaginationComponent } from '../../../components/PaginationComponent'; -import { FundingRoundPhase, FundingRoundStatus } from '../../../types'; -import { TopicLogic } from './ManageTopicLogicScreen'; import logger from '../../../logging'; import { EndUserError, NotFoundEndUserError } from '../../../Errors'; import { DiscordStatus } from '../../DiscordStatus'; import { FundingRoundMI, FundingRoundMIPhaseValue } from '../../../models/Interface'; import { InputDate } from '../../../dates/Input'; -import { ExclusionConstraintError } from 'sequelize'; import { ApproveRejectFundingRoundPaginator, EditFundingRoundPaginator, FundingRoundPaginator, RemoveCommiteeFundingRoundPaginator, SetCommitteeFundingRoundPaginator } from '../../../components/FundingRoundPaginator'; +import { TopicLogic } from '../../../logic/TopicLogic'; @@ -297,7 +295,8 @@ export class SelectTopicAction extends PaginationComponent { } protected async getItemsForPage(interaction: TrackedInteraction, page: number): Promise { - const topics = await TopicLogic.getAllTopics(); + const duid: string = interaction.discordUserId; + const topics = await TopicLogic.getTopicsForSMEMember(duid); return topics.slice(page * 25, (page + 1) * 25); } diff --git a/src/channels/admin/screens/ManageTopicLogicScreen.ts b/src/channels/admin/screens/ManageTopicLogicScreen.ts index 69ef388..94ddee2 100644 --- a/src/channels/admin/screens/ManageTopicLogicScreen.ts +++ b/src/channels/admin/screens/ManageTopicLogicScreen.ts @@ -3,323 +3,13 @@ import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, StringSelec import { Topic, SMEGroup, TopicSMEGroupProposalCreationLimiter, sequelize, TopicCommittee } from '../../../models'; import { CustomIDOracle } from '../../../CustomIDOracle'; import { PaginationComponent } from '../../../components/PaginationComponent'; -import { AnyInteractionWithShowModal, AnyModalMessageComponent } from '../../../types/common'; -import { TopicAttributes, TopicCommitteeAttributes } from '../../../types'; -import { allowedNodeEnvironmentFlags } from 'process'; +import { AnyModalMessageComponent } from '../../../types/common'; import { InteractionProperties } from '../../../core/Interaction'; -import { parsed } from 'yargs'; import logger from '../../../logging'; import { EndUserError } from '../../../Errors'; +import { TopicLogic } from '../../../logic/TopicLogic'; +import { TopicCommitteeWithSMEGroup } from '../../../types'; -interface TopicCommitteeWithSMEGroup extends TopicCommitteeAttributes { - smeGroupName: string; - } - -export class TopicLogic { - - static getByIdOrError(topicId: number): Promise { - return Topic.findByPk(topicId).then((topic) => { - - if (!topic) { - throw new EndUserError(`Topic with ID ${topicId} not found`); - } - - return topic; - }); - } - - static async getTotalTopicsCount(): Promise { - return await Topic.count(); - } - - static async getAllTopics(): Promise { - return await Topic.findAll({ - order: [['name', 'ASC']] - }); - } - - static async getPaginatedTopics(page: number, pageSize: number): Promise { - return await Topic.findAll({ - order: [['name', 'ASC']], - limit: pageSize, - offset: page * pageSize, - }); - } - - static async getTopicById(id: number): Promise { - return await Topic.findByPk(id); - } - - static async createTopic(name: string, description: string): Promise { - return await Topic.create({ name, description }); - } - - static async deleteTopic(id: number): Promise { - const topic = await this.getTopicById(id); - if (topic) { - await topic.destroy(); - } - } - - static async deleteTopicWithDependencies(topicId: number): Promise { - const topic = await this.getTopicById(topicId); - if (!topic) { - throw new EndUserError('Topic not found'); - } - - await Topic.sequelize!.transaction(async (t) => { - // Remove associated records - await TopicSMEGroupProposalCreationLimiter.destroy({ where: { topicId: topic.id }, transaction: t }); - - // TODO: Add logic to handle other dependencies (e.g., proposals, funding rounds) - // For example: - // await Proposal.destroy({ where: { topicId: topic.id }, transaction: t }); - // await FundingRound.destroy({ where: { topicId: topic.id }, transaction: t }); - - // Delete the topic itself - await topic.destroy({ transaction: t }); - }); - } - - static async setAllowedSMEGroups(topicId: number, smeGroupNames: string[]): Promise { - const topic = await this.getTopicById(topicId); - if (!topic) { - throw new EndUserError('Topic not found'); - } - - const smeGroups = await SMEGroup.findAll({ - where: { - name: smeGroupNames - } - }); - - if (smeGroups.length !== smeGroupNames.length) { - const foundNames = smeGroups.map(group => group.name); - const missingNames = smeGroupNames.filter(name => !foundNames.includes(name)); - throw new EndUserError(`The following SME groups were not found: ${missingNames.join(', ')}`); - } - - await TopicSMEGroupProposalCreationLimiter.destroy({ - where: { topicId: topic.id } - }); - - for (const smeGroup of smeGroups) { - await TopicSMEGroupProposalCreationLimiter.create({ - topicId: topic.id, - smeGroupId: smeGroup.id - }); - } - } - - static async updateTopic(topicId: number, name: string, description: string): Promise { - const topic = await this.getTopicById(topicId); - if (!topic) { - throw new EndUserError('Topic not found'); - } - - await topic.update({ name, description }); - } - - static async clearAllowedSMEGroups(topicId: number): Promise { - const topic = await this.getTopicById(topicId); - if (!topic) { - throw new EndUserError('Topic not found'); - } - - await TopicSMEGroupProposalCreationLimiter.destroy({ - where: { topicId: topic.id } - }); - } - - static async validateSMEGroups(smeGroupNames: string[]): Promise { - const smeGroups = await SMEGroup.findAll({ - where: { - name: smeGroupNames - } - }); - - if (smeGroups.length !== smeGroupNames.length) { - const foundNames = smeGroups.map(group => group.name); - const missingNames = smeGroupNames.filter(name => !foundNames.includes(name)); - throw new EndUserError(`The following SME groups were not found: ${missingNames.join(', ')}`); - } - } - - static async createTopicWithAllowedGroups(name: string, description: string, smeGroupNames: string[]): Promise { - return await sequelize.transaction(async (t) => { - const topic = await Topic.create({ name, description }, { transaction: t }); - - if (smeGroupNames.length > 0) { - const smeGroups = await SMEGroup.findAll({ - where: { name: smeGroupNames }, - transaction: t - }); - - for (const smeGroup of smeGroups) { - await TopicSMEGroupProposalCreationLimiter.create({ - topicId: topic.id, - smeGroupId: smeGroup.id - }, { transaction: t }); - } - } - - return topic; - }); - } - - static async getTopicDetails(topicId: number): Promise { - const topic = await Topic.findByPk(topicId, { - include: [ - { - model: TopicCommittee, - as: 'topicCommittees', - include: [{ model: SMEGroup, attributes: ['name'] }] - } - ] - }); - - if (!topic) { - throw new EndUserError('Topic not found'); - } - - const committeesQuery: Promise = TopicCommittee.findAll({ - where: { topicId: topic.id }, - include: [{ model: SMEGroup, attributes: ['name'] }] - }); - - let committies = [] - for (const committee of await committeesQuery) { - const smeGroupName = SMEGroup.findByPk(committee.smeGroupId).then(group => group?.name); - committies.push({ - id: committee.id, - topicId: committee.topicId, - smeGroupId: committee.smeGroupId, - smeGroupName: smeGroupName, - numUsers: committee.numUsers - }); - } - - return { - id: topic.id, - name: topic.name, - description: topic.description, - committees: committies - }; - } - - static async getTopicCommittees(topicId: number): Promise { - const committees = await TopicCommittee.findAll({ - where: { topicId } - }); - - const result: TopicCommitteeWithSMEGroup[] = []; - - for (const committee of committees) { - const smeGroup = await SMEGroup.findByPk(committee.smeGroupId); - if (smeGroup) { - result.push({ - id: committee.id, - topicId: committee.topicId, - smeGroupId: committee.smeGroupId, - smeGroupName: smeGroup.name, - numUsers: committee.numUsers - }); - } - } - - return result; - } - - static async addTopicCommittee(topicId: number, smeGroupName: string, numUsers: number): Promise { - const topic = await Topic.findByPk(topicId); - if (!topic) { - throw new EndUserError('Topic not found'); - } - - const smeGroup = await SMEGroup.findOne({ where: { name: smeGroupName } }); - if (!smeGroup) { - throw new EndUserError('SME Group not found'); - } - - const existingCommittee = await TopicCommittee.findOne({ - where: { topicId, smeGroupId: smeGroup.id } - }); - - if (existingCommittee) { - throw new EndUserError('A committee for this SME group already exists for this topic'); - } - - const committee = await TopicCommittee.create({ - topicId, - smeGroupId: smeGroup.id, - numUsers - }); - - return { - id: committee.id, - topicId: committee.topicId, - smeGroupId: committee.smeGroupId, - smeGroupName: smeGroup.name, - numUsers: committee.numUsers - }; - } - - static async updateTopicCommittee(committeeId: number, numUsers: number): Promise { - const committee = await TopicCommittee.findByPk(committeeId); - if (!committee) { - throw new EndUserError('Committee not found'); - } - - const smeGroup = await SMEGroup.findByPk(committee.smeGroupId); - if (!smeGroup) { - throw new EndUserError('Associated SME Group not found'); - } - - await committee.update({ numUsers }); - - return { - id: committee.id, - topicId: committee.topicId, - smeGroupId: committee.smeGroupId, - smeGroupName: smeGroup.name, - numUsers: committee.numUsers - }; - } - - static async removeTopicCommittee(committeeId: number): Promise { - const committee = await TopicCommittee.findByPk(committeeId); - if (!committee) { - throw new EndUserError('Committee not found'); - } - - await committee.destroy(); - } - - static async getCommitteeDetails(committeeId: number): Promise { - const committee = await TopicCommittee.findByPk(committeeId); - if (!committee) { - return null; - } - - const smeGroup = await SMEGroup.findByPk(committee.smeGroupId); - if (!smeGroup) { - throw new EndUserError('Associated SME Group not found'); - } - - return { - id: committee.id, - topicId: committee.topicId, - smeGroupId: committee.smeGroupId, - smeGroupName: smeGroup.name, - numUsers: committee.numUsers - }; - } - - static async getTopicByName(name: string): Promise { - return await Topic.findOne({ where: { name } }); - } - -} class TopicsPaginationAction extends PaginationComponent { public allSubActions(): Action[] { diff --git a/src/channels/deliberate/CommitteeDeliberationHomeScreen.ts b/src/channels/deliberate/CommitteeDeliberationHomeScreen.ts index 6dcdd98..3bb5261 100644 --- a/src/channels/deliberate/CommitteeDeliberationHomeScreen.ts +++ b/src/channels/deliberate/CommitteeDeliberationHomeScreen.ts @@ -636,7 +636,7 @@ class CommitteeDeliberationVoteAction extends Action { } await CommitteeDeliberationLogic.submitVote(interaction.interaction.user.id, projectId, fundingRoundId, vote, reason, uri); - + // TODO: don't show the button to vote on the current vote value const embed = new EmbedBuilder() .setColor('#28a745') .setTitle('Vote Submitted Successfully') @@ -653,7 +653,7 @@ class CommitteeDeliberationVoteAction extends Action { {name: 'Reason', value: reason} ) } - await interaction.update({ embeds: [embed], ephemeral: true }); + await interaction.respond({ embeds: [embed], ephemeral: true }); } diff --git a/src/channels/funding-round-init/screens/FundingRoundInitScreen.ts b/src/channels/funding-round-init/screens/FundingRoundInitScreen.ts index 3c9281a..b729c8d 100644 --- a/src/channels/funding-round-init/screens/FundingRoundInitScreen.ts +++ b/src/channels/funding-round-init/screens/FundingRoundInitScreen.ts @@ -1,7 +1,6 @@ import { Screen, Action, Dashboard, Permission, TrackedInteraction, RenderArgs } from '../../../core/BaseClasses'; import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, MessageActionRowComponentBuilder, MessageCreateOptions, ModalBuilder, StringSelectMenuBuilder, TextChannel, TextInputBuilder, TextInputStyle } from 'discord.js'; import { ArgumentOracle, CustomIDOracle } from '../../../CustomIDOracle'; -import { ZkIgniteFacilitatorPermission } from '../permissions/ZkIgniteFacilitatorPermission'; import { PaginationComponent } from '../../../components/PaginationComponent'; import { FundingRoundLogic } from '../../admin/screens/FundingRoundLogic'; import { FundingRound, Topic } from '../../../models'; @@ -20,7 +19,7 @@ export class FundingRoundInitScreen extends Screen implements IHomeScreen { public readonly voteFundingRoundAction: VoteFundingRoundAction; - protected permissions: Permission[] = [new ZkIgniteFacilitatorPermission()]; + protected permissions: Permission[] = []; public readonly manageFundingRoundScreen: ManageFundingRoundsScreen; public readonly selectTopicAction: SelectTopicAction = new SelectTopicAction(this, SelectTopicAction.ID); @@ -33,7 +32,6 @@ export class FundingRoundInitScreen extends Screen implements IHomeScreen { public async renderToTextChannel(channel: TextChannel): Promise { const content: MessageCreateOptions = await this.getResponse(); await channel.send(content); - } protected allSubScreens(): Screen[] { @@ -422,8 +420,6 @@ export class CreateDraftFundingRoundAction extends Action { try { // logic will be resused from #admin throw new EndUserError('This should not be used (deprecated'); - //await FundingRoundLogic.setFundingRoundPhase(parseInt(fundingRoundId), mappedPhase, startDate, endDate); - await this.showSetPhaseDates(interaction, parseInt(fundingRoundId)); } catch (error) { throw new EndUserError('Error setting phase dates', error); } @@ -460,12 +456,12 @@ export class VoteFundingRoundAction extends PaginationComponent { }; protected async getTotalPages(interaction: TrackedInteraction): Promise { - const eligibleRounds = await FundingRoundLogic.getEligibleVotingRounds(); + const eligibleRounds = await FundingRoundLogic.getEligibleVotingRounds(interaction); return Math.ceil(eligibleRounds.length / 25); } protected async getItemsForPage(interaction: TrackedInteraction, page: number): Promise { - const eligibleRounds = await FundingRoundLogic.getEligibleVotingRounds(); + const eligibleRounds = await FundingRoundLogic.getEligibleVotingRounds(interaction); return eligibleRounds.slice(page * 25, (page + 1) * 25); } @@ -507,7 +503,6 @@ export class VoteFundingRoundAction extends PaginationComponent { const fundingRoundIdFromContext: string | undefined = interaction.Context.get(FUNDING_ROUND_ID_ARG); if (!fundingRoundIdFromContext) { throw new EndUserError('fundingRoundId not provided neither in customId, nor in context') - throw new EndUserError('fundingRoundId not provided neither in customId, nor in context'); } else { fundingRoundId = parseInt(fundingRoundIdFromContext); } diff --git a/src/channels/vote/screens/FundingRoundVotingScreen.ts b/src/channels/vote/screens/FundingRoundVotingScreen.ts index 09bebf9..2569f18 100644 --- a/src/channels/vote/screens/FundingRoundVotingScreen.ts +++ b/src/channels/vote/screens/FundingRoundVotingScreen.ts @@ -87,12 +87,12 @@ class SelectFundingRoundAction extends PaginationComponent { }; protected async getTotalPages(interaction: TrackedInteraction): Promise { - const eligibleFundingRounds = await FundingRoundLogic.getEligibleVotingRounds(); + const eligibleFundingRounds = await FundingRoundLogic.getEligibleVotingRounds(interaction); return Math.ceil(eligibleFundingRounds.length / 25); } protected async getItemsForPage(interaction: TrackedInteraction, page: number): Promise { - const eligibleFundingRounds = await FundingRoundLogic.getEligibleVotingRounds(); + const eligibleFundingRounds = await FundingRoundLogic.getEligibleVotingRounds(interaction); return eligibleFundingRounds.slice(page * 25, (page + 1) * 25); } diff --git a/src/logic/TopicLogic.ts b/src/logic/TopicLogic.ts index e69de29..e831b74 100644 --- a/src/logic/TopicLogic.ts +++ b/src/logic/TopicLogic.ts @@ -0,0 +1,342 @@ +import { Op } from "sequelize"; +import { EndUserError } from "../Errors"; +import { Topic, TopicSMEGroupProposalCreationLimiter, SMEGroup, sequelize, TopicCommittee, SMEGroupMembership } from "../models"; +import { TopicAttributes, TopicCommitteeAttributes, TopicCommitteeWithSMEGroup} from "../types"; + + +export class TopicLogic { + + static getByIdOrError(topicId: number): Promise { + return Topic.findByPk(topicId).then((topic) => { + + if (!topic) { + throw new EndUserError(`Topic with ID ${topicId} not found`); + } + + return topic; + }); + } + + static async getTotalTopicsCount(): Promise { + return await Topic.count(); + } + + static async getAllTopics(): Promise { + return await Topic.findAll({ + order: [['name', 'ASC']] + }); + } + + static async getPaginatedTopics(page: number, pageSize: number): Promise { + return await Topic.findAll({ + order: [['name', 'ASC']], + limit: pageSize, + offset: page * pageSize, + }); + } + + static async getTopicById(id: number): Promise { + return await Topic.findByPk(id); + } + + static async createTopic(name: string, description: string): Promise { + return await Topic.create({ name, description }); + } + + static async deleteTopic(id: number): Promise { + const topic = await this.getTopicById(id); + if (topic) { + await topic.destroy(); + } + } + + static async deleteTopicWithDependencies(topicId: number): Promise { + const topic = await this.getTopicById(topicId); + if (!topic) { + throw new EndUserError('Topic not found'); + } + + await Topic.sequelize!.transaction(async (t) => { + // Remove associated records + await TopicSMEGroupProposalCreationLimiter.destroy({ where: { topicId: topic.id }, transaction: t }); + + // TODO: Add logic to handle other dependencies (e.g., proposals, funding rounds) + // For example: + // await Proposal.destroy({ where: { topicId: topic.id }, transaction: t }); + // await FundingRound.destroy({ where: { topicId: topic.id }, transaction: t }); + + // Delete the topic itself + await topic.destroy({ transaction: t }); + }); + } + + static async setAllowedSMEGroups(topicId: number, smeGroupNames: string[]): Promise { + const topic = await this.getTopicById(topicId); + if (!topic) { + throw new EndUserError('Topic not found'); + } + + const smeGroups = await SMEGroup.findAll({ + where: { + name: smeGroupNames + } + }); + + if (smeGroups.length !== smeGroupNames.length) { + const foundNames = smeGroups.map(group => group.name); + const missingNames = smeGroupNames.filter(name => !foundNames.includes(name)); + throw new EndUserError(`The following SME groups were not found: ${missingNames.join(', ')}`); + } + + await TopicSMEGroupProposalCreationLimiter.destroy({ + where: { topicId: topic.id } + }); + + for (const smeGroup of smeGroups) { + await TopicSMEGroupProposalCreationLimiter.create({ + topicId: topic.id, + smeGroupId: smeGroup.id + }); + } + } + + static async updateTopic(topicId: number, name: string, description: string): Promise { + const topic = await this.getTopicById(topicId); + if (!topic) { + throw new EndUserError('Topic not found'); + } + + await topic.update({ name, description }); + } + + static async clearAllowedSMEGroups(topicId: number): Promise { + const topic = await this.getTopicById(topicId); + if (!topic) { + throw new EndUserError('Topic not found'); + } + + await TopicSMEGroupProposalCreationLimiter.destroy({ + where: { topicId: topic.id } + }); + } + + static async validateSMEGroups(smeGroupNames: string[]): Promise { + const smeGroups = await SMEGroup.findAll({ + where: { + name: smeGroupNames + } + }); + + if (smeGroups.length !== smeGroupNames.length) { + const foundNames = smeGroups.map(group => group.name); + const missingNames = smeGroupNames.filter(name => !foundNames.includes(name)); + throw new EndUserError(`The following SME groups were not found: ${missingNames.join(', ')}`); + } + } + + static async createTopicWithAllowedGroups(name: string, description: string, smeGroupNames: string[]): Promise { + return await sequelize.transaction(async (t) => { + const topic = await Topic.create({ name, description }, { transaction: t }); + + if (smeGroupNames.length > 0) { + const smeGroups = await SMEGroup.findAll({ + where: { name: smeGroupNames }, + transaction: t + }); + + for (const smeGroup of smeGroups) { + await TopicSMEGroupProposalCreationLimiter.create({ + topicId: topic.id, + smeGroupId: smeGroup.id + }, { transaction: t }); + } + } + + return topic; + }); + } + + static async getTopicDetails(topicId: number): Promise { + const topic = await Topic.findByPk(topicId, { + include: [ + { + model: TopicCommittee, + as: 'topicCommittees', + include: [{ model: SMEGroup, attributes: ['name'] }] + } + ] + }); + + if (!topic) { + throw new EndUserError('Topic not found'); + } + + const committeesQuery: Promise = TopicCommittee.findAll({ + where: { topicId: topic.id }, + include: [{ model: SMEGroup, attributes: ['name'] }] + }); + + let committies = [] + for (const committee of await committeesQuery) { + const smeGroupName = SMEGroup.findByPk(committee.smeGroupId).then(group => group?.name); + committies.push({ + id: committee.id, + topicId: committee.topicId, + smeGroupId: committee.smeGroupId, + smeGroupName: smeGroupName, + numUsers: committee.numUsers + }); + } + + return { + id: topic.id, + name: topic.name, + description: topic.description, + committees: committies + }; + } + + static async getTopicCommittees(topicId: number): Promise { + const committees = await TopicCommittee.findAll({ + where: { topicId } + }); + + const result: TopicCommitteeWithSMEGroup[] = []; + + for (const committee of committees) { + const smeGroup = await SMEGroup.findByPk(committee.smeGroupId); + if (smeGroup) { + result.push({ + id: committee.id, + topicId: committee.topicId, + smeGroupId: committee.smeGroupId, + smeGroupName: smeGroup.name, + numUsers: committee.numUsers + }); + } + } + + return result; + } + + static async addTopicCommittee(topicId: number, smeGroupName: string, numUsers: number): Promise { + const topic = await Topic.findByPk(topicId); + if (!topic) { + throw new EndUserError('Topic not found'); + } + + const smeGroup = await SMEGroup.findOne({ where: { name: smeGroupName } }); + if (!smeGroup) { + throw new EndUserError('SME Group not found'); + } + + const existingCommittee = await TopicCommittee.findOne({ + where: { topicId, smeGroupId: smeGroup.id } + }); + + if (existingCommittee) { + throw new EndUserError('A committee for this SME group already exists for this topic'); + } + + const committee = await TopicCommittee.create({ + topicId, + smeGroupId: smeGroup.id, + numUsers + }); + + return { + id: committee.id, + topicId: committee.topicId, + smeGroupId: committee.smeGroupId, + smeGroupName: smeGroup.name, + numUsers: committee.numUsers + }; + } + + static async updateTopicCommittee(committeeId: number, numUsers: number): Promise { + const committee = await TopicCommittee.findByPk(committeeId); + if (!committee) { + throw new EndUserError('Committee not found'); + } + + const smeGroup = await SMEGroup.findByPk(committee.smeGroupId); + if (!smeGroup) { + throw new EndUserError('Associated SME Group not found'); + } + + await committee.update({ numUsers }); + + return { + id: committee.id, + topicId: committee.topicId, + smeGroupId: committee.smeGroupId, + smeGroupName: smeGroup.name, + numUsers: committee.numUsers + }; + } + + static async removeTopicCommittee(committeeId: number): Promise { + const committee = await TopicCommittee.findByPk(committeeId); + if (!committee) { + throw new EndUserError('Committee not found'); + } + + await committee.destroy(); + } + + static async getCommitteeDetails(committeeId: number): Promise { + const committee = await TopicCommittee.findByPk(committeeId); + if (!committee) { + return null; + } + + const smeGroup = await SMEGroup.findByPk(committee.smeGroupId); + if (!smeGroup) { + throw new EndUserError('Associated SME Group not found'); + } + + return { + id: committee.id, + topicId: committee.topicId, + smeGroupId: committee.smeGroupId, + smeGroupName: smeGroup.name, + numUsers: committee.numUsers + }; + } + + static async getTopicByName(name: string): Promise { + return await Topic.findOne({ where: { name } }); + } + + static async getTopicsForSMEMember(duid: string): Promise { + // Find all SME groups the user is a member of + const memberships = await SMEGroupMembership.findAll({ + where: { duid } + }); + + const smeGroupIds = memberships.map(membership => membership.smeGroupId); + + // Find all topic committees associated with these SME groups + const committees = await TopicCommittee.findAll({ + where: { + smeGroupId: { + [Op.in]: smeGroupIds + } + } + }); + + const topicIds = committees.map(committee => committee.topicId); + + // Find and return all topics associated with these committees + const topics = await Topic.findAll({ + where: { + id: { + [Op.in]: topicIds + } + } + }); + + return topics; + } + +} \ No newline at end of file From 442b3a10dc724961ffe6b055235e99c787f57892 Mon Sep 17 00:00:00 2001 From: Illya Gerasymchuk Date: Sat, 24 Aug 2024 19:06:57 +0200 Subject: [PATCH 02/12] #funding-round-init feat: update paginators + OCV link --- src/components/FundingRoundPaginator.ts | 7 ++++++- src/components/PaginationComponent.ts | 7 ++++++- src/utils/OCVLinkGenerator.ts | 4 ++-- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/components/FundingRoundPaginator.ts b/src/components/FundingRoundPaginator.ts index 14ca6cf..4fe0501 100644 --- a/src/components/FundingRoundPaginator.ts +++ b/src/components/FundingRoundPaginator.ts @@ -42,6 +42,11 @@ export class EditFundingRoundPaginator extends FundingRoundPaginator { public args: string[] = [] public title: string = "Select A Funding Round To Edit"; + + public async getItems(interaction: TrackedInteraction): Promise { + const duid: string = interaction.discordUserId; + return await FundingRoundLogic.getFundingRoundsForUser(duid); + } } export class SetCommitteeFundingRoundPaginator extends FundingRoundPaginator { @@ -75,6 +80,6 @@ export class InVotingFundingRoundPaginator extends FundingRoundPaginator { public async getItems(interaction: TrackedInteraction): Promise { - return await FundingRoundLogic.getEligibleVotingRounds(); + return await FundingRoundLogic.getEligibleVotingRounds(interaction); } } \ No newline at end of file diff --git a/src/components/PaginationComponent.ts b/src/components/PaginationComponent.ts index 38343b8..73baace 100644 --- a/src/components/PaginationComponent.ts +++ b/src/components/PaginationComponent.ts @@ -4,7 +4,7 @@ import { Action, TrackedInteraction } from '../core/BaseClasses'; import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, MessageActionRowComponentBuilder, StringSelectMenuBuilder } from 'discord.js'; import { ArgumentOracle, CustomIDOracle } from '../CustomIDOracle'; import { Screen } from '../core/BaseClasses'; -import { EndUserError } from '../Errors'; +import { EndUserError, NotFoundEndUserError, NotFoundEndUserInfo } from '../Errors'; import logger from '../logging'; export abstract class PaginationComponent extends Action { @@ -135,6 +135,11 @@ export abstract class ORMModelPaginator extends PaginationComponent { const customId: string = CustomIDOracle.addArgumentsToAction(this.action, this.operation, ...allArgs); const selectMenuOptions = await this.getOptions(interaction, allItems); + + if (!selectMenuOptions || selectMenuOptions.length === 0) { + throw new NotFoundEndUserInfo('No available items found'); + } + const selectMenu = new StringSelectMenuBuilder() .setCustomId(customId) .setPlaceholder(this.placeholder) diff --git a/src/utils/OCVLinkGenerator.ts b/src/utils/OCVLinkGenerator.ts index 2e68583..33631d8 100644 --- a/src/utils/OCVLinkGenerator.ts +++ b/src/utils/OCVLinkGenerator.ts @@ -1,5 +1,5 @@ export class OCVLinkGenerator { - private static BASE_URL = 'https://example.com/vote'; // Replace with actual voting page URL + private static BASE_URL = 'http://localhost:4321/vote' // Replace with actual voting page URL static generateFundingRoundVoteLink(fundingRoundId: number): string { return `${this.BASE_URL}/funding-round?id=${fundingRoundId}&action=vote`; @@ -10,6 +10,6 @@ export class OCVLinkGenerator { } static generateProjectVoteLink(projectId: number, phase: string): string { - return `${this.BASE_URL}/project?id=${projectId}&phase=${phase.toLowerCase()}`; + return `${this.BASE_URL}/${projectId}`; } } \ No newline at end of file From 38da0cc600781ce2618a16e10b645626416207f7 Mon Sep 17 00:00:00 2001 From: Illya Gerasymchuk Date: Sat, 24 Aug 2024 19:07:38 +0200 Subject: [PATCH 03/12] feat: add method to FundingRound model --- src/models/index.ts | 19 +++++++++++++++++++ src/types/index.ts | 5 +++++ 2 files changed, 24 insertions(+) diff --git a/src/models/index.ts b/src/models/index.ts index 6e02600..ce5950d 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -337,6 +337,25 @@ class FundingRound extends Model { + const topic: Topic = await this.getTopic(); + const topicCommittee: TopicCommittee | null = await TopicCommittee.findOne({ + where: { topicId: topic.id } + }); + + if (!topicCommittee) { + return false; + } + + const smeGroupMembership = await SMEGroupMembership.findOne({ + where: { + duid, + smeGroupId: topicCommittee.smeGroupId, + }, + }); + return !!smeGroupMembership; + } } FundingRound.init( diff --git a/src/types/index.ts b/src/types/index.ts index 19f9218..69762fa 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -218,3 +218,8 @@ export type FundingRoundPhase = { startDate: Date; endDate: Date; }; + +export interface TopicCommitteeWithSMEGroup extends TopicCommitteeAttributes { + smeGroupName: string; + } + From 6633e4b6130b07248da8acb099336385dd3e90b3 Mon Sep 17 00:00:00 2001 From: Illya Gerasymchuk Date: Sat, 24 Aug 2024 19:08:24 +0200 Subject: [PATCH 04/12] feat: introduce discordUserId at TrackedInteraction level --- src/core/BaseClasses.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/core/BaseClasses.ts b/src/core/BaseClasses.ts index f5893c2..0aa81bf 100644 --- a/src/core/BaseClasses.ts +++ b/src/core/BaseClasses.ts @@ -24,6 +24,10 @@ export class TrackedInteraction { this.interaction = interaction; } + get discordUserId(): string { + return this.interaction.user.id; + } + public getFromCustomId(name: string): string | undefined { return CustomIDOracle.getNamedArgument(this.customId, name); } From 7d69fc7dedc890b485111e01ff84f3e8f3436831 Mon Sep 17 00:00:00 2001 From: Illya Gerasymchuk Date: Sun, 25 Aug 2024 16:10:18 +0200 Subject: [PATCH 05/12] feat: enhance funding round management and topic logic * Funding Round Management Enhancements: Introduced new actions and components for creating, editing, and managing funding rounds, including setting phases and dates, selecting topics, and forum channels. * Topic Logic Refactoring: Moved topic-related logic to a dedicated TopicLogic class, simplifying the management of topics and their associated SME groups and committees. * Pagination and Validation Improvements: Added pagination components for better user interaction and validation methods to ensure the integrity of funding round dates and phases. * User-Specific Funding Rounds: Implemented logic to fetch funding rounds relevant to the user's SME group memberships. --- .../admin/screens/FundingRoundLogic.ts | 30 +++++++++------- .../screens/ManageFundingRoundsScreen.ts | 36 +++++++++++++++---- .../screens/FundingRoundInitScreen.ts | 5 +-- 3 files changed, 50 insertions(+), 21 deletions(-) diff --git a/src/channels/admin/screens/FundingRoundLogic.ts b/src/channels/admin/screens/FundingRoundLogic.ts index d2b7d8e..59ff720 100644 --- a/src/channels/admin/screens/FundingRoundLogic.ts +++ b/src/channels/admin/screens/FundingRoundLogic.ts @@ -354,22 +354,26 @@ export class FundingRoundLogic { static async getEligibleVotingRounds(interaction: TrackedInteraction): Promise { const duid: string = interaction.discordUserId; - const now = new Date(); - const allFindingRoundsInVoting = await FundingRound.findAll({ - where: { - status: FundingRoundStatus.VOTING, - votingOpenUntil: { - [Op.gte]: now, - }, - }, - }); - const onlyReadyFundingRounds = allFindingRoundsInVoting.filter( value => value.isReady()); - const onlyFundingRoundsWhereUserIsSME = onlyReadyFundingRounds.filter(value => value.isSMEGroupMember(duid)); + const userFundingRounds = await FundingRoundLogic.getFundingRoundsForUser(duid); + + const eligibleFundingRounds = userFundingRounds.filter((fr) => + fr.status === FundingRoundStatus.VOTING && + fr.votingOpenUntil >= now + ); - return onlyFundingRoundsWhereUserIsSME; -} + const readyFundingRounds = await Promise.all( + eligibleFundingRounds.map(async (fr) => ({ + fundingRound: fr, + isReady: await fr.isReady() + })) + ); + + return readyFundingRounds + .filter(({ isReady }) => isReady) + .map(({ fundingRound }) => fundingRound); + } static async hasUserVotedOnFundingRound(userId: string, fundingRoundId: number): Promise { const vote = await FundingRoundApprovalVote.findOne({ diff --git a/src/channels/admin/screens/ManageFundingRoundsScreen.ts b/src/channels/admin/screens/ManageFundingRoundsScreen.ts index 016f10f..fd4905b 100644 --- a/src/channels/admin/screens/ManageFundingRoundsScreen.ts +++ b/src/channels/admin/screens/ManageFundingRoundsScreen.ts @@ -202,7 +202,7 @@ export class CreateOrEditFundingRoundAction extends Action { .setDescription(`Status: ${progress}`) .addFields( { name: 'Topic', value: fundingRound.topicId ? `✅\n${topic.name}` : '❌', inline: true }, - { name: 'Core Information', value: fundingRound.name && fundingRound.description && fundingRound.budget ? `✅\nName: ${fundingRound.name}\nDescription: ${fundingRound.description}\nBudget: ${fundingRound.budget}` : '❌', inline: true }, + { name: 'Core Information', value: fundingRound.name && fundingRound.description && fundingRound.budget &&fundingRound.stakingLedgerEpoch ? `✅\nName: ${fundingRound.name}\nDescription: ${fundingRound.description}\nBudget: ${fundingRound.budget}\Epoch For Voting: ${fundingRound.stakingLedgerEpoch}` : '❌', inline: true }, { name: 'Funding Round Dates', value: fundingRound.startAt && fundingRound.endAt && fundingRound.votingOpenUntil ? this.formatStringForRound(fundingRound) : '❌', inline: true }, { name: 'Consideration Phase', value: considerationPhase ? this.formatStringForPhase(considerationPhase) : '❌', inline: true }, { name: 'Deliberation Phase', value: deliberationPhase ? this.formatStringForPhase(deliberationPhase) : '❌', inline: true }, @@ -464,9 +464,9 @@ export class CoreInformationAction extends Action { parsedTopicId = topicId as string; } - + const customId: string = fundingRoundId ? CustomIDOracle.addArgumentsToAction(this, CoreInformationAction.OPERATIONS.SUBMIT_FORM, CoreInformationAction.INPUT_IDS.TOPIC, parsedTopicId, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, fundingRoundId) : CustomIDOracle.addArgumentsToAction(this, CoreInformationAction.OPERATIONS.SUBMIT_FORM, CoreInformationAction.INPUT_IDS.TOPIC, parsedTopicId); const modal = new ModalBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, CoreInformationAction.OPERATIONS.SUBMIT_FORM, CoreInformationAction.INPUT_IDS.TOPIC, parsedTopicId)) + .setCustomId(customId) .setTitle('Funding Round Core Information'); const nameInput = new TextInputBuilder() @@ -492,7 +492,7 @@ export class CoreInformationAction extends Action { const stakingLedgerEpochInput = new TextInputBuilder() .setCustomId(CoreInformationAction.INPUT_IDS.STAKING_LEDGER_EPOCH) - .setLabel('Staking Ledger Epoch Number') + .setLabel('Staking Ledger Epoch For Voting') .setStyle(TextInputStyle.Short) .setValue(stakingLedgerEpochValue) .setRequired(true); @@ -517,8 +517,32 @@ export class CoreInformationAction extends Action { const budget = parseFloat(modalInteraction.fields.getTextInputValue(CoreInformationAction.INPUT_IDS.BUDGET)); const stakingLedgerEpoch = parseInt(modalInteraction.fields.getTextInputValue(CoreInformationAction.INPUT_IDS.STAKING_LEDGER_EPOCH)); - const fundingRoung: FundingRound = await FundingRoundLogic.newFundingRoundFromCoreInfo(name, description, parseInt(topicId), budget, stakingLedgerEpoch); - interaction.Context.set(ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, fundingRoung.id.toString()); + let fundingRound: FundingRound; + let fundingRoundId: string | undefined; + + try { + fundingRoundId = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID); + } catch (error) { + if (!(error instanceof NotFoundEndUserError)) { + throw error; + } + } + + if (fundingRoundId) { + // Update existing funding round + fundingRound = await FundingRoundLogic.updateFundingRound(parseInt(fundingRoundId), { + name, + description, + topicId: parseInt(topicId), + budget, + stakingLedgerEpoch + }); + } else { + // Create new funding round + fundingRound = await FundingRoundLogic.newFundingRoundFromCoreInfo(name, description, parseInt(topicId), budget, stakingLedgerEpoch); + } + + interaction.Context.set(ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, fundingRound.id.toString()); await (this.screen as ManageFundingRoundsScreen).createFundingRoundAction.handleOperation( interaction, diff --git a/src/channels/funding-round-init/screens/FundingRoundInitScreen.ts b/src/channels/funding-round-init/screens/FundingRoundInitScreen.ts index b729c8d..cc18505 100644 --- a/src/channels/funding-round-init/screens/FundingRoundInitScreen.ts +++ b/src/channels/funding-round-init/screens/FundingRoundInitScreen.ts @@ -164,6 +164,7 @@ export class CreateDraftFundingRoundAction extends Action { private async handleShowTopicSelect(interaction: TrackedInteraction): Promise { const topics = await Topic.findAll({ order: [['name', 'ASC']] }); + // FIXME: add pagination const topicSelect = new StringSelectMenuBuilder() .setCustomId(CustomIDOracle.addArgumentsToAction(this, CreateDraftFundingRoundAction.OPERATIONS.SUBMIT_TOPIC_SELECT)) .setPlaceholder('Select Parent Topic For Funding Round') @@ -440,7 +441,7 @@ export class CreateDraftFundingRoundAction extends Action { export class VoteFundingRoundAction extends PaginationComponent { public static readonly ID = 'voteFundingRound'; - public readonly editFundingRoundPaginator: InVotingFundingRoundPaginator = new InVotingFundingRoundPaginator(this.screen, this, VoteFundingRoundAction.OPERATIONS.SELECT_ROUND, InVotingFundingRoundPaginator.ID); + public readonly inVotingFundingRoundPaginator: InVotingFundingRoundPaginator = new InVotingFundingRoundPaginator(this.screen, this, VoteFundingRoundAction.OPERATIONS.SELECT_ROUND, InVotingFundingRoundPaginator.ID); public static readonly OPERATIONS = { SHOW_ELIGIBLE_ROUNDS: 'showEligibleRounds', @@ -491,7 +492,7 @@ export class VoteFundingRoundAction extends PaginationComponent { } private async handleShowEligibleRounds(interaction: TrackedInteraction): Promise { - await this.editFundingRoundPaginator.handlePagination(interaction); + await this.inVotingFundingRoundPaginator.handlePagination(interaction); } private async handleSelectRound(interaction: TrackedInteraction, successMessage: string | undefined = undefined, errorMesasge: string | undefined = undefined): Promise { From 89003faf43c1f63c0291064da2885c44c68b86d9 Mon Sep 17 00:00:00 2001 From: Illya Gerasymchuk Date: Sun, 25 Aug 2024 16:28:16 +0200 Subject: [PATCH 06/12] feat: update #consider to show funding rounds which the user is an SME group member of * Funding Round Management Enhancements: Introduced new actions and paginators for creating, editing, and managing funding rounds. Added validation for funding round and phase dates. * Topic Logic Improvements: Refactored and moved topic-related logic to a dedicated TopicLogic class. Simplified the ManageTopicLogicScreen by removing inline logic. * Pagination and Interaction Handling: Implemented a generic ORMModelPaginator for handling paginated lists of ORM models. Improved interaction handling for various funding round actions. * UI and Interaction Updates: Updated UI components and interaction flows for better user experience in managing funding rounds and topics. --- .../screens/ConsiderationHomeScreen.ts | 45 ++++++------------- src/components/FundingRoundPaginator.ts | 12 +++++ src/logic/ConsiderationLogic.ts | 4 +- 3 files changed, 27 insertions(+), 34 deletions(-) diff --git a/src/channels/consider/screens/ConsiderationHomeScreen.ts b/src/channels/consider/screens/ConsiderationHomeScreen.ts index 2fd7a71..abb1736 100644 --- a/src/channels/consider/screens/ConsiderationHomeScreen.ts +++ b/src/channels/consider/screens/ConsiderationHomeScreen.ts @@ -1,7 +1,7 @@ // src/channels/consideration/screens/ConsiderationHomeScreen.ts import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, MessageActionRowComponentBuilder, ModalBuilder, StringSelectMenuBuilder, TextChannel, TextInputBuilder, TextInputStyle } from 'discord.js'; -import { PaginationComponent } from '../../../components/PaginationComponent'; +import { ORMModelPaginator, PaginationComponent } from '../../../components/PaginationComponent'; import { Action, Dashboard, Permission, RenderArgs, Screen, TrackedInteraction } from '../../../core/BaseClasses'; import { InteractionProperties } from '../../../core/Interaction'; import { CustomIDOracle } from '../../../CustomIDOracle'; @@ -10,11 +10,12 @@ import { FundingRound, Proposal } from '../../../models'; import { IHomeScreen } from '../../../types/common'; import { CONSIDERATION_CONSTANTS } from '../Constants'; import { EndUserError } from '../../../Errors'; +import { ConsiderationFundingRoundPaginator } from '../../../components/FundingRoundPaginator'; export class ConsiderationHomeScreen extends Screen implements IHomeScreen { public static readonly ID = CONSIDERATION_CONSTANTS.SCREEN_IDS.HOME; - protected permissions: Permission[] = []; // TODO: Implement SME permission check + protected permissions: Permission[] = []; public readonly selectFundingRoundAction: SelectFundingRoundAction; public readonly selectVoteTypeAction: SelectVoteTypeAction; @@ -72,9 +73,17 @@ export class ConsiderationHomeScreen extends Screen implements IHomeScreen { class SelectFundingRoundAction extends Action { public static readonly ID = CONSIDERATION_CONSTANTS.ACTION_IDS.SELECT_FUNDING_ROUND; + private readonly considerationFundingRoundPaginator: ConsiderationFundingRoundPaginator; + + constructor(screen: Screen, actionId: string) { + super(screen, actionId); + this.considerationFundingRoundPaginator = new ConsiderationFundingRoundPaginator(this.screen, this, CONSIDERATION_CONSTANTS.OPERATION_IDS.SELECT_FUNDING_ROUND, ConsiderationFundingRoundPaginator.ID); + } + protected async handleOperation(interaction: TrackedInteraction, operationId: string): Promise { switch (operationId) { case CONSIDERATION_CONSTANTS.OPERATION_IDS.SHOW_FUNDING_ROUNDS: + case PaginationComponent.PAGINATION_ARG: await this.handleShowFundingRounds(interaction); break; case CONSIDERATION_CONSTANTS.OPERATION_IDS.SELECT_FUNDING_ROUND: @@ -86,35 +95,7 @@ class SelectFundingRoundAction extends Action { } private async handleShowFundingRounds(interaction: TrackedInteraction): Promise { - const eligibleFundingRounds = await ConsiderationLogic.getEligibleFundingRounds(interaction.interaction.user.id); - - if (eligibleFundingRounds.length === 0) { - await interaction.respond({ - content: '😊 This functionality is only available for selected subject matter experts. If you believe this is an error, please contact an administrator.', - ephemeral: true - }); - return; - } - - const options = eligibleFundingRounds.map(fr => ({ - label: fr.name, - value: fr.id.toString(), - description: `Budget: ${fr.budget}, Ends: ${fr.endAt.toLocaleDateString()}` - })); - - const selectMenu = new StringSelectMenuBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, CONSIDERATION_CONSTANTS.OPERATION_IDS.SELECT_FUNDING_ROUND)) - .setPlaceholder('Select a Funding Round') - .addOptions(options); - - const embed = new EmbedBuilder() - .setColor('#0099ff') - .setTitle('Select A Funding Round To Consider On') - .setDescription('Welcome, consideration phase voter!\n\nPlease select a Funding Round on which you would like to submit your consideration votes in the dropdown below.'); - - const row = new ActionRowBuilder().addComponents(selectMenu); - - await interaction.respond({ embeds: [embed], components: [row], ephemeral: true }); + await this.considerationFundingRoundPaginator.handlePagination(interaction); } private async handleSelectFundingRound(interaction: TrackedInteraction): Promise { @@ -137,7 +118,7 @@ class SelectFundingRoundAction extends Action { getComponent(): ButtonBuilder { return new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, CONSIDERATION_CONSTANTS.OPERATION_IDS.SHOW_FUNDING_ROUNDS)) + .setCustomId(CustomIDOracle.addArgumentsToAction(this, CONSIDERATION_CONSTANTS.OPERATION_IDS.SHOW_FUNDING_ROUNDS, ORMModelPaginator.BOOLEAN.ARGUMENTS.FORCE_REPLY, ORMModelPaginator.BOOLEAN.TRUE)) .setLabel('Select Funding Round') .setStyle(ButtonStyle.Primary); } diff --git a/src/components/FundingRoundPaginator.ts b/src/components/FundingRoundPaginator.ts index 4fe0501..f55c769 100644 --- a/src/components/FundingRoundPaginator.ts +++ b/src/components/FundingRoundPaginator.ts @@ -1,5 +1,6 @@ import { FundingRoundLogic } from "../channels/admin/screens/FundingRoundLogic"; import { TrackedInteraction } from "../core/BaseClasses"; +import { ConsiderationLogic } from "../logic/ConsiderationLogic"; import { FundingRound } from "../models"; import { ORMModelPaginator, PaginationComponent } from "./PaginationComponent"; @@ -82,4 +83,15 @@ export class InVotingFundingRoundPaginator extends FundingRoundPaginator { public async getItems(interaction: TrackedInteraction): Promise { return await FundingRoundLogic.getEligibleVotingRounds(interaction); } +} + +export class ConsiderationFundingRoundPaginator extends FundingRoundPaginator { + public static readonly ID = 'consFRPag'; + public args: string[] = [] + public title: string = "Select A Funding Round To Consider On"; + + public async getItems(interaction: TrackedInteraction): Promise { + const duid: string = interaction.discordUserId; + return await ConsiderationLogic.getEligibleFundingRounds(duid); + } } \ No newline at end of file diff --git a/src/logic/ConsiderationLogic.ts b/src/logic/ConsiderationLogic.ts index 6a6e1f9..b14efac 100644 --- a/src/logic/ConsiderationLogic.ts +++ b/src/logic/ConsiderationLogic.ts @@ -2,7 +2,7 @@ import { FundingRound, Proposal, SMEConsiderationVoteLog, TopicCommittee, ConsiderationPhase, SMEGroupMembership } from '../models'; import { Op } from 'sequelize'; -import { ProposalStatus } from '../types'; +import { FundingRoundStatus, ProposalStatus } from '../types'; import { EndUserError } from '../Errors'; export class ConsiderationLogic { @@ -18,7 +18,7 @@ export class ConsiderationLogic { return await FundingRound.findAll({ where: { topicId: eligibleTopicIds, - status: 'APPROVED', + status: FundingRoundStatus.APPROVED, startAt: { [Op.lte]: now }, endAt: { [Op.gt]: now }, }, From 190513e3368506f0e4acf204d798a6c7ad3d1de0 Mon Sep 17 00:00:00 2001 From: Illya Gerasymchuk Date: Sun, 25 Aug 2024 16:31:00 +0200 Subject: [PATCH 07/12] feat: deprecate #vote channel --- src/bot.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/bot.ts b/src/bot.ts index 1a854ff..ab731b9 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -92,13 +92,6 @@ client.once('ready', async () => { logger.error('Propose channel not found'); } - // Render initial screen in #vote channel - const voteChannel = guild.channels.cache.find(channel => channel.name === 'vote') as TextChannel | undefined; - if (voteChannel) { - await voteDashboard.homeScreen.renderToTextChannel(voteChannel); - } else { - logger.error('Vote channel not found'); - } // Render initial screen in #deliberate channel const deliberateChannel = guild.channels.cache.find(channel => channel.name === 'deliberate') as TextChannel | undefined; @@ -140,8 +133,8 @@ client.on('interactionCreate', async (interaction: Interaction) => { try { logger.error(error); - const trackedInteratction = new TrackedInteraction(interaction as AnyInteraction); - await DiscordStatus.handleException(trackedInteratction, error); + const trackedInteraction = new TrackedInteraction(interaction as AnyInteraction); + await DiscordStatus.handleException(trackedInteraction, error); } catch (error) { logger.error(`Unrecoverable error: ${error}`); From cec97552c3a9a96bd7034af3b48d81b9169f2017 Mon Sep 17 00:00:00 2001 From: Illya Gerasymchuk Date: Sun, 25 Aug 2024 23:40:21 +0200 Subject: [PATCH 08/12] refactor: error and log improvmenets --- src/CustomIDOracle.ts | 8 ++++---- src/bot.ts | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/CustomIDOracle.ts b/src/CustomIDOracle.ts index e2f2008..606eeda 100644 --- a/src/CustomIDOracle.ts +++ b/src/CustomIDOracle.ts @@ -35,7 +35,7 @@ export class CustomIDOracle { const customId = parts.join(this.SEPARATOR); if (customId.length > this.MAX_LENGTH) { - throw new EndUserError(`CustomId length of ${customId.length} exceeds the maximum allowed value of ${this.MAX_LENGTH} characters.`); + throw new EndUserError(`CustomId length of ${customId.length} exceeds the maximum allowed value of ${this.MAX_LENGTH} characters: ${customId}`); } return customId; @@ -61,7 +61,7 @@ export class CustomIDOracle { const customId = parts.join(this.SEPARATOR); if (customId.length > this.MAX_LENGTH) { - throw new EndUserError(`CustomId length of ${customId.length} exceeds the maximum allowed value of ${this.MAX_LENGTH} characters.`); + throw new EndUserError(`CustomId length of ${customId.length} exceeds the maximum allowed value of ${this.MAX_LENGTH} characters: ${customId}`); } return customId; @@ -142,8 +142,8 @@ export class CustomIDOracle { export class ArgumentOracle { static COMMON_ARGS = { - FUNDING_ROUND_ID: 'fundingRoundId', - PHASE: 'phase', + FUNDING_ROUND_ID: 'frId', + PHASE: 'ph', } static isArgumentEquals(intreaction: TrackedInteraction, argName: string, value: string): boolean { diff --git a/src/bot.ts b/src/bot.ts index ab731b9..bc9303c 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -117,7 +117,7 @@ client.once('ready', async () => { }); client.on('interactionCreate', async (interaction: Interaction) => { - logger.info("Start handling interaction"); + logger.info(`Start handling interaction: ${interaction.isMessageComponent() ? interaction.customId : 'N/A'}`); try { if (!interaction.isButton() && !interaction.isStringSelectMenu() && !interaction.isModalSubmit() && !interaction.isMessageComponent()) { @@ -145,4 +145,4 @@ client.on('interactionCreate', async (interaction: Interaction) => { }); -client.login(process.env.DISCORD_TOKEN); \ No newline at end of file +client.login(process.env.DISCORD_TOKEN); From 5fba4b159db091d39a17bb4dd07346a17a83eceb Mon Sep 17 00:00:00 2001 From: Illya Gerasymchuk Date: Sun, 25 Aug 2024 23:41:57 +0200 Subject: [PATCH 09/12] feat: implement dashboard routing by channelId via introduction of fallback + shorten customId names --- .../screens/ManageFundingRoundsScreen.ts | 4 +- .../screens/ManageProposalStatusesScreen.ts | 12 ++--- src/channels/consider/Constants.ts | 4 +- .../CommitteeDeliberationHomeScreen.ts | 12 ++--- .../proposals/ProposalsForumManager.ts | 12 ++++- .../propose/screens/ProposalHomeScreen.ts | 31 ++++++----- src/channels/vote/VoteDashboard.ts | 26 +++++++++- .../screens/FundingRoundSelectionScreen.ts | 2 +- .../vote/screens/FundingRoundVotingScreen.ts | 6 +-- .../vote/screens/ProjectVotingScreen.ts | 52 +++++++++---------- src/core/BaseClasses.ts | 10 ++++ src/core/DashboardManager.ts | 24 ++++++--- 12 files changed, 124 insertions(+), 71 deletions(-) diff --git a/src/channels/admin/screens/ManageFundingRoundsScreen.ts b/src/channels/admin/screens/ManageFundingRoundsScreen.ts index fd4905b..483e943 100644 --- a/src/channels/admin/screens/ManageFundingRoundsScreen.ts +++ b/src/channels/admin/screens/ManageFundingRoundsScreen.ts @@ -186,7 +186,7 @@ export class CreateOrEditFundingRoundAction extends Action { } private async handleShowProgress(interaction: TrackedInteraction): Promise { - const fundingRoundId = ArgumentOracle.getNamedArgument(interaction, 'fundingRoundId', 0); + const fundingRoundId = ArgumentOracle.getNamedArgument(interaction, 'frId', 0); const fundingRound = await FundingRoundLogic.getFundingRoundByIdOrError(parseInt(fundingRoundId)); const topic: Topic = await fundingRound.getTopic(); const onlyShowPhases: boolean = ArgumentOracle.isArgumentEquals(interaction, CreateOrEditFundingRoundAction.BOOLEANS.ARGUMENTS.ONLY_SHOW_PHASES, CreateOrEditFundingRoundAction.BOOLEANS.TRUE_VALUE); @@ -579,7 +579,7 @@ export class SetPhaseAction extends Action { }; public static readonly ARGUMENTS = { - PHASE: 'phase', + PHASE: 'ph', } public static PHASE_OPTIONS = { diff --git a/src/channels/admin/screens/ManageProposalStatusesScreen.ts b/src/channels/admin/screens/ManageProposalStatusesScreen.ts index 2978d2c..087e90c 100644 --- a/src/channels/admin/screens/ManageProposalStatusesScreen.ts +++ b/src/channels/admin/screens/ManageProposalStatusesScreen.ts @@ -151,7 +151,7 @@ export class SelectProposalAction extends PaginationComponent { } protected async getTotalPages(interaction: TrackedInteraction, frId?: string): Promise { - let fundingRoundId = CustomIDOracle.getNamedArgument(interaction.customId, 'fundingRoundId'); + let fundingRoundId = CustomIDOracle.getNamedArgument(interaction.customId, 'frId'); if (frId) { fundingRoundId = frId.toString(); @@ -167,7 +167,7 @@ export class SelectProposalAction extends PaginationComponent { protected async getItemsForPage(interaction: TrackedInteraction, page: number, frId?: string): Promise { - let fundingRoundId = CustomIDOracle.getNamedArgument(interaction.customId, 'fundingRoundId'); + let fundingRoundId = CustomIDOracle.getNamedArgument(interaction.customId, 'frId'); if (frId) { fundingRoundId = frId.toString(); @@ -196,7 +196,7 @@ export class SelectProposalAction extends PaginationComponent { } public async renderHandleShowProposals(interaction: TrackedInteraction, frId?: string): Promise { - let fundingRoundId: string | undefined = CustomIDOracle.getNamedArgument(interaction.customId, 'fundingRoundId'); + let fundingRoundId: string | undefined = CustomIDOracle.getNamedArgument(interaction.customId, 'frId'); if (frId) { fundingRoundId = frId; } @@ -216,7 +216,7 @@ export class SelectProposalAction extends PaginationComponent { } const selectMenu = new StringSelectMenuBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, 'selectProposal', 'fundingRoundId', fundingRoundId)) + .setCustomId(CustomIDOracle.addArgumentsToAction(this, 'selectProposal', 'frId', fundingRoundId)) .setPlaceholder('Select a Proposal') .addOptions(proposals.map(p => ({ label: p.name, @@ -259,7 +259,7 @@ export class SelectProposalAction extends PaginationComponent { getComponent(fundingRoundId: string): ButtonBuilder { return new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, 'showProposals', 'fundingRoundId', fundingRoundId)) + .setCustomId(CustomIDOracle.addArgumentsToAction(this, 'showProposals', 'frId', fundingRoundId)) .setLabel('Select Proposal') .setStyle(ButtonStyle.Primary); } @@ -371,7 +371,7 @@ export class UpdateProposalStatusAction extends Action { } const backButton = new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(selectPropAction, SelectProposalAction.OPERATIONS.showProposals, 'fundingRoundId', updatedProposal.fundingRoundId.toString())) + .setCustomId(CustomIDOracle.addArgumentsToAction(selectPropAction, SelectProposalAction.OPERATIONS.showProposals, 'frId', updatedProposal.fundingRoundId.toString())) .setLabel('Update Status Again') .setStyle(ButtonStyle.Primary); diff --git a/src/channels/consider/Constants.ts b/src/channels/consider/Constants.ts index d2f83cc..bbdd4b1 100644 --- a/src/channels/consider/Constants.ts +++ b/src/channels/consider/Constants.ts @@ -13,7 +13,7 @@ export const CONSIDERATION_CONSTANTS = { ACTION_IDS: { SELECT_FUNDING_ROUND: 'selectFundingRound', SELECT_VOTE_TYPE: 'svta', // Select Vote Type Action - SELECT_PROJECT: 'selectProject', + SELECT_PROJECT: 'slPr', SME_CONSIDERATION_VOTE: 'smeConsiderationVote', }, @@ -23,7 +23,7 @@ export const CONSIDERATION_CONSTANTS = { SHOW_VOTE_TYPES: 'showVoteTypes', SELECT_VOTE_TYPE: 'svto', // Select Vote Type Operation SHOW_PROJECTS: 'showProjects', - SELECT_PROJECT: 'selectProject', + SELECT_PROJECT: 'slPr', SHOW_VOTE_OPTIONS: 'showVoteOptions', SUBMIT_VOTE: 'submitVote', CONFIRM_VOTE: 'confirmVote', diff --git a/src/channels/deliberate/CommitteeDeliberationHomeScreen.ts b/src/channels/deliberate/CommitteeDeliberationHomeScreen.ts index 3bb5261..6c25136 100644 --- a/src/channels/deliberate/CommitteeDeliberationHomeScreen.ts +++ b/src/channels/deliberate/CommitteeDeliberationHomeScreen.ts @@ -487,17 +487,17 @@ class CommitteeDeliberationVoteAction extends Action { } const approveButton = new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, OPERATION_IDS.SUBMIT_VOTE, 'projectId', projectId.toString(), FUNDING_ROUND_ID, fundingRoundId.toString(), 'vote', VOTING_OPTION_IDS.APPROVE)) + .setCustomId(CustomIDOracle.addArgumentsToAction(this, OPERATION_IDS.SUBMIT_VOTE, 'prId', projectId.toString(), FUNDING_ROUND_ID, fundingRoundId.toString(), 'vote', VOTING_OPTION_IDS.APPROVE)) .setLabel(existingVote ? 'Change to Approve' : 'Approve Project') .setStyle(ButtonStyle.Success); const approveModifiedButton = new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, OPERATION_IDS.SUBMIT_VOTE, 'projectId', projectId.toString(), FUNDING_ROUND_ID, fundingRoundId.toString(), 'vote', VOTING_OPTION_IDS.APPROVE_MODIFIED)) + .setCustomId(CustomIDOracle.addArgumentsToAction(this, OPERATION_IDS.SUBMIT_VOTE, 'prId', projectId.toString(), FUNDING_ROUND_ID, fundingRoundId.toString(), 'vote', VOTING_OPTION_IDS.APPROVE_MODIFIED)) .setLabel(existingVote ? 'Change to Approve Modified' : 'Approve With Modifications') .setStyle(ButtonStyle.Success); const rejectButton = new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, OPERATION_IDS.SUBMIT_VOTE, 'projectId', projectId.toString(), FUNDING_ROUND_ID, fundingRoundId.toString(), 'vote', VOTING_OPTION_IDS.REJECT)) + .setCustomId(CustomIDOracle.addArgumentsToAction(this, OPERATION_IDS.SUBMIT_VOTE, 'prId', projectId.toString(), FUNDING_ROUND_ID, fundingRoundId.toString(), 'vote', VOTING_OPTION_IDS.REJECT)) .setLabel(existingVote ? 'Change to Reject' : 'Reject Project') .setStyle(ButtonStyle.Danger); @@ -519,7 +519,7 @@ class CommitteeDeliberationVoteAction extends Action { } private async handleSubmitVote(interaction: TrackedInteraction): Promise { - const projectIdRaw: string | undefined = CustomIDOracle.getNamedArgument(interaction.customId, 'projectId'); + const projectIdRaw: string | undefined = CustomIDOracle.getNamedArgument(interaction.customId, 'prId'); if (!projectIdRaw) { throw new EndUserError('projectId not included in the customId'); } @@ -554,7 +554,7 @@ class CommitteeDeliberationVoteAction extends Action { } const modal = new ModalBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, OPERATION_IDS.CONFIRM_VOTE, 'projectId', projectId.toString(), FUNDING_ROUND_ID, fundingRoundId.toString(), 'vote', voteRaw)) + .setCustomId(CustomIDOracle.addArgumentsToAction(this, OPERATION_IDS.CONFIRM_VOTE, 'prId', projectId.toString(), FUNDING_ROUND_ID, fundingRoundId.toString(), 'vote', voteRaw)) .setTitle(title); const uriInput = new TextInputBuilder() @@ -593,7 +593,7 @@ class CommitteeDeliberationVoteAction extends Action { throw new EndUserError('Invalid interaction type.'); } - const projectIdRaw: string | undefined = CustomIDOracle.getNamedArgument(interaction.customId, 'projectId'); + const projectIdRaw: string | undefined = CustomIDOracle.getNamedArgument(interaction.customId, 'prId'); if (!projectIdRaw) { throw new EndUserError('projectId not included in the customId'); } diff --git a/src/channels/proposals/ProposalsForumManager.ts b/src/channels/proposals/ProposalsForumManager.ts index 5e4e859..67208bd 100644 --- a/src/channels/proposals/ProposalsForumManager.ts +++ b/src/channels/proposals/ProposalsForumManager.ts @@ -8,6 +8,7 @@ import { EndUserError, NotFoundEndUserError } from '../../Errors'; import { Screen } from '../../core/BaseClasses'; import { ProposalLogic } from '../../logic/ProposalLogic'; import { ProposalStatus } from '../../types'; +import { FundingRoundLogic } from '../admin/screens/FundingRoundLogic'; export function proposalStatusToPhase(status: ProposalStatus): string { switch (status) { @@ -139,9 +140,18 @@ export class ProposalsForumManager { } public static async createVoteButton(proposalId: number, fundingRoundId: number, screen: any): Promise> { + const fundingRound: FundingRound = await FundingRoundLogic.getFundingRoundByIdOrError(fundingRoundId); + + if (!fundingRound.forumChannelId) { + throw new EndUserError(`Funding round ${fundingRoundId} does not have a forum channel`); + } + + const dashBoardId: string = fundingRound.forumChannelId.toString(); + + const proposal: Proposal = await ProposalLogic.getProposalByIdOrError(proposalId); const proposalPhase: string = proposalStatusToPhase(proposal.status); - const customId: string = CustomIDOracle.customIdFromRawParts(VoteDashboard.ID, ProjectVotingScreen.ID, SelectProjectAction.ID, SelectProjectAction.OPERATIONS.selectProject, "projectId", proposalId.toString(), ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, fundingRoundId.toString(), "phase", proposalPhase); + const customId: string = CustomIDOracle.customIdFromRawParts(dashBoardId, ProjectVotingScreen.ID, SelectProjectAction.ID, SelectProjectAction.OPERATIONS.selectProject, 'prId', proposalId.toString(), ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, fundingRoundId.toString(), 'ph', proposalPhase); const button = new ButtonBuilder() .setCustomId(customId) .setLabel('Vote On This Proposal') diff --git a/src/channels/propose/screens/ProposalHomeScreen.ts b/src/channels/propose/screens/ProposalHomeScreen.ts index 968b09c..355ef55 100644 --- a/src/channels/propose/screens/ProposalHomeScreen.ts +++ b/src/channels/propose/screens/ProposalHomeScreen.ts @@ -198,7 +198,7 @@ export class ManageSubmittedProposalsAction extends PaginationComponent { } const proposalId: number = parseInt(interactionWithValues.values[0]); - const fundingRoundId: number = parseInt(CustomIDOracle.getNamedArgument(interaction.customId, 'fundingRoundId') || ''); + const fundingRoundId: number = parseInt(CustomIDOracle.getNamedArgument(interaction.customId, 'frId') || ''); const proposal: Proposal | null = await ProposalLogic.getProposalById(proposalId); const fundingRound: FundingRound | null = await FundingRoundLogic.getFundingRoundById(fundingRoundId); @@ -216,7 +216,7 @@ export class ManageSubmittedProposalsAction extends PaginationComponent { ); const cancelButton: ButtonBuilder = new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, ManageSubmittedProposalsAction.OPERATIONS.CONFIRM_CANCEL_PROPOSAL, 'proposalId', proposalId.toString(), 'fundingRoundId', fundingRoundId.toString())) + .setCustomId(CustomIDOracle.addArgumentsToAction(this, ManageSubmittedProposalsAction.OPERATIONS.CONFIRM_CANCEL_PROPOSAL, 'proposalId', proposalId.toString(), 'frId', fundingRoundId.toString())) .setLabel('Cancel My Proposal') .setStyle(ButtonStyle.Danger); @@ -231,7 +231,7 @@ export class ManageSubmittedProposalsAction extends PaginationComponent { private async handleConfirmCancelProposal(interaction: TrackedInteraction): Promise { const proposalId: string | undefined = CustomIDOracle.getNamedArgument(interaction.customId, 'proposalId'); - const fundingRoundId: string | undefined = CustomIDOracle.getNamedArgument(interaction.customId, 'fundingRoundId'); + const fundingRoundId: string | undefined = CustomIDOracle.getNamedArgument(interaction.customId, 'frId'); if (!proposalId || !fundingRoundId) { throw new EndUserError('Invalid proposal or funding round ID.'); @@ -255,12 +255,12 @@ export class ManageSubmittedProposalsAction extends PaginationComponent { .setFooter({ text: 'Once cancelled, this proposal cannot be re-submitted to the funding round. You will need to create a new proposal if you want to submit again.' }); const confirmButton: ButtonBuilder = new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, ManageSubmittedProposalsAction.OPERATIONS.EXECUTE_CANCEL_PROPOSAL, 'proposalId', proposalId, 'fundingRoundId', fundingRoundId)) + .setCustomId(CustomIDOracle.addArgumentsToAction(this, ManageSubmittedProposalsAction.OPERATIONS.EXECUTE_CANCEL_PROPOSAL, 'proposalId', proposalId, 'frId', fundingRoundId)) .setLabel('Confirm Cancellation') .setStyle(ButtonStyle.Danger); const cancelButton: ButtonBuilder = new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, ManageSubmittedProposalsAction.OPERATIONS.SHOW_PROPOSAL_DETAILS, 'proposalId', proposalId, 'fundingRoundId', fundingRoundId)) + .setCustomId(CustomIDOracle.addArgumentsToAction(this, ManageSubmittedProposalsAction.OPERATIONS.SHOW_PROPOSAL_DETAILS, 'proposalId', proposalId, 'frId', fundingRoundId)) .setLabel('Go Back') .setStyle(ButtonStyle.Secondary); @@ -271,7 +271,7 @@ export class ManageSubmittedProposalsAction extends PaginationComponent { private async handleExecuteCancelProposal(interaction: TrackedInteraction): Promise { const proposalId: string | undefined = CustomIDOracle.getNamedArgument(interaction.customId, 'proposalId'); - const fundingRoundId: string | undefined = CustomIDOracle.getNamedArgument(interaction.customId, 'fundingRoundId'); + const fundingRoundId: string | undefined = CustomIDOracle.getNamedArgument(interaction.customId, 'frId'); if (!proposalId || !fundingRoundId) { throw new EndUserError('Invalid proposal or funding round ID.'); @@ -286,7 +286,7 @@ export class ManageSubmittedProposalsAction extends PaginationComponent { .setDescription('Your proposal has been cancelled and cannot be re-submitted to this funding round.'); const backButton: ButtonBuilder = new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, ManageSubmittedProposalsAction.OPERATIONS.SHOW_PROPOSALS, 'fundingRoundId', fundingRoundId)) + .setCustomId(CustomIDOracle.addArgumentsToAction(this, ManageSubmittedProposalsAction.OPERATIONS.SHOW_PROPOSALS, 'frId', fundingRoundId)) .setLabel('Back to My Proposals') .setStyle(ButtonStyle.Primary); @@ -804,10 +804,10 @@ export class SubmitProposalToFundingRoundAction extends Action { public static readonly ID = 'submitProposalToFundingRound'; public static readonly OPERATIONS = { - SHOW_DRAFT_PROPOSALS: 'showDraftProposals', - SELECT_FUNDING_ROUND: 'selectFundingRound', - CONFIRM_SUBMISSION: 'confirmSubmission', - EXECUTE_SUBMISSION: 'executeSubmission', + SHOW_DRAFT_PROPOSALS: 'shDrP', + SELECT_FUNDING_ROUND: 'slFr', + CONFIRM_SUBMISSION: 'cnSb', + EXECUTE_SUBMISSION: 'exSb', }; protected async handleOperation(interaction: TrackedInteraction, operationId: string): Promise { @@ -923,7 +923,7 @@ export class SubmitProposalToFundingRoundAction extends Action { .setFooter({ text: 'Note: Once submitted, you cannot remove or reassign this proposal to another funding round. You can cancel your proposal later.' }); const confirmButton = new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, SubmitProposalToFundingRoundAction.OPERATIONS.EXECUTE_SUBMISSION, 'proposalId', proposalId.toString(), 'fundingRoundId', fundingRoundId.toString())) + .setCustomId(CustomIDOracle.addArgumentsToAction(this, SubmitProposalToFundingRoundAction.OPERATIONS.EXECUTE_SUBMISSION, 'proposalId', proposalId.toString(), 'frId', fundingRoundId.toString())) .setLabel('Confirm Submission') .setStyle(ButtonStyle.Success); @@ -939,11 +939,9 @@ export class SubmitProposalToFundingRoundAction extends Action { private async handleExecuteSubmission(interaction: TrackedInteraction): Promise { const proposalId = parseInt(CustomIDOracle.getNamedArgument(interaction.customId, 'proposalId') || ''); - const fundingRoundId = parseInt(CustomIDOracle.getNamedArgument(interaction.customId, 'fundingRoundId') || ''); + const fundingRoundId = parseInt(CustomIDOracle.getNamedArgument(interaction.customId, 'frId') || ''); try { - await ProposalLogic.submitProposalToFundingRound(proposalId, fundingRoundId, this.screen); - const embed = new EmbedBuilder() .setColor('#00FF00') .setTitle('Proposal Submitted Successfully') @@ -956,12 +954,13 @@ export class SubmitProposalToFundingRoundAction extends Action { .setStyle(ButtonStyle.Primary); const row = new ActionRowBuilder().addComponents(manageButton); - await interaction.update({ embeds: [embed], components: [row] }); + await ProposalLogic.submitProposalToFundingRound(proposalId, fundingRoundId, this.screen); } catch (error) { logger.error(error); throw new EndUserError('Failed to submit proposal', error); } + } public allSubActions(): Action[] { diff --git a/src/channels/vote/VoteDashboard.ts b/src/channels/vote/VoteDashboard.ts index 62d4347..a6ef7d0 100644 --- a/src/channels/vote/VoteDashboard.ts +++ b/src/channels/vote/VoteDashboard.ts @@ -1,5 +1,29 @@ -import { Dashboard } from '../../core/BaseClasses'; +import { Dashboard, TrackedInteraction } from '../../core/BaseClasses'; +import { FundingRound } from '../../models'; +import { CustomIDOracle } from '../../CustomIDOracle'; +import { client } from '../../bot'; +import logger from '../../logging'; +import { ChannelType } from 'discord.js'; export class VoteDashboard extends Dashboard { public static readonly ID = 'vote'; + + public async isFallback(interaction: TrackedInteraction): Promise { + logger.info(`Checking if ${interaction.customId} is a fallback`); + const channelId = CustomIDOracle.getDashboardId(interaction.customId); + if (!channelId) return false; + + const channel = client.channels.cache.get(channelId); + if (!channel || channel.type !== ChannelType.GuildForum) { + logger.info(`Forum channel with ID ${channelId} not found in cache or is not a forum channel`); + return false; + } + + const fundingRound = await FundingRound.findOne({ + where: { forumChannelId: channelId } + }); + + logger.info(`FundingRound ${fundingRound ? 'found' : 'not found'} for forum channel with ID ${channelId}`); + return !!fundingRound; + } } \ No newline at end of file diff --git a/src/channels/vote/screens/FundingRoundSelectionScreen.ts b/src/channels/vote/screens/FundingRoundSelectionScreen.ts index 55820e7..b28af95 100644 --- a/src/channels/vote/screens/FundingRoundSelectionScreen.ts +++ b/src/channels/vote/screens/FundingRoundSelectionScreen.ts @@ -143,7 +143,7 @@ export class SelectFundingRoundAction extends PaginationComponent { throw new EndUserError('Funding round not found.'); } - interaction.Context.set('fundingRoundId', fundingRoundId.toString()); + interaction.Context.set('frId', fundingRoundId.toString()); if (fundingRound.status === FundingRoundStatus.VOTING) { await (this.screen as FundingRoundSelectionScreen).voteFundingRoundAction.handleOperation( interaction, diff --git a/src/channels/vote/screens/FundingRoundVotingScreen.ts b/src/channels/vote/screens/FundingRoundVotingScreen.ts index 2569f18..15d7029 100644 --- a/src/channels/vote/screens/FundingRoundVotingScreen.ts +++ b/src/channels/vote/screens/FundingRoundVotingScreen.ts @@ -233,7 +233,7 @@ export class MemberVoteFundingRoundAction extends Action { } private async handleUnvote(interaction: TrackedInteraction): Promise { - const fundingRoundId = parseInt(CustomIDOracle.getNamedArgument(interaction.customId, 'fundingRoundId') || ''); + const fundingRoundId = parseInt(CustomIDOracle.getNamedArgument(interaction.customId, 'frId') || ''); const userId = interaction.interaction.user.id; try { @@ -244,7 +244,7 @@ export class MemberVoteFundingRoundAction extends Action { } private async handleVote(interaction: TrackedInteraction): Promise { - const fundingRoundId = parseInt(CustomIDOracle.getNamedArgument(interaction.customId, 'fundingRoundId') || ''); + const fundingRoundId = parseInt(CustomIDOracle.getNamedArgument(interaction.customId, 'frId') || ''); const userId = interaction.interaction.user.id; try { @@ -261,7 +261,7 @@ export class MemberVoteFundingRoundAction extends Action { getComponent(fundingRoundId: number): ButtonBuilder { return new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, 'showVoteOptions', 'fundingRoundId', fundingRoundId.toString())) + .setCustomId(CustomIDOracle.addArgumentsToAction(this, 'showVoteOptions', 'frId', fundingRoundId.toString())) .setLabel('Vote on Funding Round') .setStyle(ButtonStyle.Primary); } diff --git a/src/channels/vote/screens/ProjectVotingScreen.ts b/src/channels/vote/screens/ProjectVotingScreen.ts index 82bcbc2..e4e823f 100644 --- a/src/channels/vote/screens/ProjectVotingScreen.ts +++ b/src/channels/vote/screens/ProjectVotingScreen.ts @@ -11,14 +11,12 @@ import { InteractionProperties } from '../../../core/Interaction'; import { FundingRoundLogic } from '../../admin/screens/FundingRoundLogic'; import { OCVLinkGenerator } from '../../../utils/OCVLinkGenerator'; import logger from '../../../logging'; -import { DiscordStatus } from '../../DiscordStatus'; import { EndUserError, EndUserInfo } from '../../../Errors'; import { proposalStatusToPhase } from '../../proposals/ProposalsForumManager'; -import { ProposalStatus } from '../../../types'; export class ProjectVotingScreen extends Screen { - public static readonly ID = 'projectVoting'; + public static readonly ID = 'prVt'; protected permissions: Permission[] = []; @@ -46,7 +44,7 @@ export class ProjectVotingScreen extends Screen { } protected async getResponse(interaction: TrackedInteraction, args?: RenderArgs): Promise { - const fundingRoundIdFromContext: string | undefined = interaction?.Context.get('fundingRoundId'); + const fundingRoundIdFromContext: string | undefined = interaction?.Context.get('frId'); if (!fundingRoundIdFromContext) { return { content: 'FundingRoundID not passed in context', @@ -96,8 +94,8 @@ export class ProjectVotingScreen extends Screen { components.push(new ActionRowBuilder().addComponents(selectPhaseButton)); } else if (activePhases.length === 1) { const selectProjectButton = this.selectProjectAction.getComponent(fundingRoundId, activePhases[0]); - interaction.Context.set('phase', activePhases[0]); - interaction.Context.set('fundingRoundId', fundingRoundId.toString()); + interaction.Context.set('ph', activePhases[0]); + interaction.Context.set('frId', fundingRoundId.toString()); const displayData = await this.selectProjectAction.getSelectProjectComponent(interaction, fundingRoundId, activePhases[0]); components = displayData.components; } else { @@ -121,7 +119,7 @@ class SelectPhaseAction extends Action { }; protected async handleOperation(interaction: TrackedInteraction, operationId: string): Promise { - const fundingRoundId = parseInt(CustomIDOracle.getNamedArgument(interaction.customId, 'fundingRoundId') || ''); + const fundingRoundId = parseInt(CustomIDOracle.getNamedArgument(interaction.customId, 'frId') || ''); const activePhases = await FundingRoundLogic.getActiveFundingRoundPhases(fundingRoundId); const options = activePhases.map(phase => ({ @@ -131,7 +129,7 @@ class SelectPhaseAction extends Action { const selectMenu = new StringSelectMenuBuilder() .setCustomId(CustomIDOracle.generateCustomId(this.screen.dashboard, this.screen, (this.screen as ProjectVotingScreen).selectProjectAction, SelectProjectAction.OPERATIONS.showProjects)) - .setCustomId(CustomIDOracle.addArgumentsToAction(this, 'selectPhase', 'fundingRoundId', fundingRoundId.toString())) + .setCustomId(CustomIDOracle.addArgumentsToAction(this, 'selectPhase', 'frId', fundingRoundId.toString())) .setPlaceholder('Select a Voting Phase') .addOptions(options); @@ -146,18 +144,18 @@ class SelectPhaseAction extends Action { getComponent(fundingRoundId: number): ButtonBuilder { return new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, 'selectPhase', 'fundingRoundId', fundingRoundId.toString())) + .setCustomId(CustomIDOracle.addArgumentsToAction(this, 'selectPhase', 'frId', fundingRoundId.toString())) .setLabel('Select Voting Phase') .setStyle(ButtonStyle.Primary); } } export class SelectProjectAction extends PaginationComponent { - public static readonly ID = 'selectProject'; + public static readonly ID = 'slPr'; public static readonly OPERATIONS = { showProjects: 'showProjects', - selectProject: 'selectProject', + selectProject: 'slPr', paginate: 'paginate', }; @@ -222,7 +220,7 @@ export class SelectProjectAction extends PaginationComponent { })); const selectMenu: StringSelectMenuBuilder = new StringSelectMenuBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, SelectProjectAction.OPERATIONS.selectProject, 'fundingRoundId', fundingRoundId.toString(), 'phase', phase)) + .setCustomId(CustomIDOracle.addArgumentsToAction(this, SelectProjectAction.OPERATIONS.selectProject, 'frId', fundingRoundId.toString(), 'ph', phase)) .setPlaceholder('Select a Project to Vote On') .addOptions(options); @@ -242,13 +240,13 @@ export class SelectProjectAction extends PaginationComponent { const totalPages = await this.getTotalPages(interaction); const projects = await this.getItemsForPage(interaction, currentPage); - const fundingRoundIdRaw: string | undefined = CustomIDOracle.getNamedArgument(interaction.customId, 'fundingRoundId'); + const fundingRoundIdRaw: string | undefined = CustomIDOracle.getNamedArgument(interaction.customId, 'frId'); if (!fundingRoundIdRaw) { throw new EndUserError('fundingRoundId not passed in customId'); } const fundingRoundId: number = parseInt(fundingRoundIdRaw); - const phase: string | undefined = CustomIDOracle.getNamedArgument(interaction.customId, 'phase'); + const phase: string | undefined = CustomIDOracle.getNamedArgument(interaction.customId, 'ph'); let parsedPhase: string; if (!phase) { @@ -273,18 +271,18 @@ export class SelectProjectAction extends PaginationComponent { } private async handleSelectProject(interaction: TrackedInteraction): Promise { - const projectId = ArgumentOracle.getNamedArgument(interaction, 'projectId') + const projectId = ArgumentOracle.getNamedArgument(interaction, 'prId') const fundingRoundIdFromCI: string = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID); const fundingRoundId: number = parseInt(fundingRoundIdFromCI); - const phase = ArgumentOracle.getNamedArgument(interaction, 'phase'); + const phase = ArgumentOracle.getNamedArgument(interaction, 'ph'); - interaction.Context.set('projectId', projectId.toString()); - interaction.Context.set('fundingRoundId', fundingRoundId.toString()); - interaction.Context.set('phase', phase); + interaction.Context.set('prId', projectId.toString()); + interaction.Context.set('frId', fundingRoundId.toString()); + interaction.Context.set('ph', phase); await (this.screen as ProjectVotingScreen).voteProjectAction.handleOperation( interaction, 'showVoteOptions', @@ -298,7 +296,7 @@ export class SelectProjectAction extends PaginationComponent { getComponent(fundingRoundId: number, phase: string): ButtonBuilder { return new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, SelectProjectAction.OPERATIONS.showProjects, 'fundingRoundId', fundingRoundId.toString(), 'phase', phase)) + .setCustomId(CustomIDOracle.addArgumentsToAction(this, SelectProjectAction.OPERATIONS.showProjects, 'frId', fundingRoundId.toString(), 'ph', phase)) .setLabel('Select Project') .setStyle(ButtonStyle.Primary); } @@ -427,7 +425,7 @@ class VoteProjectAction extends Action { break; case 'deliberation': const deliberationButton = new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, VoteProjectAction.OPERATIONS.submitDeliberationReasoning, 'projectId', projectId.toString(), 'fundingRoundId', fundingRoundId.toString())) + .setCustomId(CustomIDOracle.addArgumentsToAction(this, VoteProjectAction.OPERATIONS.submitDeliberationReasoning, 'prId', projectId.toString(), 'frId', fundingRoundId.toString())) .setLabel(gptResponseButtonLabel) .setStyle(ButtonStyle.Primary); components.push(new ActionRowBuilder().addComponents(deliberationButton)); @@ -438,8 +436,8 @@ class VoteProjectAction extends Action { } private async handleSubmitDeliberationReasoning(interaction: TrackedInteraction): Promise { - const projectIdRaw = CustomIDOracle.getNamedArgument(interaction.customId, 'projectId'); - const fundingRoundIdRaw = CustomIDOracle.getNamedArgument(interaction.customId, 'fundingRoundId'); + const projectIdRaw = CustomIDOracle.getNamedArgument(interaction.customId, 'prId'); + const fundingRoundIdRaw = CustomIDOracle.getNamedArgument(interaction.customId, 'frId'); if (!projectIdRaw) { throw new EndUserError('projectId not passed in customId'); @@ -457,7 +455,7 @@ class VoteProjectAction extends Action { const title: string = hasUserSubmittedReasoning ? '✏️ Update Reasoning' : '✍️ Submit Reasoning'; const modal = new ModalBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, VoteProjectAction.OPERATIONS.submitReasoningModal, 'projectId', projectId.toString(), 'fundingRoundId', fundingRoundId.toString())) + .setCustomId(CustomIDOracle.addArgumentsToAction(this, VoteProjectAction.OPERATIONS.submitReasoningModal, 'prId', projectId.toString(), 'frId', fundingRoundId.toString())) .setTitle(title); const reasoningInput = new TextInputBuilder() @@ -493,8 +491,8 @@ class VoteProjectAction extends Action { throw new EndUserError('Invalid interaction type.'); } - const projectIdRaw = CustomIDOracle.getNamedArgument(interaction.customId, 'projectId'); - const fundingRoundIdRaw = CustomIDOracle.getNamedArgument(interaction.customId, 'fundingRoundId'); + const projectIdRaw = CustomIDOracle.getNamedArgument(interaction.customId, 'prId'); + const fundingRoundIdRaw = CustomIDOracle.getNamedArgument(interaction.customId, 'frId'); if (!projectIdRaw) { throw new EndUserError('projectId not passed in customId'); @@ -542,7 +540,7 @@ class VoteProjectAction extends Action { getComponent(projectId: number, fundingRoundId: number, phase: string): ButtonBuilder { return new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, 'showVoteOptions', 'projectId', projectId.toString(), 'fundingRoundId', fundingRoundId.toString(), 'phase', phase)) + .setCustomId(CustomIDOracle.addArgumentsToAction(this, 'showVoteOptions', 'prId', projectId.toString(), 'frId', fundingRoundId.toString(), 'ph', phase)) .setLabel('Vote on Project') .setStyle(ButtonStyle.Primary); } diff --git a/src/core/BaseClasses.ts b/src/core/BaseClasses.ts index 0aa81bf..1de0e4c 100644 --- a/src/core/BaseClasses.ts +++ b/src/core/BaseClasses.ts @@ -360,6 +360,16 @@ export abstract class Dashboard { } } + /** + * If no matching dashboards are round, .isFallBack() will be called on them one-by-one, until one of them returns true. + * This dashboard will then be used to handle the interaction. + * + * Default implementation returns false, but you can override it in the subclass. + */ + public async isFallback(interaction: TrackedInteraction): Promise { + return false; + } + /** * Returns the fully qualified custom_id for the dashboard. */ diff --git a/src/core/DashboardManager.ts b/src/core/DashboardManager.ts index 6669c4d..507ee0d 100644 --- a/src/core/DashboardManager.ts +++ b/src/core/DashboardManager.ts @@ -13,11 +13,11 @@ export class DashboardManager { } async handleInteraction(interaction: AnyInteraction): Promise { - const trackedInteratction: TrackedInteraction = new TrackedInteraction(interaction); + const trackedInteraction: TrackedInteraction = new TrackedInteraction(interaction); const channelName = this.isNamedChannelInteraction(interaction) ? interaction.channel.name : undefined; if (!channelName) { - await DiscordStatus.Error.error(trackedInteratction, 'No channel name found in interaction'); + await DiscordStatus.Error.error(trackedInteraction, 'No channel name found in interaction'); throw new EndUserError('No channel name found in interaction'); } @@ -32,13 +32,25 @@ export class DashboardManager { } } - const dashboard = this.dashboards.get(parentMostChannelName); + let dashboard = this.dashboards.get(parentMostChannelName); + + if (!dashboard) { + // Iterate over all dashboards to find a fallback + for (const [_, fallbackDashboard] of this.dashboards) { + if (await fallbackDashboard.isFallback(trackedInteraction)) { + logger.info(`Using fallback dashboard for channel '${parentMostChannelName}'`); + dashboard = fallbackDashboard; + break; + } + } + } + + // If no fallback dashboard is found, throw an error if (!dashboard) { - await DiscordStatus.Error.error(trackedInteratction, `No dashboard found for channel '${parentMostChannelName}'`); - throw new EndUserError(`No dashboard found for channel '${parentMostChannelName}'`); + throw new EndUserError(`No dashboard or fallback found for channel '${parentMostChannelName}'`); } - await dashboard.handleInteraction(trackedInteratction) + await dashboard.handleInteraction(trackedInteraction) } private isNamedChannelInteraction(interaction: AnyInteraction): interaction is AnyNamedChannelInteraction { From e427ea4c68b71c2ae1e17c10294a48d0f005dd19 Mon Sep 17 00:00:00 2001 From: Illya Gerasymchuk Date: Mon, 26 Aug 2024 01:03:23 +0200 Subject: [PATCH 10/12] feat: correct dynamic/fallback routing for voting, add standardized paginations --- src/CustomIDOracle.ts | 13 + .../screens/ManageProposalStatusesScreen.ts | 158 +-- .../vote/screens/ProjectVotingScreen.ts | 918 +++++++++--------- src/components/FundingRoundPaginator.ts | 11 + src/components/ProposalsPaginator.ts | 19 + src/logic/ProposalLogic.ts | 165 +++- 6 files changed, 679 insertions(+), 605 deletions(-) diff --git a/src/CustomIDOracle.ts b/src/CustomIDOracle.ts index 606eeda..14c9342 100644 --- a/src/CustomIDOracle.ts +++ b/src/CustomIDOracle.ts @@ -82,6 +82,19 @@ export class CustomIDOracle { return outputCustomId; } + static addArgumentsToActionCustomDashboardId(dashboardId: string, action: Action, operation?: string, ...args: string[]): string { + if (args.length % 2 !== 0) { + throw new EndUserError('Arguments must be key-value pairs'); + } + const customId = this.customIdFromRawParts(dashboardId, action.screen.ID, action.ID, operation, ...args); + + if (customId.length > this.MAX_LENGTH) { + throw new EndUserError(`Custom ID exceeds maximum length of ${this.MAX_LENGTH} characters by ${customId.length - this.MAX_LENGTH} characters`); + } + + return customId; + } + static getNamedArgument(customId: string, argName: string): string | undefined { const args = this.getArguments(customId); for (let i = 0; i < args.length; i += 2) { diff --git a/src/channels/admin/screens/ManageProposalStatusesScreen.ts b/src/channels/admin/screens/ManageProposalStatusesScreen.ts index 087e90c..4709a83 100644 --- a/src/channels/admin/screens/ManageProposalStatusesScreen.ts +++ b/src/channels/admin/screens/ManageProposalStatusesScreen.ts @@ -3,16 +3,17 @@ import { Screen, Action, Dashboard, Permission, TrackedInteraction } from '../../../core/BaseClasses'; import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, InteractionWebhook, StringSelectMenuBuilder } from 'discord.js'; import { CustomIDOracle } from '../../../CustomIDOracle'; -import { FundingRoundLogic } from './FundingRoundLogic'; import { AdminProposalLogic } from '../../../logic/AdminProposalLogic'; import { PaginationComponent } from '../../../components/PaginationComponent'; import { DiscordStatus } from '../../DiscordStatus'; -import { FundingRound, Proposal } from '../../../models'; +import { Proposal } from '../../../models'; import { AnyInteractionWithValues } from '../../../types/common'; import { InteractionProperties } from '../../../core/Interaction'; import { ProposalStatus } from '../../../types'; -import { errorMonitor } from 'events'; import { EndUserError } from '../../../Errors'; +import { ActiveFundingRoundPaginator } from '../../../components/FundingRoundPaginator'; +import { ManageProposalStatusesPaginator } from '../../../components/ProposalsPaginator'; +import { ArgumentOracle } from '../../../CustomIDOracle'; export class ManageProposalStatusesScreen extends Screen { public static readonly ID = 'manageProposalStatuses'; @@ -25,8 +26,8 @@ export class ManageProposalStatusesScreen extends Screen { constructor(dashboard: Dashboard, screenId: string) { super(dashboard, screenId); - this.selectFundingRoundAction = new SelectFundingRoundAction(this, SelectFundingRoundAction.ID); - this.selectProposalAction = new SelectProposalAction(this, SelectProposalAction.ID); + this.selectFundingRoundAction = new SelectFundingRoundAction(this); + this.selectProposalAction = new SelectProposalAction(this); this.updateProposalStatusAction = new UpdateProposalStatusAction(this, UpdateProposalStatusAction.ID); } @@ -60,23 +61,25 @@ export class ManageProposalStatusesScreen extends Screen { } } - -export class SelectFundingRoundAction extends PaginationComponent { +export class SelectFundingRoundAction extends Action { public static readonly ID = 'selectFundingRound'; - protected async getTotalPages(): Promise { - const fundingRounds = await FundingRoundLogic.getActiveFundingRounds(); - return Math.ceil(fundingRounds.length / 25); - } + private activeFundingRoundPaginator: ActiveFundingRoundPaginator; - protected async getItemsForPage(interaction: TrackedInteraction, page: number): Promise { - const fundingRounds = await FundingRoundLogic.getActiveFundingRounds(); - return fundingRounds.slice(page * 25, (page + 1) * 25); + constructor(screen: ManageProposalStatusesScreen) { + super(screen, SelectFundingRoundAction.ID); + this.activeFundingRoundPaginator = new ActiveFundingRoundPaginator( + this.screen, + this, + 'selectFundingRound', + ActiveFundingRoundPaginator.ID + ); } protected async handleOperation(interaction: TrackedInteraction, operationId: string): Promise { switch (operationId) { case 'showFundingRounds': + case PaginationComponent.PAGINATION_ARG: await this.handleShowFundingRounds(interaction); break; case 'selectFundingRound': @@ -88,33 +91,7 @@ export class SelectFundingRoundAction extends PaginationComponent { } private async handleShowFundingRounds(interaction: TrackedInteraction): Promise { - const currentPage = this.getCurrentPage(interaction); - const totalPages = await this.getTotalPages(); - const fundingRounds = await this.getItemsForPage(interaction, currentPage); - - if (fundingRounds.length === 0) { - await DiscordStatus.Info.info(interaction, 'There are no active funding rounds.'); - return; - } - - const selectMenu = new StringSelectMenuBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, 'selectFundingRound')) - .setPlaceholder('Select a Funding Round') - .addOptions(fundingRounds.map(fr => ({ - label: fr.name, - value: fr.id.toString(), - description: `Status: ${fr.status}, Budget: ${fr.budget}` - }))); - - const row = new ActionRowBuilder().addComponents(selectMenu); - const components: ActionRowBuilder[] = [row]; - - if (totalPages > 1) { - const paginationRow = this.getPaginationRow(interaction, currentPage, totalPages); - components.push(paginationRow); - } - - await interaction.update({ components }); + await this.activeFundingRoundPaginator.handlePagination(interaction); } private async handleSelectFundingRound(interaction: TrackedInteraction): Promise { @@ -142,52 +119,34 @@ export class SelectFundingRoundAction extends PaginationComponent { } } -export class SelectProposalAction extends PaginationComponent { +export class SelectProposalAction extends Action { public static readonly ID = 'selectProposal'; + public static readonly OPERATIONS = { showProposals: 'showProposals', selectProposal: 'selectProposal' } - protected async getTotalPages(interaction: TrackedInteraction, frId?: string): Promise { - let fundingRoundId = CustomIDOracle.getNamedArgument(interaction.customId, 'frId'); - - if (frId) { - fundingRoundId = frId.toString(); - } - - if (!fundingRoundId) { - await DiscordStatus.Error.error(interaction, 'Funding Round ID not found in customId'); - throw new EndUserError('Funding Round ID not found in customId'); - } - const proposals = await AdminProposalLogic.getProposalsForFundingRound(parseInt(fundingRoundId)); - return Math.ceil(proposals.length / 25); - } - - protected async getItemsForPage(interaction: TrackedInteraction, page: number, frId?: string): Promise { - - let fundingRoundId = CustomIDOracle.getNamedArgument(interaction.customId, 'frId'); - - if (frId) { - fundingRoundId = frId.toString(); - } - - if (!fundingRoundId) { - await DiscordStatus.Error.error(interaction, 'Funding Round ID not found in customId'); - throw new EndUserError('Funding Round ID not found in customId'); - } + private manageProposalStatusesPaginator: ManageProposalStatusesPaginator; - const proposals = await AdminProposalLogic.getProposalsForFundingRound(parseInt(fundingRoundId)); - return proposals.slice(page * 25, (page + 1) * 25); + constructor(screen: ManageProposalStatusesScreen) { + super(screen, SelectProposalAction.ID); + this.manageProposalStatusesPaginator = new ManageProposalStatusesPaginator( + this.screen, + this, + 'selectProposal', + ManageProposalStatusesPaginator.ID + ); } protected async handleOperation(interaction: TrackedInteraction, operationId: string): Promise { switch (operationId) { - case SelectProposalAction.OPERATIONS.showProposals: + case 'showProposals': + case PaginationComponent.PAGINATION_ARG: await this.handleShowProposals(interaction); break; - case SelectProposalAction.OPERATIONS.selectProposal: + case 'selectProposal': await this.handleSelectProposal(interaction); break; default: @@ -195,56 +154,27 @@ export class SelectProposalAction extends PaginationComponent { } } - public async renderHandleShowProposals(interaction: TrackedInteraction, frId?: string): Promise { - let fundingRoundId: string | undefined = CustomIDOracle.getNamedArgument(interaction.customId, 'frId'); - if (frId) { - fundingRoundId = frId; - } - - if (!fundingRoundId) { - await DiscordStatus.Error.error(interaction, 'Funding Round ID not found in customId or arg'); - throw new EndUserError(`Funding Round ID not found in customId or context or arg`); - } - - const currentPage = this.getCurrentPage(interaction); - const totalPages = await this.getTotalPages(interaction, frId); - const proposals = await this.getItemsForPage(interaction, currentPage, frId); - if (proposals.length === 0) { - await DiscordStatus.Info.info(interaction, 'There are no proposals for this funding round.'); - return; - } - - const selectMenu = new StringSelectMenuBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, 'selectProposal', 'frId', fundingRoundId)) - .setPlaceholder('Select a Proposal') - .addOptions(proposals.map(p => ({ - label: p.name, - value: p.id.toString(), - description: `Status: ${p.status}, Budget: ${p.budget}` - }))); + private async handleShowProposals(interaction: TrackedInteraction): Promise { + return await this.renderHandleShowProposals(interaction); + } - const row = new ActionRowBuilder().addComponents(selectMenu); - const components: ActionRowBuilder[] = [row]; + public async renderHandleShowProposals(interaction: TrackedInteraction, frId?: string): Promise { + const fundingRoundId = frId || CustomIDOracle.getNamedArgument(interaction.customId, 'frId'); - if (totalPages > 1) { - const paginationRow = this.getPaginationRow(interaction, currentPage, totalPages); - components.push(paginationRow); + if (!fundingRoundId) { + await DiscordStatus.Error.error(interaction, 'Funding Round ID not found'); + throw new EndUserError('Funding Round ID not found'); } - await interaction.update({ components }); - } - - private async handleShowProposals(interaction: TrackedInteraction): Promise { - return await this.renderHandleShowProposals(interaction); + interaction.Context.set(ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, fundingRoundId); + await this.manageProposalStatusesPaginator.handlePagination(interaction); } private async handleSelectProposal(interaction: TrackedInteraction): Promise { - - const parsedInteraction: AnyInteractionWithValues | undefined = InteractionProperties.toInteractionWithValuesOrUndefined(interaction.interaction); + const parsedInteraction = InteractionProperties.toInteractionWithValuesOrUndefined(interaction.interaction); if (!parsedInteraction) { - await DiscordStatus.Error.error(interaction, 'Interaction does not have values'); - throw new EndUserError('Interaction does not have values'); + throw new EndUserError('Invalid interaction type.'); } const proposalId = parsedInteraction.values[0]; diff --git a/src/channels/vote/screens/ProjectVotingScreen.ts b/src/channels/vote/screens/ProjectVotingScreen.ts index e4e823f..2b7db71 100644 --- a/src/channels/vote/screens/ProjectVotingScreen.ts +++ b/src/channels/vote/screens/ProjectVotingScreen.ts @@ -1,11 +1,21 @@ // src/channels/vote/screens/ProjectVotingScreen.ts import { Screen, Action, Dashboard, Permission, TrackedInteraction, RenderArgs } from '../../../core/BaseClasses'; -import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, MessageActionRowComponentBuilder, StringSelectMenuBuilder, ModalBuilder, TextInputBuilder, TextInputStyle } from 'discord.js'; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + MessageActionRowComponentBuilder, + StringSelectMenuBuilder, + ModalBuilder, + TextInputBuilder, + TextInputStyle, +} from 'discord.js'; import { ArgumentOracle, CustomIDOracle } from '../../../CustomIDOracle'; import { ProposalLogic } from '../../../logic/ProposalLogic'; import { VoteLogic } from '../../../logic/VoteLogic'; -import { GPTSummarizerVoteLog, Proposal } from '../../../models'; +import { FundingRound, GPTSummarizerVoteLog, Proposal } from '../../../models'; import { PaginationComponent } from '../../../components/PaginationComponent'; import { InteractionProperties } from '../../../core/Interaction'; import { FundingRoundLogic } from '../../admin/screens/FundingRoundLogic'; @@ -14,348 +24,351 @@ import logger from '../../../logging'; import { EndUserError, EndUserInfo } from '../../../Errors'; import { proposalStatusToPhase } from '../../proposals/ProposalsForumManager'; - export class ProjectVotingScreen extends Screen { - public static readonly ID = 'prVt'; - - protected permissions: Permission[] = []; - - public readonly selectPhaseAction: SelectPhaseAction; - public readonly selectProjectAction: SelectProjectAction; - public readonly voteProjectAction: VoteProjectAction; - - constructor(dashboard: Dashboard, screenId: string) { - super(dashboard, screenId); - this.selectPhaseAction = new SelectPhaseAction(this, SelectPhaseAction.ID); - this.selectProjectAction = new SelectProjectAction(this, SelectProjectAction.ID); - this.voteProjectAction = new VoteProjectAction(this, VoteProjectAction.ID); + public static readonly ID = 'prVt'; + + protected permissions: Permission[] = []; + + public readonly selectPhaseAction: SelectPhaseAction; + public readonly selectProjectAction: SelectProjectAction; + public readonly voteProjectAction: VoteProjectAction; + + constructor(dashboard: Dashboard, screenId: string) { + super(dashboard, screenId); + this.selectPhaseAction = new SelectPhaseAction(this, SelectPhaseAction.ID); + this.selectProjectAction = new SelectProjectAction(this, SelectProjectAction.ID); + this.voteProjectAction = new VoteProjectAction(this, VoteProjectAction.ID); + } + + protected allSubScreens(): Screen[] { + return []; + } + + protected allActions(): Action[] { + return [this.selectPhaseAction, this.selectProjectAction, this.voteProjectAction]; + } + + protected async getResponse(interaction: TrackedInteraction, args?: RenderArgs): Promise { + const fundingRoundIdFromContext: string | undefined = interaction?.Context.get('frId'); + if (!fundingRoundIdFromContext) { + return { + content: 'FundingRoundID not passed in context', + ephemeral: true, + }; } - protected allSubScreens(): Screen[] { - return []; - } + const fundingRoundId = parseInt(fundingRoundIdFromContext); - protected allActions(): Action[] { - return [ - this.selectPhaseAction, - this.selectProjectAction, - this.voteProjectAction, - ]; + if (!fundingRoundId) { + return { + content: 'Invalid funding round ID.', + ephemeral: true, + }; } - protected async getResponse(interaction: TrackedInteraction, args?: RenderArgs): Promise { - const fundingRoundIdFromContext: string | undefined = interaction?.Context.get('frId'); - if (!fundingRoundIdFromContext) { - return { - content: 'FundingRoundID not passed in context', - ephemeral: true - }; - } - - const fundingRoundId = parseInt(fundingRoundIdFromContext); - - if (!fundingRoundId) { - return { - content: 'Invalid funding round ID.', - ephemeral: true - }; - } - - const fundingRound = await FundingRoundLogic.getFundingRoundById(fundingRoundId); - if (!fundingRound) { - return { - content: 'Funding round not found.', - ephemeral: true - }; - } - - const activePhases = await FundingRoundLogic.getActiveFundingRoundPhases(fundingRoundId); - const shouldSelectPhase = activePhases.length > 1; - - let description: string = `In this section you can cast your votes on approval or rejections of projects in ${fundingRound.name}.`; - if (shouldSelectPhase) { - description += 'Since there is a more than one phase to vote on, you will need to select the phase first. '; - } else { - description += 'Begin by selecting a project to vote '; - } - - const embed = new EmbedBuilder() - .setColor('#0099ff') - .setTitle(`Vote on Projects In ${fundingRound.name}`) - .setDescription(description); - - let components: ActionRowBuilder[] = []; - - if (activePhases.length > 1) { - // TODO: This wasn't tested. Also, will this ever happen? - // NOTE for reader: this branch handles the display of phase to vote on, if there is more than one active phase - const selectPhaseButton = this.selectPhaseAction.getComponent(fundingRoundId); - const displayData = this.selectProjectAction.getSelectProjectComponent(interaction, fundingRoundId, activePhases[0]); - components.push(new ActionRowBuilder().addComponents(selectPhaseButton)); - } else if (activePhases.length === 1) { - const selectProjectButton = this.selectProjectAction.getComponent(fundingRoundId, activePhases[0]); - interaction.Context.set('ph', activePhases[0]); - interaction.Context.set('frId', fundingRoundId.toString()); - const displayData = await this.selectProjectAction.getSelectProjectComponent(interaction, fundingRoundId, activePhases[0]); - components = displayData.components; - } else { - embed.setDescription('There are no active voting phases for this funding round at the moment.'); - } - - return { - embeds: [embed], - components, - ephemeral: true - }; + const fundingRound = await FundingRoundLogic.getFundingRoundById(fundingRoundId); + if (!fundingRound) { + return { + content: 'Funding round not found.', + ephemeral: true, + }; } -} - -class SelectPhaseAction extends Action { - public static readonly ID = 'selectPhase'; - - public static readonly OPERATIONS = { - showPhases: 'showPhases', - selectPhase: 'selectPhase', - }; - - protected async handleOperation(interaction: TrackedInteraction, operationId: string): Promise { - const fundingRoundId = parseInt(CustomIDOracle.getNamedArgument(interaction.customId, 'frId') || ''); - const activePhases = await FundingRoundLogic.getActiveFundingRoundPhases(fundingRoundId); - const options = activePhases.map(phase => ({ - label: phase.charAt(0).toUpperCase() + phase.slice(1), - value: phase, - })); + const activePhases = await FundingRoundLogic.getActiveFundingRoundPhases(fundingRoundId); + const shouldSelectPhase = activePhases.length > 1; - const selectMenu = new StringSelectMenuBuilder() - .setCustomId(CustomIDOracle.generateCustomId(this.screen.dashboard, this.screen, (this.screen as ProjectVotingScreen).selectProjectAction, SelectProjectAction.OPERATIONS.showProjects)) - .setCustomId(CustomIDOracle.addArgumentsToAction(this, 'selectPhase', 'frId', fundingRoundId.toString())) - .setPlaceholder('Select a Voting Phase') - .addOptions(options); - - const row = new ActionRowBuilder().addComponents(selectMenu); - - await interaction.respond({ components: [row], ephemeral: true }); - } - - public allSubActions(): Action[] { - return []; + let description: string = `In this section you can cast your votes on approval or rejections of projects in ${fundingRound.name}.`; + if (shouldSelectPhase) { + description += 'Since there is a more than one phase to vote on, you will need to select the phase first. '; + } else { + description += 'Begin by selecting a project to vote '; } - getComponent(fundingRoundId: number): ButtonBuilder { - return new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, 'selectPhase', 'frId', fundingRoundId.toString())) - .setLabel('Select Voting Phase') - .setStyle(ButtonStyle.Primary); + const embed = new EmbedBuilder().setColor('#0099ff').setTitle(`Vote on Projects In ${fundingRound.name}`).setDescription(description); + + let components: ActionRowBuilder[] = []; + + if (activePhases.length > 1) { + // TODO: This wasn't tested. Also, will this ever happen? + // NOTE for reader: this branch handles the display of phase to vote on, if there is more than one active phase + const selectPhaseButton = this.selectPhaseAction.getComponent(fundingRoundId); + const displayData = this.selectProjectAction.getSelectProjectComponent(interaction, fundingRoundId, activePhases[0]); + components.push(new ActionRowBuilder().addComponents(selectPhaseButton)); + } else if (activePhases.length === 1) { + const selectProjectButton = this.selectProjectAction.getComponent(fundingRoundId, activePhases[0]); + interaction.Context.set('ph', activePhases[0]); + interaction.Context.set('frId', fundingRoundId.toString()); + const displayData = await this.selectProjectAction.getSelectProjectComponent(interaction, fundingRoundId, activePhases[0]); + components = displayData.components; + } else { + embed.setDescription('There are no active voting phases for this funding round at the moment.'); } -} -export class SelectProjectAction extends PaginationComponent { - public static readonly ID = 'slPr'; - - public static readonly OPERATIONS = { - showProjects: 'showProjects', - selectProject: 'slPr', - paginate: 'paginate', + return { + embeds: [embed], + components, + ephemeral: true, }; + } +} - protected async getTotalPages(interaction: TrackedInteraction, phaseArg?: string): Promise { - const fundingRoundIdRaw: string = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID); - - let fundingRoundId: number = parseInt(fundingRoundIdRaw); - - - let phase: string = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.PHASE); - phase = phase.toLowerCase(); - - const projects = await FundingRoundLogic.getActiveProposalsForPhase(fundingRoundId, phase); - return Math.ceil(projects.length / 25); - } - - protected async getItemsForPage(interaction: TrackedInteraction, page: number): Promise { - const fundingRoundIdRaw: string = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID); - - let fundingRoundId: number = parseInt(fundingRoundIdRaw); - - - - const phase: string = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.PHASE); - - const projects = await FundingRoundLogic.getActiveProposalsForPhase(fundingRoundId, phase); - return projects.slice(page * 25, (page + 1) * 25); - } +class SelectPhaseAction extends Action { + public static readonly ID = 'selectPhase'; + + public static readonly OPERATIONS = { + showPhases: 'showPhases', + selectPhase: 'selectPhase', + }; + + protected async handleOperation(interaction: TrackedInteraction, operationId: string): Promise { + const fundingRoundId = parseInt(CustomIDOracle.getNamedArgument(interaction.customId, 'frId') || ''); + const activePhases = await FundingRoundLogic.getActiveFundingRoundPhases(fundingRoundId); + + const options = activePhases.map((phase) => ({ + label: phase.charAt(0).toUpperCase() + phase.slice(1), + value: phase, + })); + + const selectMenu = new StringSelectMenuBuilder() + .setCustomId( + CustomIDOracle.generateCustomId( + this.screen.dashboard, + this.screen, + (this.screen as ProjectVotingScreen).selectProjectAction, + SelectProjectAction.OPERATIONS.showProjects, + ), + ) + .setCustomId(CustomIDOracle.addArgumentsToAction(this, 'selectPhase', 'frId', fundingRoundId.toString())) + .setPlaceholder('Select a Voting Phase') + .addOptions(options); + + const row = new ActionRowBuilder().addComponents(selectMenu); + + await interaction.respond({ components: [row], ephemeral: true }); + } + + public allSubActions(): Action[] { + return []; + } + + getComponent(fundingRoundId: number): ButtonBuilder { + return new ButtonBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction(this, 'selectPhase', 'frId', fundingRoundId.toString())) + .setLabel('Select Voting Phase') + .setStyle(ButtonStyle.Primary); + } +} - public async handleOperation(interaction: TrackedInteraction, operationId: string): Promise { - switch (operationId) { - case SelectProjectAction.OPERATIONS.showProjects: - await this.handleShowProjects(interaction); - break; - case SelectProjectAction.OPERATIONS.selectProject: - await this.handleSelectProject(interaction); - break; - case SelectProjectAction.OPERATIONS.paginate: - await this.handlePagination(interaction); - break; - default: - await this.handleInvalidOperation(interaction, operationId); - } +export class SelectProjectAction extends PaginationComponent { + public static readonly ID = 'slPr'; + + public static readonly OPERATIONS = { + showProjects: 'showProjects', + selectProject: 'slPr', + paginate: 'paginate', + }; + + protected async getTotalPages(interaction: TrackedInteraction, phaseArg?: string): Promise { + const fundingRoundIdRaw: string = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID); + + let fundingRoundId: number = parseInt(fundingRoundIdRaw); + + let phase: string = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.PHASE); + phase = phase.toLowerCase(); + + const projects = await FundingRoundLogic.getActiveProposalsForPhase(fundingRoundId, phase); + return Math.ceil(projects.length / 25); + } + + protected async getItemsForPage(interaction: TrackedInteraction, page: number): Promise { + const fundingRoundIdRaw: string = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID); + + let fundingRoundId: number = parseInt(fundingRoundIdRaw); + + const phase: string = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.PHASE); + + const projects = await FundingRoundLogic.getActiveProposalsForPhase(fundingRoundId, phase); + return projects.slice(page * 25, (page + 1) * 25); + } + + public async handleOperation(interaction: TrackedInteraction, operationId: string): Promise { + switch (operationId) { + case SelectProjectAction.OPERATIONS.showProjects: + await this.handleShowProjects(interaction); + break; + case SelectProjectAction.OPERATIONS.selectProject: + await this.handleSelectProject(interaction); + break; + case SelectProjectAction.OPERATIONS.paginate: + await this.handlePagination(interaction); + break; + default: + await this.handleInvalidOperation(interaction, operationId); } - - public async getSelectProjectComponent(interaction: TrackedInteraction, fundingRoundId: number, phase: string) { - const currentPage = this.getCurrentPage(interaction); - const totalPages = await this.getTotalPages(interaction, phase); - const projects = await this.getItemsForPage(interaction, currentPage); - - const embed = new EmbedBuilder() - .setColor('#0099ff') - .setTitle('Select a Project to Vote On') - .setDescription(`Here, you can select a project that you can vote on. A vote can either an approval or rejection. Page ${currentPage + 1} of ${totalPages}`); - - const embeds = [embed] - - const options = projects.map(p => ({ - label: p.name, - value: p.id.toString(), - description: `Budget: ${p.budget}` - })); - - const selectMenu: StringSelectMenuBuilder = new StringSelectMenuBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, SelectProjectAction.OPERATIONS.selectProject, 'frId', fundingRoundId.toString(), 'ph', phase)) - .setPlaceholder('Select a Project to Vote On') - .addOptions(options); - - const row = new ActionRowBuilder().addComponents(selectMenu); - const components: ActionRowBuilder[] = [row]; - - if (totalPages > 1) { - const paginationRow = this.getPaginationRow(interaction, currentPage, totalPages); - components.push(paginationRow); - } - - return { embeds, components, ephemeral: true } + } + + public async getSelectProjectComponent(interaction: TrackedInteraction, fundingRoundId: number, phase: string) { + const currentPage = this.getCurrentPage(interaction); + const totalPages = await this.getTotalPages(interaction, phase); + const projects = await this.getItemsForPage(interaction, currentPage); + + const embed = new EmbedBuilder() + .setColor('#0099ff') + .setTitle('Select a Project to Vote On') + .setDescription( + `Here, you can select a project that you can vote on. A vote can either an approval or rejection. Page ${currentPage + 1} of ${totalPages}`, + ); + + const embeds = [embed]; + + const options = projects.map((p) => ({ + label: p.name, + value: p.id.toString(), + description: `Budget: ${p.budget}`, + })); + + const selectMenu: StringSelectMenuBuilder = new StringSelectMenuBuilder() + .setCustomId( + CustomIDOracle.addArgumentsToAction(this, SelectProjectAction.OPERATIONS.selectProject, 'frId', fundingRoundId.toString(), 'ph', phase), + ) + .setPlaceholder('Select a Project to Vote On') + .addOptions(options); + + const row = new ActionRowBuilder().addComponents(selectMenu); + const components: ActionRowBuilder[] = [row]; + + if (totalPages > 1) { + const paginationRow = this.getPaginationRow(interaction, currentPage, totalPages); + components.push(paginationRow); } - private async handleShowProjects(interaction: TrackedInteraction): Promise { - const currentPage = this.getCurrentPage(interaction); - const totalPages = await this.getTotalPages(interaction); - const projects = await this.getItemsForPage(interaction, currentPage); - - const fundingRoundIdRaw: string | undefined = CustomIDOracle.getNamedArgument(interaction.customId, 'frId'); - if (!fundingRoundIdRaw) { - throw new EndUserError('fundingRoundId not passed in customId'); - } - const fundingRoundId: number = parseInt(fundingRoundIdRaw); - - const phase: string | undefined = CustomIDOracle.getNamedArgument(interaction.customId, 'ph'); - let parsedPhase: string; + return { embeds, components, ephemeral: true }; + } - if (!phase) { - const parsedInteraction = InteractionProperties.toInteractionWithValuesOrUndefined(interaction.interaction); - if (!parsedInteraction) { - throw new EndUserError('phase neither passed in customId, nor the interaction has values'); - } - const chosenPhase: string = parsedInteraction.values[0]; - parsedPhase = chosenPhase.toLocaleLowerCase(); - } else { - parsedPhase = phase.toLocaleLowerCase(); - } + private async handleShowProjects(interaction: TrackedInteraction): Promise { + const currentPage = this.getCurrentPage(interaction); + const totalPages = await this.getTotalPages(interaction); + const projects = await this.getItemsForPage(interaction, currentPage); - - - if (projects.length === 0) { - throw new EndUserInfo('ℹ️ There are no active projects for voting in this phase at the moment.'); - } - - const displayData = this.getSelectProjectComponent(interaction, fundingRoundId, parsedPhase); - await interaction.respond(displayData); + const fundingRoundIdRaw: string | undefined = CustomIDOracle.getNamedArgument(interaction.customId, 'frId'); + if (!fundingRoundIdRaw) { + throw new EndUserError('fundingRoundId not passed in customId'); } - - private async handleSelectProject(interaction: TrackedInteraction): Promise { - const projectId = ArgumentOracle.getNamedArgument(interaction, 'prId') - - const fundingRoundIdFromCI: string = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID); - - - const fundingRoundId: number = parseInt(fundingRoundIdFromCI); - const phase = ArgumentOracle.getNamedArgument(interaction, 'ph'); - - - interaction.Context.set('prId', projectId.toString()); - interaction.Context.set('frId', fundingRoundId.toString()); - interaction.Context.set('ph', phase); - await (this.screen as ProjectVotingScreen).voteProjectAction.handleOperation( - interaction, - 'showVoteOptions', - { projectId, fundingRoundId, phase } - ); + const fundingRoundId: number = parseInt(fundingRoundIdRaw); + + const phase: string | undefined = CustomIDOracle.getNamedArgument(interaction.customId, 'ph'); + let parsedPhase: string; + + if (!phase) { + const parsedInteraction = InteractionProperties.toInteractionWithValuesOrUndefined(interaction.interaction); + if (!parsedInteraction) { + throw new EndUserError('phase neither passed in customId, nor the interaction has values'); + } + const chosenPhase: string = parsedInteraction.values[0]; + parsedPhase = chosenPhase.toLocaleLowerCase(); + } else { + parsedPhase = phase.toLocaleLowerCase(); } - public allSubActions(): Action[] { - return []; + if (projects.length === 0) { + throw new EndUserInfo('ℹ️ There are no active projects for voting in this phase at the moment.'); } - getComponent(fundingRoundId: number, phase: string): ButtonBuilder { - return new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, SelectProjectAction.OPERATIONS.showProjects, 'frId', fundingRoundId.toString(), 'ph', phase)) - .setLabel('Select Project') - .setStyle(ButtonStyle.Primary); - } + const displayData = this.getSelectProjectComponent(interaction, fundingRoundId, parsedPhase); + await interaction.respond(displayData); + } + + private async handleSelectProject(interaction: TrackedInteraction): Promise { + const projectId = ArgumentOracle.getNamedArgument(interaction, 'prId'); + + const fundingRoundIdFromCI: string = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID); + + const fundingRoundId: number = parseInt(fundingRoundIdFromCI); + const phase = ArgumentOracle.getNamedArgument(interaction, 'ph'); + + interaction.Context.set('prId', projectId.toString()); + interaction.Context.set('frId', fundingRoundId.toString()); + interaction.Context.set('ph', phase); + await (this.screen as ProjectVotingScreen).voteProjectAction.handleOperation(interaction, 'showVoteOptions', { + projectId, + fundingRoundId, + phase, + }); + } + + public allSubActions(): Action[] { + return []; + } + + getComponent(fundingRoundId: number, phase: string): ButtonBuilder { + return new ButtonBuilder() + .setCustomId( + CustomIDOracle.addArgumentsToAction(this, SelectProjectAction.OPERATIONS.showProjects, 'frId', fundingRoundId.toString(), 'ph', phase), + ) + .setLabel('Select Project') + .setStyle(ButtonStyle.Primary); + } } class VoteProjectAction extends Action { - public static readonly ID = 'voteProject'; - - public static readonly OPERATIONS = { - showVoteOptions: 'showVoteOptions', - submitDeliberationReasoning: 'submitDeliberationReasoning', - submitReasoningModal: 'submitReasoningModal', - }; - - public static readonly MODAL_FIELDS = { - reasoning: 'reasoning', - updateReasoning: 'update_reasoning', - }; - - public async handleOperation(interaction: TrackedInteraction, operationId: string, args?: any): Promise { - switch (operationId) { - case VoteProjectAction.OPERATIONS.showVoteOptions: - await this.handleShowVoteOptions(interaction, args); - break; - case VoteProjectAction.OPERATIONS.submitDeliberationReasoning: - await this.handleSubmitDeliberationReasoning(interaction); - break; - case VoteProjectAction.OPERATIONS.submitReasoningModal: - await this.handleModalSubmit(interaction); - break; - default: - await this.handleInvalidOperation(interaction, operationId); - } + public static readonly ID = 'voteProject'; + + public static readonly OPERATIONS = { + showVoteOptions: 'showVoteOptions', + submitDeliberationReasoning: 'submitDeliberationReasoning', + submitReasoningModal: 'submitReasoningModal', + }; + + public static readonly MODAL_FIELDS = { + reasoning: 'reasoning', + updateReasoning: 'update_reasoning', + }; + + public async handleOperation(interaction: TrackedInteraction, operationId: string, args?: any): Promise { + switch (operationId) { + case VoteProjectAction.OPERATIONS.showVoteOptions: + await this.handleShowVoteOptions(interaction, args); + break; + case VoteProjectAction.OPERATIONS.submitDeliberationReasoning: + await this.handleSubmitDeliberationReasoning(interaction); + break; + case VoteProjectAction.OPERATIONS.submitReasoningModal: + await this.handleModalSubmit(interaction); + break; + default: + await this.handleInvalidOperation(interaction, operationId); } + } - private async handleShowVoteOptions(interaction: TrackedInteraction, args: { projectId: number, fundingRoundId: number, phase: string }): Promise { - const { projectId, fundingRoundId} = args; - const project = await ProposalLogic.getProposalByIdOrError(projectId); - logger.info(`Funding Round ID: ${fundingRoundId}`); - - const fundingRoundPhases = await FundingRoundLogic.getActiveFundingRoundPhases(fundingRoundId); - const proposalPhase: string = proposalStatusToPhase(project.status); - - if (!fundingRoundPhases) { - throw new EndUserError("All voting is currently closed in the Funding Round"); - } + private async handleShowVoteOptions( + interaction: TrackedInteraction, + args: { projectId: number; fundingRoundId: number; phase: string }, + ): Promise { + const { projectId, fundingRoundId } = args; + const project = await ProposalLogic.getProposalByIdOrError(projectId); + logger.info(`Funding Round ID: ${fundingRoundId}`); - if (!fundingRoundPhases.includes(proposalPhase)) { - throw new EndUserError(`Voting for this project is unavailable. Funding Round has voting open in ${fundingRoundPhases.join(', ')}, but proposal is in ${proposalPhase}.`) - } + const fundingRoundPhases = await FundingRoundLogic.getActiveFundingRoundPhases(fundingRoundId); + const proposalPhase: string = proposalStatusToPhase(project.status); + if (!fundingRoundPhases) { + throw new EndUserError('All voting is currently closed in the Funding Round'); + } - const hasUserSubmittedReasoning: boolean = await VoteLogic.hasUserSubmittedDeliberationReasoning(interaction.interaction.user.id, projectId); - const gptResponseButtonLabel: string = hasUserSubmittedReasoning ? '✏️ Update Reasoning' : '✍️ Submit Reasoning'; - const gptResponseButtonLabelWithoutEmoji: string = hasUserSubmittedReasoning ? 'Update Reasoning' : 'Submit Reasoning'; + if (!fundingRoundPhases.includes(proposalPhase)) { + throw new EndUserError( + `Voting for this project is unavailable. Funding Round has voting open in ${fundingRoundPhases.join( + ', ', + )}, but proposal is in ${proposalPhase}.`, + ); + } + const hasUserSubmittedReasoning: boolean = await VoteLogic.hasUserSubmittedDeliberationReasoning(interaction.interaction.user.id, projectId); + const gptResponseButtonLabel: string = hasUserSubmittedReasoning ? '✏️ Update Reasoning' : '✍️ Submit Reasoning'; + const gptResponseButtonLabelWithoutEmoji: string = hasUserSubmittedReasoning ? 'Update Reasoning' : 'Submit Reasoning'; - // assuming consideration phase - let description: string = ` + // assuming consideration phase + let description: string = ` Voting Stage: 1️⃣/3️⃣ Current Phase: ${proposalPhase} Next Phase: deliberation @@ -368,23 +381,23 @@ class VoteProjectAction extends Action { - Reject - Remove most recent 'Approve' vote, if exists. The voting is done on-chain. Click the button below to vote. - ` + `; - if (proposalPhase === 'deliberation') { - description = ` + if (proposalPhase === 'deliberation') { + description = ` Voting Stage: 2️⃣/3️⃣ Current Phase: ${proposalPhase} Previous Phase: consideration Next Phase: funding The current phase is the deliberation phase. In this phase, you can submit your reasoning for why you believe the project should be funded or not. Your reasoning and discord user ID will be stored for internal records and may be analyzed by third-party systems. By pressing the "${gptResponseButtonLabelWithoutEmoji}" button below, you agree to these terms. - ` - } + `; + } - if (proposalPhase === 'funding') { - description = ` + if (proposalPhase === 'funding') { + description = ` Voting Stage: 3️⃣/3️⃣ - Curernt Phase: ${proposalPhase} Previous Phase: deliberation + Current Phase: ${proposalPhase} Previous Phase: deliberation ⚠️ This is the final voting stage. The votes in this stage decide which projects will be funded. @@ -398,150 +411,165 @@ class VoteProjectAction extends Action { - Reject - Remove most recent 'Approve' vote, if exists. The voting is done on-chain. Click the button below to vote. - ` - } - // TODO: add description for each phase - const embed = new EmbedBuilder() - .setColor('#0099ff') - .setTitle(`Vote on Project: ${project.name}`) - .setDescription(description) - .addFields( - { name: 'Budget', value: project.budget.toString(), inline: true }, - { name: 'Status', value: project.status, inline: true }, - { name: 'URI', value: project.uri, inline: true }, - { name: 'Proposer Discord ID', value: project.proposerDuid, inline: true } - ); - - let components: ActionRowBuilder[] = []; - switch (proposalPhase.toLowerCase()) { - case 'consideration': - case 'funding': - const voteLink = OCVLinkGenerator.generateProjectVoteLink(projectId, proposalPhase); - const voteButton = new ButtonBuilder() - .setLabel('🗳️ Vote On-Chain') - .setStyle(ButtonStyle.Link) - .setURL(voteLink); - components.push(new ActionRowBuilder().addComponents(voteButton)); - break; - case 'deliberation': - const deliberationButton = new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, VoteProjectAction.OPERATIONS.submitDeliberationReasoning, 'prId', projectId.toString(), 'frId', fundingRoundId.toString())) - .setLabel(gptResponseButtonLabel) - .setStyle(ButtonStyle.Primary); - components.push(new ActionRowBuilder().addComponents(deliberationButton)); - break; - } - - await interaction.respond({ embeds: [embed], components, ephemeral: true }); + `; } - - private async handleSubmitDeliberationReasoning(interaction: TrackedInteraction): Promise { - const projectIdRaw = CustomIDOracle.getNamedArgument(interaction.customId, 'prId'); - const fundingRoundIdRaw = CustomIDOracle.getNamedArgument(interaction.customId, 'frId'); - - if (!projectIdRaw) { - throw new EndUserError('projectId not passed in customId'); - } - - if (!fundingRoundIdRaw) { - throw new EndUserError('fundingRoundId not passed in customId'); - } - - const projectId: number = parseInt(projectIdRaw); - const fundingRoundId: number = parseInt(fundingRoundIdRaw); - - const hasUserSubmittedReasoning: boolean = await VoteLogic.hasUserSubmittedDeliberationReasoning(interaction.interaction.user.id, projectId); - - const title: string = hasUserSubmittedReasoning ? '✏️ Update Reasoning' : '✍️ Submit Reasoning'; - - const modal = new ModalBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, VoteProjectAction.OPERATIONS.submitReasoningModal, 'prId', projectId.toString(), 'frId', fundingRoundId.toString())) - .setTitle(title); - - const reasoningInput = new TextInputBuilder() - .setCustomId('reasoning') - .setLabel('Should this project be funded? Why?') - .setStyle(TextInputStyle.Paragraph) - .setRequired(true); - - modal.addComponents(new ActionRowBuilder().addComponents(reasoningInput)); - - if (hasUserSubmittedReasoning) { - const reasonInput = new TextInputBuilder() - .setCustomId(VoteProjectAction.MODAL_FIELDS.updateReasoning) - .setLabel('I am updating my reasoning because...') - .setStyle(TextInputStyle.Paragraph) - .setRequired(true); - - modal.addComponents(new ActionRowBuilder().addComponents(reasonInput)); - } - - - const modalInteraction = InteractionProperties.toShowModalOrUndefined(interaction.interaction); - if (!modalInteraction) { - throw new EndUserError('Failed to show modal. Please try again.'); - } - - await modalInteraction.showModal(modal); + // TODO: add description for each phase + const embed = new EmbedBuilder().setColor('#0099ff').setTitle(`Vote on Project: ${project.name}`).setDescription(description).addFields( + { name: 'Budget', value: project.budget.toString(), inline: true }, + { name: 'Status', value: project.status, inline: true }, + { name: 'URI', value: project.uri, inline: true }, + { + name: 'Proposer Discord ID', + value: project.proposerDuid, + inline: true, + }, + ); + + let components: ActionRowBuilder[] = []; + switch (proposalPhase.toLowerCase()) { + case 'consideration': + case 'funding': + const voteLink = OCVLinkGenerator.generateProjectVoteLink(projectId, proposalPhase); + const voteButton = new ButtonBuilder().setLabel('🗳️ Vote On-Chain').setStyle(ButtonStyle.Link).setURL(voteLink); + components.push(new ActionRowBuilder().addComponents(voteButton)); + break; + case 'deliberation': + const fundingRound: FundingRound = await FundingRoundLogic.getFundingRoundByIdOrError(fundingRoundId); + const customId = CustomIDOracle.customIdFromRawParts( + fundingRound.forumChannelId, + this.screen.ID, + VoteProjectAction.ID, + VoteProjectAction.OPERATIONS.submitDeliberationReasoning, + 'prId', + projectId.toString(), + 'frId', + fundingRoundId.toString(), + ); + const deliberationButton = new ButtonBuilder().setCustomId(customId).setLabel(gptResponseButtonLabel).setStyle(ButtonStyle.Primary); + components.push(new ActionRowBuilder().addComponents(deliberationButton)); + break; } - public async handleModalSubmit(interaction: TrackedInteraction): Promise { - const modalInteraction = InteractionProperties.toModalSubmitInteractionOrUndefined(interaction.interaction); - if (!modalInteraction) { - throw new EndUserError('Invalid interaction type.'); - } - - const projectIdRaw = CustomIDOracle.getNamedArgument(interaction.customId, 'prId'); - const fundingRoundIdRaw = CustomIDOracle.getNamedArgument(interaction.customId, 'frId'); + await interaction.respond({ embeds: [embed], components, ephemeral: true }); + } - if (!projectIdRaw) { - throw new EndUserError('projectId not passed in customId'); - } + private async handleSubmitDeliberationReasoning(interaction: TrackedInteraction): Promise { + const projectIdRaw = CustomIDOracle.getNamedArgument(interaction.customId, 'prId'); + const fundingRoundIdRaw = CustomIDOracle.getNamedArgument(interaction.customId, 'frId'); - if (!fundingRoundIdRaw) { - throw new EndUserError('fundingRoundId not passed in customId'); - } + if (!projectIdRaw) { + throw new EndUserError('projectId not passed in customId'); + } - const projectId: number = parseInt(projectIdRaw); - const fundingRoundId: number = parseInt(fundingRoundIdRaw); + if (!fundingRoundIdRaw) { + throw new EndUserError('fundingRoundId not passed in customId'); + } + const projectId: number = parseInt(projectIdRaw); + const fundingRoundId: number = parseInt(fundingRoundIdRaw); + + const hasUserSubmittedReasoning: boolean = await VoteLogic.hasUserSubmittedDeliberationReasoning(interaction.interaction.user.id, projectId); + + const title: string = hasUserSubmittedReasoning ? '✏️ Update Reasoning' : '✍️ Submit Reasoning'; + + const fundingRound: FundingRound = await FundingRoundLogic.getFundingRoundByIdOrError(fundingRoundId); + const modal = new ModalBuilder() + .setCustomId( + CustomIDOracle.addArgumentsToActionCustomDashboardId( + fundingRound.forumChannelId, + this, + VoteProjectAction.OPERATIONS.submitReasoningModal, + 'prId', + projectId.toString(), + 'frId', + fundingRoundId.toString(), + ), + ) + .setTitle(title); + + const reasoningInput = new TextInputBuilder() + .setCustomId('reasoning') + .setLabel('Should this project be funded? Why?') + .setStyle(TextInputStyle.Paragraph) + .setRequired(true); + + modal.addComponents(new ActionRowBuilder().addComponents(reasoningInput)); + + if (hasUserSubmittedReasoning) { + const reasonInput = new TextInputBuilder() + .setCustomId(VoteProjectAction.MODAL_FIELDS.updateReasoning) + .setLabel('I am updating my reasoning because...') + .setStyle(TextInputStyle.Paragraph) + .setRequired(true); + + modal.addComponents(new ActionRowBuilder().addComponents(reasonInput)); + } - const reasoning = modalInteraction.fields.getTextInputValue(VoteProjectAction.MODAL_FIELDS.reasoning); - let updateReasoning: string | null = null; - try { - updateReasoning = modalInteraction.fields.getTextInputValue(VoteProjectAction.MODAL_FIELDS.updateReasoning); - } catch (error) { - // reason not present in modal, it's safe to ignore + const modalInteraction = InteractionProperties.toShowModalOrUndefined(interaction.interaction); + if (!modalInteraction) { + throw new EndUserError('Failed to show modal. Please try again.'); + } - } + await modalInteraction.showModal(modal); + } + public async handleModalSubmit(interaction: TrackedInteraction): Promise { + const modalInteraction = InteractionProperties.toModalSubmitInteractionOrUndefined(interaction.interaction); + if (!modalInteraction) { + throw new EndUserError('Invalid interaction type.'); + } - try { - await VoteLogic.submitDeliberationReasoning(interaction.interaction.user.id, projectId, fundingRoundId, reasoning, updateReasoning); + const projectIdRaw = CustomIDOracle.getNamedArgument(interaction.customId, 'prId'); + const fundingRoundIdRaw = CustomIDOracle.getNamedArgument(interaction.customId, 'frId'); - const embed = new EmbedBuilder() - .setColor('#28a745') - .setTitle('Reasoning Submitted Successfully') - .setDescription('Your reasoning has been recorded and will be submitted to the GPTSummarizer bot for analysis.') - .addFields( - { name: 'Warning', value: 'Your submitted data will be stored for internal records and may be analyzed by our or third-party systems.' } - ); + if (!projectIdRaw) { + throw new EndUserError('projectId not passed in customId'); + } - await interaction.respond({ embeds: [embed], ephemeral: true }); - } catch (error) { - logger.error('Error submitting reasoning'); - throw new EndUserError('Failed to submit reasoning', error); - } + if (!fundingRoundIdRaw) { + throw new EndUserError('fundingRoundId not passed in customId'); } - public allSubActions(): Action[] { - return []; + const projectId: number = parseInt(projectIdRaw); + const fundingRoundId: number = parseInt(fundingRoundIdRaw); + + const reasoning = modalInteraction.fields.getTextInputValue(VoteProjectAction.MODAL_FIELDS.reasoning); + let updateReasoning: string | null = null; + try { + updateReasoning = modalInteraction.fields.getTextInputValue(VoteProjectAction.MODAL_FIELDS.updateReasoning); + } catch (error) { + // reason not present in modal, it's safe to ignore } - getComponent(projectId: number, fundingRoundId: number, phase: string): ButtonBuilder { - return new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, 'showVoteOptions', 'prId', projectId.toString(), 'frId', fundingRoundId.toString(), 'ph', phase)) - .setLabel('Vote on Project') - .setStyle(ButtonStyle.Primary); + try { + await VoteLogic.submitDeliberationReasoning(interaction.interaction.user.id, projectId, fundingRoundId, reasoning, updateReasoning); + + const embed = new EmbedBuilder() + .setColor('#28a745') + .setTitle('Reasoning Submitted Successfully') + .setDescription('Your reasoning has been recorded and will be submitted to the GPTSummarizer bot for analysis.') + .addFields({ + name: 'Warning', + value: 'Your submitted data will be stored for internal records and may be analyzed by our or third-party systems.', + }); + + await interaction.respond({ embeds: [embed], ephemeral: true }); + } catch (error) { + logger.error('Error submitting reasoning'); + throw new EndUserError('Failed to submit reasoning', error); } -} \ No newline at end of file + } + + public allSubActions(): Action[] { + return []; + } + + getComponent(projectId: number, fundingRoundId: number, phase: string): ButtonBuilder { + return new ButtonBuilder() + .setCustomId( + CustomIDOracle.addArgumentsToAction(this, 'showVoteOptions', 'prId', projectId.toString(), 'frId', fundingRoundId.toString(), 'ph', phase), + ) + .setLabel('Vote on Project') + .setStyle(ButtonStyle.Primary); + } +} diff --git a/src/components/FundingRoundPaginator.ts b/src/components/FundingRoundPaginator.ts index f55c769..d950a1a 100644 --- a/src/components/FundingRoundPaginator.ts +++ b/src/components/FundingRoundPaginator.ts @@ -94,4 +94,15 @@ export class ConsiderationFundingRoundPaginator extends FundingRoundPaginator { const duid: string = interaction.discordUserId; return await ConsiderationLogic.getEligibleFundingRounds(duid); } +} + +export class ActiveFundingRoundPaginator extends FundingRoundPaginator { + public static readonly ID = 'activeFRPag'; + + public args: string[] = [] + public title: string = "Select an Active Funding Round"; + + public async getItems(interaction: TrackedInteraction): Promise { + return await FundingRoundLogic.getActiveFundingRounds(); + } } \ No newline at end of file diff --git a/src/components/ProposalsPaginator.ts b/src/components/ProposalsPaginator.ts index 3cb0691..3fe1277 100644 --- a/src/components/ProposalsPaginator.ts +++ b/src/components/ProposalsPaginator.ts @@ -37,7 +37,26 @@ export class EditMySubmittedProposalsPaginator extends ProposalsPaginator { ); return eligibleProposals; } +} +export class ManageProposalStatusesPaginator extends ProposalsPaginator { + public static readonly ID = "ManPropStatPag"; + public args: string[] = []; + public title: string = "Select a Proposal to Manage"; + public readonly REQUIRED_ARGUMENTS: string[] = [ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID]; + public async getItems(interaction: TrackedInteraction): Promise { + const fundingRoundId: string = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID); + const fundingRoundIdNum: number = parseInt(fundingRoundId); + return await ProposalLogic.getProposalsForFundingRound(fundingRoundIdNum); + } + + protected async getOptions(interaction: TrackedInteraction, items: Proposal[]): Promise { + return items.map((p: Proposal) => ({ + label: p.name, + value: p.id.toString(), + description: `Status: ${p.status}, Budget: ${p.budget}` + })); + } } \ No newline at end of file diff --git a/src/logic/ProposalLogic.ts b/src/logic/ProposalLogic.ts index 7e0623e..0d5c127 100644 --- a/src/logic/ProposalLogic.ts +++ b/src/logic/ProposalLogic.ts @@ -1,17 +1,27 @@ -import { EndUserError } from '../Errors'; -import logger from '../logging'; -import { Proposal, FundingRound, TopicSMEGroupProposalCreationLimiter, SMEGroupMembership } from '../models'; -import { FundingRoundStatus, ProposalAttributes, ProposalCreationAttributes, ProposalStatus } from '../types'; -import { Screen } from '../core/BaseClasses'; +import { EndUserError } from "../Errors"; +import logger from "../logging"; +import { + Proposal, + FundingRound, + TopicSMEGroupProposalCreationLimiter, + SMEGroupMembership, +} from "../models"; +import { + FundingRoundStatus, + ProposalAttributes, + ProposalCreationAttributes, + ProposalStatus, +} from "../types"; +import { Screen } from "../core/BaseClasses"; export class ProposalLogic { static async getUserDraftProposals(userId: string): Promise { return await Proposal.findAll({ where: { proposerDuid: userId, - status: ProposalStatus.DRAFT + status: ProposalStatus.DRAFT, }, - order: [['createdAt', 'DESC']] + order: [["createdAt", "DESC"]], }); } @@ -29,14 +39,20 @@ export class ProposalLogic { return proposal; } - static async createProposal(data: ProposalCreationAttributes): Promise { + static async createProposal( + data: ProposalCreationAttributes + ): Promise { return await Proposal.create({ ...data, - status: ProposalStatus.DRAFT + status: ProposalStatus.DRAFT, }); } - static async updateProposal(id: number, data: Partial, screen?: any): Promise { + static async updateProposal( + id: number, + data: Partial, + screen?: any + ): Promise { const proposal = await Proposal.findByPk(id); if (!proposal) { return null; @@ -45,13 +61,15 @@ export class ProposalLogic { if (screen && proposal.fundingRoundId && proposal.forumThreadId) { try { - const { ProposalsForumManager } = await import('../channels/proposals/ProposalsForumManager'); + const { ProposalsForumManager } = await import( + "../channels/proposals/ProposalsForumManager" + ); await ProposalsForumManager.refreshThread(proposal, screen); } catch (error) { - logger.error('Error refreshing forum thread:', error); + logger.error("Error refreshing forum thread:", error); } } - return proposal + return proposal; } static async deleteProposal(id: number): Promise { @@ -62,10 +80,12 @@ export class ProposalLogic { if (proposal.forumThreadId) { try { - const { ProposalsForumManager } = await import('../channels/proposals/ProposalsForumManager'); + const { ProposalsForumManager } = await import( + "../channels/proposals/ProposalsForumManager" + ); await ProposalsForumManager.deleteThread(proposal); } catch (error) { - logger.error('Error deleting forum thread:', error); + logger.error("Error deleting forum thread:", error); } } @@ -73,7 +93,11 @@ export class ProposalLogic { return true; } - static async submitProposalToFundingRound(proposalId: number, fundingRoundId: number, screen?: any): Promise { + static async submitProposalToFundingRound( + proposalId: number, + fundingRoundId: number, + screen?: any + ): Promise { const proposal = await Proposal.findByPk(proposalId); const fundingRound = await FundingRound.findByPk(fundingRoundId); @@ -82,98 +106,133 @@ export class ProposalLogic { } if (proposal.status !== ProposalStatus.DRAFT) { - throw new EndUserError('Only draft proposals can be submitted to funding rounds.'); + throw new EndUserError( + "Only draft proposals can be submitted to funding rounds." + ); } // Check if the user has permission to submit to this funding round - const hasPermission = await this.userHasPermissionToSubmit(proposal.proposerDuid, fundingRound.topicId); + const hasPermission = await this.userHasPermissionToSubmit( + proposal.proposerDuid, + fundingRound.topicId + ); if (!hasPermission) { - throw new EndUserError('You do not have permission to submit proposals to this funding round.'); + throw new EndUserError( + "You do not have permission to submit proposals to this funding round." + ); } await proposal.update({ fundingRoundId: fundingRoundId, - status: ProposalStatus.CONSIDERATION_PHASE + status: ProposalStatus.CONSIDERATION_PHASE, }); if (screen) { - try { - const { ProposalsForumManager } = await import('../channels/proposals/ProposalsForumManager'); - await ProposalsForumManager.createThread(proposal, fundingRound, screen); + const { ProposalsForumManager } = await import( + "../channels/proposals/ProposalsForumManager" + ); + await ProposalsForumManager.createThread( + proposal, + fundingRound, + screen + ); } catch (error) { - logger.error('Error creating forum thread for proposal:', error); + logger.error("Error creating forum thread for proposal:", error); //TODO: Consider whether to revert the proposal update or not? } - } return proposal; } - static async getUserProposalsForFundingRound(userId: string, fundingRoundId: number): Promise { + static async getUserProposalsForFundingRound( + userId: string, + fundingRoundId: number + ): Promise { return await Proposal.findAll({ where: { proposerDuid: userId, - fundingRoundId: fundingRoundId + fundingRoundId: fundingRoundId, }, - order: [['createdAt', 'DESC']] + order: [["createdAt", "DESC"]], }); } - static async cancelProposal(proposalId: number, screen?: Screen): Promise { + static async cancelProposal( + proposalId: number, + screen?: Screen + ): Promise { const proposal = await Proposal.findByPk(proposalId); if (!proposal) { throw new EndUserError(`Proposal with ID ${proposalId} not found.`); } - if (proposal.status === ProposalStatus.DRAFT || proposal.status === ProposalStatus.CANCELLED) { - throw new EndUserError('Cannot cancel a draft or already cancelled proposal.'); + if ( + proposal.status === ProposalStatus.DRAFT || + proposal.status === ProposalStatus.CANCELLED + ) { + throw new EndUserError( + "Cannot cancel a draft or already cancelled proposal." + ); } if (!proposal.fundingRoundId) { throw new EndUserError(`Cannot cancel proposal without a funding round.`); } - // Not passing the screen to prevent update to the forum thread, as we want to delete the thread - const updatedProposal: Proposal = await this.updateProposalStatus(proposalId, ProposalStatus.CANCELLED); + const updatedProposal: Proposal = await this.updateProposalStatus( + proposalId, + ProposalStatus.CANCELLED + ); if (screen) { try { - const { ProposalsForumManager } = await import('../channels/proposals/ProposalsForumManager'); + const { ProposalsForumManager } = await import( + "../channels/proposals/ProposalsForumManager" + ); await ProposalsForumManager.deleteThread(proposal); } catch (error) { - throw new EndUserError('Error deleting forum thread', error); + throw new EndUserError("Error deleting forum thread", error); } } return updatedProposal; } - static async updateProposalStatus(proposalId: number, status: ProposalStatus, screen?: Screen) { + static async updateProposalStatus( + proposalId: number, + status: ProposalStatus, + screen?: Screen + ) { const proposal = await Proposal.findByPk(proposalId); if (!proposal) { - throw new EndUserError('Proposal not found'); + throw new EndUserError("Proposal not found"); } await proposal.update({ status }); if (screen && proposal.fundingRoundId && proposal.forumThreadId) { try { - const { ProposalsForumManager } = await import('../channels/proposals/ProposalsForumManager'); + const { ProposalsForumManager } = await import( + "../channels/proposals/ProposalsForumManager" + ); await ProposalsForumManager.refreshThread(proposal, screen); } catch (error) { - logger.error('Error refreshing forum thread:', error); + logger.error("Error refreshing forum thread:", error); } } return proposal; } - private static async userHasPermissionToSubmit(userId: string, topicId: number): Promise { + private static async userHasPermissionToSubmit( + userId: string, + topicId: number + ): Promise { // Check if there are any limitations for this topic const limitations = await TopicSMEGroupProposalCreationLimiter.findAll({ - where: { topicId: topicId } + where: { topicId: topicId }, }); // If there are no limitations, everyone can submit @@ -183,13 +242,27 @@ export class ProposalLogic { // Check if the user belongs to any of the allowed SME groups const userMemberships = await SMEGroupMembership.findAll({ - where: { duid: userId } + where: { duid: userId }, }); - const userSMEGroupIds = userMemberships.map(membership => membership.smeGroupId); - const allowedSMEGroupIds = limitations.map(limitation => limitation.smeGroupId); + const userSMEGroupIds = userMemberships.map( + (membership) => membership.smeGroupId + ); + const allowedSMEGroupIds = limitations.map( + (limitation) => limitation.smeGroupId + ); - return userSMEGroupIds.some(id => allowedSMEGroupIds.includes(id)); + return userSMEGroupIds.some((id) => allowedSMEGroupIds.includes(id)); } -} \ No newline at end of file + static async getProposalsForFundingRound( + fundingRoundId: number + ): Promise { + return await Proposal.findAll({ + where: { + fundingRoundId: fundingRoundId, + }, + order: [["createdAt", "DESC"]], + }); + } +} From 4b8bf94ea51f1c816b390223b425905dd1d9d549 Mon Sep 17 00:00:00 2001 From: Illya Gerasymchuk Date: Mon, 26 Aug 2024 01:18:03 +0200 Subject: [PATCH 11/12] bug: fix 'Go Back' button --- .../screens/ManageProposalStatusesScreen.ts | 497 +++-- .../propose/screens/ProposalHomeScreen.ts | 1791 +++++++++-------- 2 files changed, 1176 insertions(+), 1112 deletions(-) diff --git a/src/channels/admin/screens/ManageProposalStatusesScreen.ts b/src/channels/admin/screens/ManageProposalStatusesScreen.ts index 4709a83..8def035 100644 --- a/src/channels/admin/screens/ManageProposalStatusesScreen.ts +++ b/src/channels/admin/screens/ManageProposalStatusesScreen.ts @@ -16,309 +16,304 @@ import { ManageProposalStatusesPaginator } from '../../../components/ProposalsPa import { ArgumentOracle } from '../../../CustomIDOracle'; export class ManageProposalStatusesScreen extends Screen { - public static readonly ID = 'manageProposalStatuses'; + public static readonly ID = 'manageProposalStatuses'; - protected permissions: Permission[] = []; // TODO: Implement proper admin permissions + protected permissions: Permission[] = []; // TODO: Implement proper admin permissions - public readonly selectFundingRoundAction: SelectFundingRoundAction; - public readonly selectProposalAction: SelectProposalAction; - public readonly updateProposalStatusAction: UpdateProposalStatusAction; + public readonly selectFundingRoundAction: SelectFundingRoundAction; + public readonly selectProposalAction: SelectProposalAction; + public readonly updateProposalStatusAction: UpdateProposalStatusAction; - constructor(dashboard: Dashboard, screenId: string) { - super(dashboard, screenId); - this.selectFundingRoundAction = new SelectFundingRoundAction(this); - this.selectProposalAction = new SelectProposalAction(this); - this.updateProposalStatusAction = new UpdateProposalStatusAction(this, UpdateProposalStatusAction.ID); - } + constructor(dashboard: Dashboard, screenId: string) { + super(dashboard, screenId); + this.selectFundingRoundAction = new SelectFundingRoundAction(this); + this.selectProposalAction = new SelectProposalAction(this); + this.updateProposalStatusAction = new UpdateProposalStatusAction(this, UpdateProposalStatusAction.ID); + } - protected allSubScreens(): Screen[] { - return []; - } + protected allSubScreens(): Screen[] { + return []; + } - protected allActions(): Action[] { - return [ - this.selectFundingRoundAction, - this.selectProposalAction, - this.updateProposalStatusAction, - ]; - } + protected allActions(): Action[] { + return [this.selectFundingRoundAction, this.selectProposalAction, this.updateProposalStatusAction]; + } - protected async getResponse(interaction: TrackedInteraction): Promise { - const embed = new EmbedBuilder() - .setColor('#0099ff') - .setTitle('Manage Proposal Statuses') - .setDescription('Select a Funding Round to manage proposal statuses:'); + protected async getResponse(interaction: TrackedInteraction): Promise { + const embed = new EmbedBuilder() + .setColor('#0099ff') + .setTitle('Manage Proposal Statuses') + .setDescription('Select a Funding Round to manage proposal statuses:'); - const selectFundingRoundButton = this.selectFundingRoundAction.getComponent(); + const selectFundingRoundButton = this.selectFundingRoundAction.getComponent(); - const row = new ActionRowBuilder().addComponents(selectFundingRoundButton); + const row = new ActionRowBuilder().addComponents(selectFundingRoundButton); - return { - embeds: [embed], - components: [row], - ephemeral: true - }; - } + return { + embeds: [embed], + components: [row], + ephemeral: true, + }; + } } export class SelectFundingRoundAction extends Action { - public static readonly ID = 'selectFundingRound'; - - private activeFundingRoundPaginator: ActiveFundingRoundPaginator; - - constructor(screen: ManageProposalStatusesScreen) { - super(screen, SelectFundingRoundAction.ID); - this.activeFundingRoundPaginator = new ActiveFundingRoundPaginator( - this.screen, - this, - 'selectFundingRound', - ActiveFundingRoundPaginator.ID - ); + public static readonly ID = 'selectFundingRound'; + + private activeFundingRoundPaginator: ActiveFundingRoundPaginator; + + constructor(screen: ManageProposalStatusesScreen) { + super(screen, SelectFundingRoundAction.ID); + this.activeFundingRoundPaginator = new ActiveFundingRoundPaginator(this.screen, this, 'selectFundingRound', ActiveFundingRoundPaginator.ID); + } + + protected async handleOperation(interaction: TrackedInteraction, operationId: string): Promise { + switch (operationId) { + case 'showFundingRounds': + case PaginationComponent.PAGINATION_ARG: + await this.handleShowFundingRounds(interaction); + break; + case 'selectFundingRound': + await this.handleSelectFundingRound(interaction); + break; + default: + await this.handleInvalidOperation(interaction, operationId); } + } - protected async handleOperation(interaction: TrackedInteraction, operationId: string): Promise { - switch (operationId) { - case 'showFundingRounds': - case PaginationComponent.PAGINATION_ARG: - await this.handleShowFundingRounds(interaction); - break; - case 'selectFundingRound': - await this.handleSelectFundingRound(interaction); - break; - default: - await this.handleInvalidOperation(interaction, operationId); - } - } + private async handleShowFundingRounds(interaction: TrackedInteraction): Promise { + await this.activeFundingRoundPaginator.handlePagination(interaction); + } - private async handleShowFundingRounds(interaction: TrackedInteraction): Promise { - await this.activeFundingRoundPaginator.handlePagination(interaction); - } + private async handleSelectFundingRound(interaction: TrackedInteraction): Promise { + const parsedInteraction: AnyInteractionWithValues | undefined = InteractionProperties.toInteractionWithValuesOrUndefined(interaction.interaction); - private async handleSelectFundingRound(interaction: TrackedInteraction): Promise { - const parsedInteraction: AnyInteractionWithValues | undefined = InteractionProperties.toInteractionWithValuesOrUndefined(interaction.interaction); + if (!parsedInteraction) { + await DiscordStatus.Error.error(interaction, 'Interaction does not have values'); + throw new EndUserError('Interaction does not have values'); + } - if (!parsedInteraction) { - await DiscordStatus.Error.error(interaction, 'Interaction does not have values'); - throw new EndUserError('Interaction does not have values'); - } + const fundingRoundId = parsedInteraction.values[0]; - const fundingRoundId = parsedInteraction.values[0]; + await (this.screen as ManageProposalStatusesScreen).selectProposalAction.renderHandleShowProposals(interaction, fundingRoundId); + } - await (this.screen as ManageProposalStatusesScreen).selectProposalAction.renderHandleShowProposals(interaction, fundingRoundId); - } + public allSubActions(): Action[] { + return []; + } - public allSubActions(): Action[] { - return []; - } - - getComponent(): ButtonBuilder { - return new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, 'showFundingRounds')) - .setLabel('Select Funding Round') - .setStyle(ButtonStyle.Primary); - } + getComponent(): ButtonBuilder { + return new ButtonBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction(this, 'showFundingRounds')) + .setLabel('Select Funding Round') + .setStyle(ButtonStyle.Primary); + } } export class SelectProposalAction extends Action { - public static readonly ID = 'selectProposal'; - - - public static readonly OPERATIONS = { - showProposals: 'showProposals', - selectProposal: 'selectProposal' - } - - private manageProposalStatusesPaginator: ManageProposalStatusesPaginator; - - constructor(screen: ManageProposalStatusesScreen) { - super(screen, SelectProposalAction.ID); - this.manageProposalStatusesPaginator = new ManageProposalStatusesPaginator( - this.screen, - this, - 'selectProposal', - ManageProposalStatusesPaginator.ID - ); + public static readonly ID = 'selectProposal'; + + public static readonly OPERATIONS = { + showProposals: 'showProposals', + selectProposal: 'selectProposal', + }; + + private manageProposalStatusesPaginator: ManageProposalStatusesPaginator; + + constructor(screen: ManageProposalStatusesScreen) { + super(screen, SelectProposalAction.ID); + this.manageProposalStatusesPaginator = new ManageProposalStatusesPaginator( + this.screen, + this, + 'selectProposal', + ManageProposalStatusesPaginator.ID, + ); + } + + protected async handleOperation(interaction: TrackedInteraction, operationId: string): Promise { + switch (operationId) { + case 'showProposals': + case PaginationComponent.PAGINATION_ARG: + await this.handleShowProposals(interaction); + break; + case 'selectProposal': + await this.handleSelectProposal(interaction); + break; + default: + await this.handleInvalidOperation(interaction, operationId); } + } - protected async handleOperation(interaction: TrackedInteraction, operationId: string): Promise { - switch (operationId) { - case 'showProposals': - case PaginationComponent.PAGINATION_ARG: - await this.handleShowProposals(interaction); - break; - case 'selectProposal': - await this.handleSelectProposal(interaction); - break; - default: - await this.handleInvalidOperation(interaction, operationId); - } - } + private async handleShowProposals(interaction: TrackedInteraction): Promise { + return await this.renderHandleShowProposals(interaction); + } + public async renderHandleShowProposals(interaction: TrackedInteraction, frId?: string): Promise { + const fundingRoundId = frId || CustomIDOracle.getNamedArgument(interaction.customId, 'frId'); - private async handleShowProposals(interaction: TrackedInteraction): Promise { - return await this.renderHandleShowProposals(interaction); + if (!fundingRoundId) { + await DiscordStatus.Error.error(interaction, 'Funding Round ID not found'); + throw new EndUserError('Funding Round ID not found'); } - public async renderHandleShowProposals(interaction: TrackedInteraction, frId?: string): Promise { - const fundingRoundId = frId || CustomIDOracle.getNamedArgument(interaction.customId, 'frId'); - - if (!fundingRoundId) { - await DiscordStatus.Error.error(interaction, 'Funding Round ID not found'); - throw new EndUserError('Funding Round ID not found'); - } + interaction.Context.set(ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, fundingRoundId); + await this.manageProposalStatusesPaginator.handlePagination(interaction); + } - interaction.Context.set(ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, fundingRoundId); - await this.manageProposalStatusesPaginator.handlePagination(interaction); + private async handleSelectProposal(interaction: TrackedInteraction): Promise { + const parsedInteraction = InteractionProperties.toInteractionWithValuesOrUndefined(interaction.interaction); + if (!parsedInteraction) { + throw new EndUserError('Invalid interaction type.'); } - private async handleSelectProposal(interaction: TrackedInteraction): Promise { - const parsedInteraction = InteractionProperties.toInteractionWithValuesOrUndefined(interaction.interaction); - if (!parsedInteraction) { - throw new EndUserError('Invalid interaction type.'); - } + const proposalId = parsedInteraction.values[0]; + interaction.Context.set('prId', proposalId); - const proposalId = parsedInteraction.values[0]; - interaction.Context.set('proposalId', proposalId); + await (this.screen as ManageProposalStatusesScreen).updateProposalStatusAction.renderShowStatusOptions(interaction, proposalId); + } - await (this.screen as ManageProposalStatusesScreen).updateProposalStatusAction.renderShowStatusOptions(interaction, proposalId); - } - - public allSubActions(): Action[] { - return []; - } + public allSubActions(): Action[] { + return []; + } - getComponent(fundingRoundId: string): ButtonBuilder { - return new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, 'showProposals', 'frId', fundingRoundId)) - .setLabel('Select Proposal') - .setStyle(ButtonStyle.Primary); - } + getComponent(fundingRoundId: string): ButtonBuilder { + return new ButtonBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction(this, 'showProposals', 'frId', fundingRoundId)) + .setLabel('Select Proposal') + .setStyle(ButtonStyle.Primary); + } } export class UpdateProposalStatusAction extends Action { - public static readonly ID = 'updateProposalStatus'; - - protected async handleOperation(interaction: TrackedInteraction, operationId: string): Promise { - switch (operationId) { - case 'showStatusOptions': - await this.handleShowStatusOptions(interaction); - break; - case 'updateStatus': - await this.handleUpdateStatus(interaction); - break; - default: - await this.handleInvalidOperation(interaction, operationId); - } + public static readonly ID = 'updateProposalStatus'; + + protected async handleOperation(interaction: TrackedInteraction, operationId: string): Promise { + switch (operationId) { + case 'showStatusOptions': + await this.handleShowStatusOptions(interaction); + break; + case 'updateStatus': + await this.handleUpdateStatus(interaction); + break; + default: + await this.handleInvalidOperation(interaction, operationId); } + } - public async renderShowStatusOptions(interaction: TrackedInteraction, pId?: string): Promise { - - const proposalIdFromCntx: string | undefined = interaction.Context.get('proposalId'); - const proposalIdFromCustomId = CustomIDOracle.getNamedArgument(interaction.customId, 'proposalId'); - - const proposalId: string | undefined = pId || proposalIdFromCntx || proposalIdFromCustomId; - - if (!proposalId) { - await DiscordStatus.Error.error(interaction, 'Proposal ID not found in customId, context or arg.'); - return; - } - - const proposal = await AdminProposalLogic.getProposalById(parseInt(proposalId)); - if (!proposal) { - await DiscordStatus.Error.error(interaction, 'Proposal not found'); - return; - } + public async renderShowStatusOptions(interaction: TrackedInteraction, pId?: string): Promise { + const proposalIdFromCntx: string | undefined = interaction.Context.get('prId'); + const proposalIdFromCustomId = CustomIDOracle.getNamedArgument(interaction.customId, 'prId'); - const statusOptions = Object.values(ProposalStatus).filter(status => status !== proposal.status); - - const selectMenu = new StringSelectMenuBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, 'updateStatus', 'proposalId', proposalId)) - .setPlaceholder('Select new status') - .addOptions(statusOptions.map(status => ({ - label: status, - value: status, - description: `Change status to ${status}` - }))); - - const embed = new EmbedBuilder() - .setColor('#0099ff') - .setTitle(`Update Proposal Status: ${proposal.name}`) - .setDescription(`Current status: ${proposal.status}`) - .addFields( - { name: 'ID', value: proposal.id.toString(), inline: true }, - { name: 'URL', value: proposal.uri, inline: true }, - { name: 'Budget', value: proposal.budget.toString(), inline: true }, - { name: 'Proposer', value: proposal.proposerDuid, inline: true } - ); - - const row = new ActionRowBuilder().addComponents(selectMenu); - - await interaction.update({ embeds: [embed], components: [row] }); + const proposalId: string | undefined = pId || proposalIdFromCntx || proposalIdFromCustomId; + if (!proposalId) { + await DiscordStatus.Error.error(interaction, 'Proposal ID not found in customId, context or arg.'); + return; } - private async handleShowStatusOptions(interaction: TrackedInteraction): Promise { - await this.renderShowStatusOptions(interaction); + const proposal = await AdminProposalLogic.getProposalById(parseInt(proposalId)); + if (!proposal) { + await DiscordStatus.Error.error(interaction, 'Proposal not found'); + return; } - private async handleUpdateStatus(interaction: TrackedInteraction): Promise { - const parsedInteraction: AnyInteractionWithValues | undefined = InteractionProperties.toInteractionWithValuesOrUndefined(interaction.interaction); - - if (!parsedInteraction) { - await DiscordStatus.Error.error(interaction, 'Interaction does not have values'); - throw new EndUserError('Interaction does not have values'); - } - - const proposalIdFromCustomId: string | undefined = CustomIDOracle.getNamedArgument(interaction.customId, 'proposalId'); - const proposalIdFromContext: string | undefined = interaction.Context.get('proposalId'); - - const proposalId: string | undefined = proposalIdFromCustomId || proposalIdFromContext; - if (!proposalId) { - await DiscordStatus.Error.error(interaction, 'Proposal ID not found in customId or context.'); - return; - } - - const newStatus = parsedInteraction.values[0] as ProposalStatus; - - const updatedProposal = await AdminProposalLogic.updateProposalStatus(parseInt(proposalId), newStatus, this.screen); - - const embed = new EmbedBuilder() - .setColor('#00FF00') - .setTitle(`Proposal Status Updated: ${updatedProposal.name}`) - .setDescription(`New status: ${updatedProposal.status}`) - .addFields( - { name: 'ID', value: updatedProposal.id.toString(), inline: true }, - { name: 'URL', value: updatedProposal.uri, inline: true }, - { name: 'Budget', value: updatedProposal.budget.toString(), inline: true }, - { name: 'Proposer', value: updatedProposal.proposerDuid, inline: true } - ); + const statusOptions = Object.values(ProposalStatus).filter((status) => status !== proposal.status); + + const selectMenu = new StringSelectMenuBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction(this, 'updateStatus', 'prId', proposalId)) + .setPlaceholder('Select new status') + .addOptions( + statusOptions.map((status) => ({ + label: status, + value: status, + description: `Change status to ${status}`, + })), + ); + + const embed = new EmbedBuilder() + .setColor('#0099ff') + .setTitle(`Update Proposal Status: ${proposal.name}`) + .setDescription(`Current status: ${proposal.status}`) + .addFields( + { name: 'ID', value: proposal.id.toString(), inline: true }, + { name: 'URL', value: proposal.uri, inline: true }, + { name: 'Budget', value: proposal.budget.toString(), inline: true }, + { name: 'Proposer', value: proposal.proposerDuid, inline: true }, + ); + + const row = new ActionRowBuilder().addComponents(selectMenu); + + await interaction.update({ embeds: [embed], components: [row] }); + } + + private async handleShowStatusOptions(interaction: TrackedInteraction): Promise { + await this.renderShowStatusOptions(interaction); + } + + private async handleUpdateStatus(interaction: TrackedInteraction): Promise { + const parsedInteraction: AnyInteractionWithValues | undefined = InteractionProperties.toInteractionWithValuesOrUndefined(interaction.interaction); + + if (!parsedInteraction) { + await DiscordStatus.Error.error(interaction, 'Interaction does not have values'); + throw new EndUserError('Interaction does not have values'); + } - const selectPropAction: SelectProposalAction = (this.screen as ManageProposalStatusesScreen).selectProposalAction; + const proposalIdFromCustomId: string | undefined = CustomIDOracle.getNamedArgument(interaction.customId, 'prId'); + const proposalIdFromContext: string | undefined = interaction.Context.get('prId'); - if (!updatedProposal.fundingRoundId) { - await DiscordStatus.Warning.warning(interaction, `Proposal does not have a Funding Round associated`); - throw new EndUserError(`Proposal ${updatedProposal.id} does not have a Funding Round associated`); - } + const proposalId: string | undefined = proposalIdFromCustomId || proposalIdFromContext; + if (!proposalId) { + await DiscordStatus.Error.error(interaction, 'Proposal ID not found in customId or context.'); + return; + } - const backButton = new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(selectPropAction, SelectProposalAction.OPERATIONS.showProposals, 'frId', updatedProposal.fundingRoundId.toString())) - .setLabel('Update Status Again') - .setStyle(ButtonStyle.Primary); + const newStatus = parsedInteraction.values[0] as ProposalStatus; - const row = new ActionRowBuilder().addComponents(backButton); + const updatedProposal = await AdminProposalLogic.updateProposalStatus(parseInt(proposalId), newStatus, this.screen); - await interaction.update({ embeds: [embed], components: [row] }); + const embed = new EmbedBuilder() + .setColor('#00FF00') + .setTitle(`Proposal Status Updated: ${updatedProposal.name}`) + .setDescription(`New status: ${updatedProposal.status}`) + .addFields( + { name: 'ID', value: updatedProposal.id.toString(), inline: true }, + { name: 'URL', value: updatedProposal.uri, inline: true }, + { name: 'Budget', value: updatedProposal.budget.toString(), inline: true }, + { name: 'Proposer', value: updatedProposal.proposerDuid, inline: true }, + ); - } + const selectPropAction: SelectProposalAction = (this.screen as ManageProposalStatusesScreen).selectProposalAction; - public allSubActions(): Action[] { - return []; + if (!updatedProposal.fundingRoundId) { + await DiscordStatus.Warning.warning(interaction, `Proposal does not have a Funding Round associated`); + throw new EndUserError(`Proposal ${updatedProposal.id} does not have a Funding Round associated`); } - getComponent(): ButtonBuilder { - return new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, 'showStatusOptions')) - .setLabel('Update Proposal Status') - .setStyle(ButtonStyle.Primary); - } -} \ No newline at end of file + const backButton = new ButtonBuilder() + .setCustomId( + CustomIDOracle.addArgumentsToAction( + selectPropAction, + SelectProposalAction.OPERATIONS.showProposals, + 'frId', + updatedProposal.fundingRoundId.toString(), + ), + ) + .setLabel('Update Status Again') + .setStyle(ButtonStyle.Primary); + + const row = new ActionRowBuilder().addComponents(backButton); + + await interaction.update({ embeds: [embed], components: [row] }); + } + + public allSubActions(): Action[] { + return []; + } + + getComponent(): ButtonBuilder { + return new ButtonBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction(this, 'showStatusOptions')) + .setLabel('Update Proposal Status') + .setStyle(ButtonStyle.Primary); + } +} diff --git a/src/channels/propose/screens/ProposalHomeScreen.ts b/src/channels/propose/screens/ProposalHomeScreen.ts index 355ef55..41a4ab6 100644 --- a/src/channels/propose/screens/ProposalHomeScreen.ts +++ b/src/channels/propose/screens/ProposalHomeScreen.ts @@ -1,5 +1,17 @@ import { Screen, Action, Dashboard, Permission, TrackedInteraction, RenderArgs } from '../../../core/BaseClasses'; -import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, MessageActionRowComponentBuilder, MessageCreateOptions, ModalBuilder, StringSelectMenuBuilder, TextChannel, TextInputBuilder, TextInputStyle } from 'discord.js'; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + MessageActionRowComponentBuilder, + MessageCreateOptions, + ModalBuilder, + StringSelectMenuBuilder, + TextChannel, + TextInputBuilder, + TextInputStyle, +} from 'discord.js'; import { ArgumentOracle, CustomIDOracle } from '../../../CustomIDOracle'; import { AnyInteractionWithShowModal, AnyInteractionWithValues, IHomeScreen } from '../../../types/common'; import { ProposalLogic } from '../../../logic/ProposalLogic'; @@ -14,963 +26,1020 @@ import logger from '../../../logging'; import { EditMySubmittedProposalsPaginator } from '../../../components/ProposalsPaginator'; export class ProposalHomeScreen extends Screen implements IHomeScreen { - - public static readonly ID = 'proposalHome'; - - protected permissions: Permission[] = []; // Add appropriate permissions if needed - - public readonly manageSubmittedProposalsAction: ManageSubmittedProposalsAction; - public readonly manageDraftsAction: ManageDraftsAction; - public readonly createNewProposalAction: CreateNewProposalAction; - public readonly submitProposalToFundingRoundAction: SubmitProposalToFundingRoundAction; - - constructor(dashboard: Dashboard, screenId: string) { - super(dashboard, screenId); - this.manageSubmittedProposalsAction = new ManageSubmittedProposalsAction(this, ManageSubmittedProposalsAction.ID); - this.manageDraftsAction = new ManageDraftsAction(this, ManageDraftsAction.ID); - this.createNewProposalAction = new CreateNewProposalAction(this, CreateNewProposalAction.ID); - this.submitProposalToFundingRoundAction = new SubmitProposalToFundingRoundAction(this, SubmitProposalToFundingRoundAction.ID); + public static readonly ID = 'proposalHome'; + + protected permissions: Permission[] = []; // Add appropriate permissions if needed + + public readonly manageSubmittedProposalsAction: ManageSubmittedProposalsAction; + public readonly manageDraftsAction: ManageDraftsAction; + public readonly createNewProposalAction: CreateNewProposalAction; + public readonly submitProposalToFundingRoundAction: SubmitProposalToFundingRoundAction; + + constructor(dashboard: Dashboard, screenId: string) { + super(dashboard, screenId); + this.manageSubmittedProposalsAction = new ManageSubmittedProposalsAction(this, ManageSubmittedProposalsAction.ID); + this.manageDraftsAction = new ManageDraftsAction(this, ManageDraftsAction.ID); + this.createNewProposalAction = new CreateNewProposalAction(this, CreateNewProposalAction.ID); + this.submitProposalToFundingRoundAction = new SubmitProposalToFundingRoundAction(this, SubmitProposalToFundingRoundAction.ID); + } + + public async renderToTextChannel(channel: TextChannel): Promise { + const content: MessageCreateOptions = await this.getResponse(); + await channel.send(content); + } + + protected allSubScreens(): Screen[] { + return []; + } + + protected allActions(): Action[] { + return [this.manageSubmittedProposalsAction, this.manageDraftsAction, this.createNewProposalAction, this.submitProposalToFundingRoundAction]; + } + + protected async getResponse(interaction?: TrackedInteraction, args?: RenderArgs): Promise { + const embed = new EmbedBuilder() + .setColor('#0099ff') + .setTitle('Proposal Management') + .setDescription('Welcome to the Proposal Management channel. Here you can create, manage, and submit proposals for funding rounds.'); + + const manageSubmittedButton = this.manageSubmittedProposalsAction.getComponent(); + const manageDraftsButton = this.manageDraftsAction.getComponent(); + const createNewButton = this.createNewProposalAction.getComponent(); + const submitToFundingRoundButton = this.submitProposalToFundingRoundAction.getComponent(); + + const row = new ActionRowBuilder().addComponents( + manageSubmittedButton, + manageDraftsButton, + createNewButton, + submitToFundingRoundButton, + ); + + const components = [row]; + + if (args?.successMessage) { + const successEmbed = new EmbedBuilder().setColor('#28a745').setDescription(args.successMessage); + return { + embeds: [embed, successEmbed], + components, + ephemeral: true, + }; + } else if (args?.errorMessage) { + const errorEmbed = new EmbedBuilder().setColor('#dc3545').setDescription(args.errorMessage); + return { + embeds: [embed, errorEmbed], + components, + ephemeral: true, + }; } - public async renderToTextChannel(channel: TextChannel): Promise { - const content: MessageCreateOptions = await this.getResponse(); - await channel.send(content); + return { + embeds: [embed], + components, + ephemeral: true, + }; + } +} +export class ManageSubmittedProposalsAction extends PaginationComponent { + public static readonly ID: string = 'manageSubmittedProposals'; + + public editSubmittedProposalsPaginator = new EditMySubmittedProposalsPaginator( + this.screen, + this, + ManageSubmittedProposalsAction.OPERATIONS.SHOW_PROPOSAL_DETAILS, + EditMySubmittedProposalsPaginator.ID, + ); + public static readonly OPERATIONS = { + SHOW_FUNDING_ROUNDS: 'showFundingRounds', + SELECT_FUNDING_ROUND: 'selectFundingRound', + SHOW_PROPOSALS: 'showProposals', + SHOW_PROPOSAL_DETAILS: 'showProposalDetails', + CONFIRM_CANCEL_PROPOSAL: 'confirmCancelProposal', + EXECUTE_CANCEL_PROPOSAL: 'executeCancelProposal', + }; + + protected async getTotalPages(interaction: TrackedInteraction): Promise { + const fundingRounds: FundingRound[] = await FundingRoundLogic.getFundingRoundsWithUserProposals(interaction.interaction.user.id); + return Math.ceil(fundingRounds.length / 25); + } + + protected async getItemsForPage(interaction: TrackedInteraction, page: number): Promise { + const fundingRounds: FundingRound[] = await FundingRoundLogic.getFundingRoundsWithUserProposals(interaction.interaction.user.id); + return fundingRounds.slice(page * 25, (page + 1) * 25); + } + + protected async handleOperation(interaction: TrackedInteraction, operationId: string): Promise { + switch (operationId) { + case ManageSubmittedProposalsAction.OPERATIONS.SHOW_FUNDING_ROUNDS: + await this.handleShowFundingRounds(interaction); + break; + case ManageSubmittedProposalsAction.OPERATIONS.SELECT_FUNDING_ROUND: + await this.handleSelectFundingRound(interaction); + break; + case ManageSubmittedProposalsAction.OPERATIONS.SHOW_PROPOSALS: + await this.handleShowProposals(interaction); + break; + case ManageSubmittedProposalsAction.OPERATIONS.SHOW_PROPOSAL_DETAILS: + await this.handleShowProposalDetails(interaction); + break; + case ManageSubmittedProposalsAction.OPERATIONS.CONFIRM_CANCEL_PROPOSAL: + await this.handleConfirmCancelProposal(interaction); + break; + case ManageSubmittedProposalsAction.OPERATIONS.EXECUTE_CANCEL_PROPOSAL: + await this.handleExecuteCancelProposal(interaction); + break; + default: + await this.handleInvalidOperation(interaction, operationId); } - - protected allSubScreens(): Screen[] { - return []; + } + + private async handleShowFundingRounds(interaction: TrackedInteraction): Promise { + const currentPage: number = this.getCurrentPage(interaction); + const totalPages: number = await this.getTotalPages(interaction); + const fundingRounds: FundingRound[] = await this.getItemsForPage(interaction, currentPage); + + const embed = new EmbedBuilder() + .setColor('#0099ff') + .setTitle('Select A Funding Round') + .setDescription('To proceed, please select a funding round from the list below.'); + + const selectMenu: StringSelectMenuBuilder = new StringSelectMenuBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction(this, ManageSubmittedProposalsAction.OPERATIONS.SELECT_FUNDING_ROUND)) + .setPlaceholder('Select a Funding Round') + .addOptions( + fundingRounds.map((fr: FundingRound) => ({ + label: `${fr.name}${fr.endAt < new Date() ? ' (Ended)' : ''}`, + value: fr.id.toString(), + description: `Budget: ${fr.budget}, Status: ${fr.status}`, + })), + ); + + const row: ActionRowBuilder = new ActionRowBuilder().addComponents(selectMenu); + const components: ActionRowBuilder[] = [row]; + + if (totalPages > 1) { + const paginationRow: ActionRowBuilder = this.getPaginationRow(interaction, currentPage, totalPages); + components.push(paginationRow); } - protected allActions(): Action[] { - return [ - this.manageSubmittedProposalsAction, - this.manageDraftsAction, - this.createNewProposalAction, - this.submitProposalToFundingRoundAction, - ]; + await interaction.respond({ embeds: [embed], components }); + } + + private async handleSelectFundingRound(interaction: TrackedInteraction): Promise { + const interactionWithValues: AnyInteractionWithValues | undefined = InteractionProperties.toInteractionWithValuesOrUndefined( + interaction.interaction, + ); + if (!interactionWithValues) { + throw new EndUserError('Invalid interaction type.'); } - protected async getResponse(interaction?: TrackedInteraction, args?: RenderArgs): Promise { - const embed = new EmbedBuilder() - .setColor('#0099ff') - .setTitle('Proposal Management') - .setDescription('Welcome to the Proposal Management channel. Here you can create, manage, and submit proposals for funding rounds.'); + const fundingRoundId: number = parseInt(interactionWithValues.values[0]); + interaction.Context.set(ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, fundingRoundId.toString()); + await this.handleShowProposals(interaction); + } - const manageSubmittedButton = this.manageSubmittedProposalsAction.getComponent(); - const manageDraftsButton = this.manageDraftsAction.getComponent(); - const createNewButton = this.createNewProposalAction.getComponent(); - const submitToFundingRoundButton = this.submitProposalToFundingRoundAction.getComponent(); + private async handleShowProposals(interaction: TrackedInteraction): Promise { + await this.editSubmittedProposalsPaginator.handlePagination(interaction); + } - const row = new ActionRowBuilder() - .addComponents(manageSubmittedButton, manageDraftsButton, createNewButton, submitToFundingRoundButton); + private async handleShowProposalDetails(interaction: TrackedInteraction): Promise { + const proposalId: number = parseInt(ArgumentOracle.getNamedArgument(interaction, 'prId', 0)); + const fundingRoundId: number = parseInt(ArgumentOracle.getNamedArgument(interaction, 'frId')); - const components = [row]; + const proposal: Proposal | null = await ProposalLogic.getProposalById(proposalId); + const fundingRound: FundingRound | null = await FundingRoundLogic.getFundingRoundById(fundingRoundId); - if (args?.successMessage) { - const successEmbed = new EmbedBuilder() - .setColor('#28a745') - .setDescription(args.successMessage); - return { - embeds: [embed, successEmbed], - components, - ephemeral: true - }; - } else if (args?.errorMessage) { - const errorEmbed = new EmbedBuilder() - .setColor('#dc3545') - .setDescription(args.errorMessage); - return { - embeds: [embed, errorEmbed], - components, - ephemeral: true - }; - } + if (!proposal || !fundingRound) { + throw new EndUserError('Proposal or Funding Round not found.'); + } - return { - embeds: [embed], - components, - ephemeral: true - }; + const embed: EmbedBuilder = new EmbedBuilder() + .setColor('#0099ff') + .setTitle(`Proposal: ${proposal.name}`) + .addFields( + { name: 'Proposal Details', value: `Budget: ${proposal.budget}\nStatus: ${proposal.status}\nURI: ${proposal.uri}` }, + { name: 'Funding Round', value: `Name: ${fundingRound.name}\nBudget: ${fundingRound.budget}\nStatus: ${fundingRound.status}` }, + ); + + const cancelButton: ButtonBuilder = new ButtonBuilder() + .setCustomId( + CustomIDOracle.addArgumentsToAction( + this, + ManageSubmittedProposalsAction.OPERATIONS.CONFIRM_CANCEL_PROPOSAL, + 'prId', + proposalId.toString(), + 'frId', + fundingRoundId.toString(), + ), + ) + .setLabel('Cancel My Proposal') + .setStyle(ButtonStyle.Danger); + + if (proposal.status === ProposalStatus.CANCELLED) { + cancelButton.setDisabled(true); } -} -export class ManageSubmittedProposalsAction extends PaginationComponent { - public static readonly ID: string = 'manageSubmittedProposals'; + const row: ActionRowBuilder = new ActionRowBuilder().addComponents(cancelButton); + await interaction.respond({ embeds: [embed], components: [row], ephemeral: true }); + } - public editSubmittedProposalsPaginator = new EditMySubmittedProposalsPaginator(this.screen, this, ManageSubmittedProposalsAction.OPERATIONS.SHOW_PROPOSAL_DETAILS, EditMySubmittedProposalsPaginator.ID) - public static readonly OPERATIONS = { - SHOW_FUNDING_ROUNDS: 'showFundingRounds', - SELECT_FUNDING_ROUND: 'selectFundingRound', - SHOW_PROPOSALS: 'showProposals', - SHOW_PROPOSAL_DETAILS: 'showProposalDetails', - CONFIRM_CANCEL_PROPOSAL: 'confirmCancelProposal', - EXECUTE_CANCEL_PROPOSAL: 'executeCancelProposal', - }; + private async handleConfirmCancelProposal(interaction: TrackedInteraction): Promise { + const proposalId: string | undefined = CustomIDOracle.getNamedArgument(interaction.customId, 'prId'); + const fundingRoundId: string | undefined = CustomIDOracle.getNamedArgument(interaction.customId, 'frId'); + if (!proposalId || !fundingRoundId) { + throw new EndUserError('Invalid proposal or funding round ID.'); + } + const proposal: Proposal | null = await ProposalLogic.getProposalById(parseInt(proposalId)); + const fundingRound: FundingRound | null = await FundingRoundLogic.getFundingRoundById(parseInt(fundingRoundId)); - protected async getTotalPages(interaction: TrackedInteraction): Promise { - const fundingRounds: FundingRound[] = await FundingRoundLogic.getFundingRoundsWithUserProposals(interaction.interaction.user.id); - return Math.ceil(fundingRounds.length / 25); - } - - protected async getItemsForPage(interaction: TrackedInteraction, page: number): Promise { - const fundingRounds: FundingRound[] = await FundingRoundLogic.getFundingRoundsWithUserProposals(interaction.interaction.user.id); - return fundingRounds.slice(page * 25, (page + 1) * 25); - } - - protected async handleOperation(interaction: TrackedInteraction, operationId: string): Promise { - switch (operationId) { - case ManageSubmittedProposalsAction.OPERATIONS.SHOW_FUNDING_ROUNDS: - await this.handleShowFundingRounds(interaction); - break; - case ManageSubmittedProposalsAction.OPERATIONS.SELECT_FUNDING_ROUND: - await this.handleSelectFundingRound(interaction); - break; - case ManageSubmittedProposalsAction.OPERATIONS.SHOW_PROPOSALS: - await this.handleShowProposals(interaction); - break; - case ManageSubmittedProposalsAction.OPERATIONS.SHOW_PROPOSAL_DETAILS: - await this.handleShowProposalDetails(interaction); - break; - case ManageSubmittedProposalsAction.OPERATIONS.CONFIRM_CANCEL_PROPOSAL: - await this.handleConfirmCancelProposal(interaction); - break; - case ManageSubmittedProposalsAction.OPERATIONS.EXECUTE_CANCEL_PROPOSAL: - await this.handleExecuteCancelProposal(interaction); - break; - default: - await this.handleInvalidOperation(interaction, operationId); - } - } - - private async handleShowFundingRounds(interaction: TrackedInteraction): Promise { - const currentPage: number = this.getCurrentPage(interaction); - const totalPages: number = await this.getTotalPages(interaction); - const fundingRounds: FundingRound[] = await this.getItemsForPage(interaction, currentPage); - - const embed = new EmbedBuilder() - .setColor('#0099ff') - .setTitle('Select A Funding Round') - .setDescription('To proceed, please select a funding round from the list below.'); - - const selectMenu: StringSelectMenuBuilder = new StringSelectMenuBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, ManageSubmittedProposalsAction.OPERATIONS.SELECT_FUNDING_ROUND)) - .setPlaceholder('Select a Funding Round') - .addOptions(fundingRounds.map((fr: FundingRound) => ({ - label: `${fr.name}${fr.endAt < new Date() ? ' (Ended)' : ''}`, - value: fr.id.toString(), - description: `Budget: ${fr.budget}, Status: ${fr.status}` - }))); - - const row: ActionRowBuilder = new ActionRowBuilder().addComponents(selectMenu); - const components: ActionRowBuilder[] = [row]; - - if (totalPages > 1) { - const paginationRow: ActionRowBuilder = this.getPaginationRow(interaction, currentPage, totalPages); - components.push(paginationRow); - } - - await interaction.respond({ embeds: [embed], components }); - } - - private async handleSelectFundingRound(interaction: TrackedInteraction): Promise { - const interactionWithValues: AnyInteractionWithValues | undefined = InteractionProperties.toInteractionWithValuesOrUndefined(interaction.interaction); - if (!interactionWithValues) { - throw new EndUserError('Invalid interaction type.'); - } - - const fundingRoundId: number = parseInt(interactionWithValues.values[0]); - interaction.Context.set(ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, fundingRoundId.toString()); - await this.handleShowProposals(interaction); + if (!proposal || !fundingRound) { + throw new EndUserError('Proposal or Funding Round not found.'); } - private async handleShowProposals(interaction: TrackedInteraction): Promise { - await this.editSubmittedProposalsPaginator.handlePagination(interaction) + const embed: EmbedBuilder = new EmbedBuilder() + .setColor('#FF0000') + .setTitle('Confirm Proposal Cancellation') + .setDescription('Are you sure you want to cancel this proposal? This action cannot be undone.') + .addFields( + { name: 'Proposal Details', value: `Name: ${proposal.name}\nBudget: ${proposal.budget}\nStatus: ${proposal.status}\nURI: ${proposal.uri}` }, + { name: 'Funding Round', value: `Name: ${fundingRound.name}\nBudget: ${fundingRound.budget}\nStatus: ${fundingRound.status}` }, + ) + .setFooter({ + text: 'Once cancelled, this proposal cannot be re-submitted to the funding round. You will need to create a new proposal if you want to submit again.', + }); + + const confirmButton: ButtonBuilder = new ButtonBuilder() + .setCustomId( + CustomIDOracle.addArgumentsToAction( + this, + ManageSubmittedProposalsAction.OPERATIONS.EXECUTE_CANCEL_PROPOSAL, + 'prId', + proposalId, + 'frId', + fundingRoundId, + ), + ) + .setLabel('Confirm Cancellation') + .setStyle(ButtonStyle.Danger); + + const cancelButton: ButtonBuilder = new ButtonBuilder() + .setCustomId( + CustomIDOracle.addArgumentsToAction( + this, + ManageSubmittedProposalsAction.OPERATIONS.SHOW_PROPOSAL_DETAILS, + 'prId', + proposalId, + 'frId', + fundingRoundId, + ), + ) + .setLabel('Go Back') + .setStyle(ButtonStyle.Secondary); + + const row: ActionRowBuilder = new ActionRowBuilder().addComponents(confirmButton, cancelButton); + + await interaction.update({ embeds: [embed], components: [row] }); + } + + private async handleExecuteCancelProposal(interaction: TrackedInteraction): Promise { + const proposalId: string | undefined = CustomIDOracle.getNamedArgument(interaction.customId, 'prId'); + const fundingRoundId: string | undefined = CustomIDOracle.getNamedArgument(interaction.customId, 'frId'); + + if (!proposalId || !fundingRoundId) { + throw new EndUserError('Invalid proposal or funding round ID.'); } - private async handleShowProposalDetails(interaction: TrackedInteraction): Promise { - const interactionWithValues: AnyInteractionWithValues | undefined = InteractionProperties.toInteractionWithValuesOrUndefined(interaction.interaction); - if (!interactionWithValues) { - throw new EndUserError('Invalid interaction type.'); - } - - const proposalId: number = parseInt(interactionWithValues.values[0]); - const fundingRoundId: number = parseInt(CustomIDOracle.getNamedArgument(interaction.customId, 'frId') || ''); - - const proposal: Proposal | null = await ProposalLogic.getProposalById(proposalId); - const fundingRound: FundingRound | null = await FundingRoundLogic.getFundingRoundById(fundingRoundId); - - if (!proposal || !fundingRound) { - throw new EndUserError('Proposal or Funding Round not found.'); - } + try { + await ProposalLogic.cancelProposal(parseInt(proposalId), this.screen); - const embed: EmbedBuilder = new EmbedBuilder() - .setColor('#0099ff') - .setTitle(`Proposal: ${proposal.name}`) - .addFields( - { name: 'Proposal Details', value: `Budget: ${proposal.budget}\nStatus: ${proposal.status}\nURI: ${proposal.uri}` }, - { name: 'Funding Round', value: `Name: ${fundingRound.name}\nBudget: ${fundingRound.budget}\nStatus: ${fundingRound.status}` } - ); + const successEmbed: EmbedBuilder = new EmbedBuilder() + .setColor('#00FF00') + .setTitle('Proposal Cancelled Successfully') + .setDescription('Your proposal has been cancelled and cannot be re-submitted to this funding round.'); - const cancelButton: ButtonBuilder = new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, ManageSubmittedProposalsAction.OPERATIONS.CONFIRM_CANCEL_PROPOSAL, 'proposalId', proposalId.toString(), 'frId', fundingRoundId.toString())) - .setLabel('Cancel My Proposal') - .setStyle(ButtonStyle.Danger); + const backButton: ButtonBuilder = new ButtonBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction(this, ManageSubmittedProposalsAction.OPERATIONS.SHOW_PROPOSALS, 'frId', fundingRoundId)) + .setLabel('Back to My Proposals') + .setStyle(ButtonStyle.Primary); - if (proposal.status === ProposalStatus.CANCELLED) { - cancelButton.setDisabled(true); - } + const row: ActionRowBuilder = new ActionRowBuilder().addComponents(backButton); - const row: ActionRowBuilder = new ActionRowBuilder().addComponents(cancelButton); - - await interaction.respond({ embeds: [embed], components: [row], ephemeral: true }); + await interaction.update({ embeds: [successEmbed], components: [row] }); + } catch (error) { + throw new EndUserError('Error cancelling proposal', error); } + } + + public allSubActions(): Action[] { + return []; + } + + getComponent(): ButtonBuilder { + return new ButtonBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction(this, ManageSubmittedProposalsAction.OPERATIONS.SHOW_FUNDING_ROUNDS)) + .setLabel('Manage My Proposals') + .setStyle(ButtonStyle.Primary); + } +} - private async handleConfirmCancelProposal(interaction: TrackedInteraction): Promise { - const proposalId: string | undefined = CustomIDOracle.getNamedArgument(interaction.customId, 'proposalId'); - const fundingRoundId: string | undefined = CustomIDOracle.getNamedArgument(interaction.customId, 'frId'); - - if (!proposalId || !fundingRoundId) { - throw new EndUserError('Invalid proposal or funding round ID.'); - } - - const proposal: Proposal | null = await ProposalLogic.getProposalById(parseInt(proposalId)); - const fundingRound: FundingRound | null = await FundingRoundLogic.getFundingRoundById(parseInt(fundingRoundId)); - - if (!proposal || !fundingRound) { - throw new EndUserError('Proposal or Funding Round not found.'); - } +export class ManageDraftsAction extends PaginationComponent { + public static readonly ID = 'manageDrafts'; + + public static readonly OPERATIONS = { + SHOW_DRAFTS: 'showDrafts', + SELECT_DRAFT: 'selectDraft', + EDIT_DRAFT: 'editDraft', + SUBMIT_EDIT: 'submitEdit', + DELETE_DRAFT: 'deleteDraft', + CONFIRM_DELETE: 'confirmDelete', + SUBMIT_TO_FUNDING_ROUND: 'submitToFundingRound', + }; + + public static readonly INPUT_IDS = { + NAME: 'name', + DESCRIPTION: 'description', + BUDGET: 'budget', + URI: 'uri', + }; + + protected async getTotalPages(interaction: TrackedInteraction): Promise { + const drafts = await ProposalLogic.getUserDraftProposals(interaction.interaction.user.id); + return Math.ceil(drafts.length / 25); + } + + protected async getItemsForPage(interaction: TrackedInteraction, page: number): Promise { + const drafts = await ProposalLogic.getUserDraftProposals(interaction.interaction.user.id); + return drafts.slice(page * 25, (page + 1) * 25); + } + + protected async handleOperation(interaction: TrackedInteraction, operationId: string): Promise { + switch (operationId) { + case ManageDraftsAction.OPERATIONS.SHOW_DRAFTS: + await this.handleShowDrafts(interaction); + break; + case ManageDraftsAction.OPERATIONS.SELECT_DRAFT: + await this.handleSelectDraft(interaction); + break; + case ManageDraftsAction.OPERATIONS.EDIT_DRAFT: + await this.handleEditDraft(interaction); + break; + case ManageDraftsAction.OPERATIONS.SUBMIT_EDIT: + await this.handleSubmitEdit(interaction); + break; + case ManageDraftsAction.OPERATIONS.DELETE_DRAFT: + await this.handleDeleteDraft(interaction); + break; + case ManageDraftsAction.OPERATIONS.CONFIRM_DELETE: + await this.handleConfirmDelete(interaction); + break; + case ManageDraftsAction.OPERATIONS.SUBMIT_TO_FUNDING_ROUND: + await this.handleSubmitToFundingRound(interaction); + break; + default: + await this.handleInvalidOperation(interaction, operationId); + } + } - const embed: EmbedBuilder = new EmbedBuilder() - .setColor('#FF0000') - .setTitle('Confirm Proposal Cancellation') - .setDescription('Are you sure you want to cancel this proposal? This action cannot be undone.') - .addFields( - { name: 'Proposal Details', value: `Name: ${proposal.name}\nBudget: ${proposal.budget}\nStatus: ${proposal.status}\nURI: ${proposal.uri}` }, - { name: 'Funding Round', value: `Name: ${fundingRound.name}\nBudget: ${fundingRound.budget}\nStatus: ${fundingRound.status}` } - ) - .setFooter({ text: 'Once cancelled, this proposal cannot be re-submitted to the funding round. You will need to create a new proposal if you want to submit again.' }); + private async handleShowDrafts(interaction: TrackedInteraction): Promise { + const currentPage = this.getCurrentPage(interaction); + const totalPages = await this.getTotalPages(interaction); + const drafts: Proposal[] = await this.getItemsForPage(interaction, currentPage); - const confirmButton: ButtonBuilder = new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, ManageSubmittedProposalsAction.OPERATIONS.EXECUTE_CANCEL_PROPOSAL, 'proposalId', proposalId, 'frId', fundingRoundId)) - .setLabel('Confirm Cancellation') - .setStyle(ButtonStyle.Danger); + if (drafts.length === 0) { + throw new EndUserInfo('You have no draft proposals.'); + } - const cancelButton: ButtonBuilder = new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, ManageSubmittedProposalsAction.OPERATIONS.SHOW_PROPOSAL_DETAILS, 'proposalId', proposalId, 'frId', fundingRoundId)) - .setLabel('Go Back') - .setStyle(ButtonStyle.Secondary); + const embed = new EmbedBuilder() + .setColor('#0099ff') + .setTitle('Select a Draft Proposal') + .setDescription('To proceed, please select a draft proposal from the list below.'); + + const selectMenu = new StringSelectMenuBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction(this, ManageDraftsAction.OPERATIONS.SELECT_DRAFT)) + .setPlaceholder('Select a Draft Proposal') + .addOptions( + drafts.map((draft) => ({ + label: draft.name, + value: draft.id.toString(), + description: `Budget: ${draft.budget}`, + })), + ); + + const row = new ActionRowBuilder().addComponents(selectMenu); + const components = [row]; + + if (totalPages > 1) { + const paginationRow = this.getPaginationRow(interaction, currentPage, totalPages); + components.push(paginationRow); + } - const row: ActionRowBuilder = new ActionRowBuilder().addComponents(confirmButton, cancelButton); + const asUpdate = CustomIDOracle.getNamedArgument(interaction.customId, 'udt') === '1'; - await interaction.update({ embeds: [embed], components: [row] }); + if (asUpdate) { + await interaction.update({ embeds: [embed], components }); + } else { + await interaction.respond({ embeds: [embed], components }); + } + } + + private async handleSelectDraft(interaction: TrackedInteraction, asUpdate: boolean = false, successMessage?: string): Promise { + let proposalId: number; + + const interactionWithValues: AnyInteractionWithValues | undefined = InteractionProperties.toInteractionWithValuesOrUndefined( + interaction.interaction, + ); + if (interactionWithValues) { + proposalId = parseInt(interactionWithValues.values[0]); + } else { + const proposalIdFromCustomId: string | undefined = CustomIDOracle.getNamedArgument(interaction.customId, 'prId'); + if (!proposalIdFromCustomId) { + throw new EndUserError('Interaction neither has proposalId in values, nor in customId.'); + } else { + proposalId = parseInt(proposalIdFromCustomId); + } } - private async handleExecuteCancelProposal(interaction: TrackedInteraction): Promise { - const proposalId: string | undefined = CustomIDOracle.getNamedArgument(interaction.customId, 'proposalId'); - const fundingRoundId: string | undefined = CustomIDOracle.getNamedArgument(interaction.customId, 'frId'); + const proposal: Proposal | null = await ProposalLogic.getProposalById(proposalId); - if (!proposalId || !fundingRoundId) { - throw new EndUserError('Invalid proposal or funding round ID.'); - } + if (!proposal) { + throw new EndUserError('Proposal not found.'); + } - try { - await ProposalLogic.cancelProposal(parseInt(proposalId), this.screen); + let successEmbed: EmbedBuilder | undefined; + if (successMessage) { + successEmbed = new EmbedBuilder().setColor('#28a745').setDescription(successMessage); + } - const successEmbed: EmbedBuilder = new EmbedBuilder() - .setColor('#00FF00') - .setTitle('Proposal Cancelled Successfully') - .setDescription('Your proposal has been cancelled and cannot be re-submitted to this funding round.'); + const embed: EmbedBuilder = new EmbedBuilder() + .setColor('#0099ff') + .setTitle(`Draft Proposal: ${proposal.name}`) + .setDescription(`Proposal URI: ${proposal.uri}`) + .addFields( + { name: 'ID (auto-assigned)', value: proposal.id.toString(), inline: true }, + { name: 'Budget', value: proposal.budget.toString(), inline: true }, + { name: 'URL', value: proposal.uri, inline: true }, + { name: 'Status', value: proposal.status, inline: true }, + ); + + const editButton = new ButtonBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction(this, ManageDraftsAction.OPERATIONS.EDIT_DRAFT, 'prId', proposalId.toString())) + .setLabel('Edit Proposal') + .setStyle(ButtonStyle.Primary); + + const deleteButton = new ButtonBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction(this, ManageDraftsAction.OPERATIONS.DELETE_DRAFT, 'prId', proposalId.toString())) + .setLabel('Delete Proposal') + .setStyle(ButtonStyle.Danger); + + const submitButton = new ButtonBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction(this, ManageDraftsAction.OPERATIONS.SUBMIT_TO_FUNDING_ROUND, 'prId', proposalId.toString())) + .setLabel('Submit to Funding Round') + .setStyle(ButtonStyle.Success); + + const row = new ActionRowBuilder().addComponents(editButton, deleteButton, submitButton); + + let embeds: EmbedBuilder[]; + if (successEmbed) { + embeds = [successEmbed, embed]; + } else { + embeds = [embed]; + } - const backButton: ButtonBuilder = new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, ManageSubmittedProposalsAction.OPERATIONS.SHOW_PROPOSALS, 'frId', fundingRoundId)) - .setLabel('Back to My Proposals') - .setStyle(ButtonStyle.Primary); + const asUpdateFromCustomId = CustomIDOracle.getNamedArgument(interaction.customId, 'udt'); - const row: ActionRowBuilder = new ActionRowBuilder().addComponents(backButton); + let doUpdate: boolean = asUpdate; - await interaction.update({ embeds: [successEmbed], components: [row] }); - } catch (error) { - throw new EndUserError('Error cancelling proposal', error); - } + if (asUpdateFromCustomId) { + if (asUpdateFromCustomId === '1') { + doUpdate = true; + } } - public allSubActions(): Action[] { - return []; + if (doUpdate) { + await interaction.update({ embeds, components: [row] }); + } else { + await interaction.respond({ embeds, components: [row] }); } + } - getComponent(): ButtonBuilder { - return new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, ManageSubmittedProposalsAction.OPERATIONS.SHOW_FUNDING_ROUNDS)) - .setLabel('Manage My Proposals') - .setStyle(ButtonStyle.Primary); + private async handleEditDraft(interaction: TrackedInteraction): Promise { + const proposalId: string | undefined = CustomIDOracle.getNamedArgument(interaction.customId, 'prId'); + if (!proposalId) { + throw new EndUserError('Invalid proposal ID.'); } -} - - -export class ManageDraftsAction extends PaginationComponent { - public static readonly ID = 'manageDrafts'; - - public static readonly OPERATIONS = { - SHOW_DRAFTS: 'showDrafts', - SELECT_DRAFT: 'selectDraft', - EDIT_DRAFT: 'editDraft', - SUBMIT_EDIT: 'submitEdit', - DELETE_DRAFT: 'deleteDraft', - CONFIRM_DELETE: 'confirmDelete', - SUBMIT_TO_FUNDING_ROUND: 'submitToFundingRound', - }; - public static readonly INPUT_IDS = { - NAME: 'name', - DESCRIPTION: 'description', - BUDGET: 'budget', - URI: 'uri', - }; - - protected async getTotalPages(interaction: TrackedInteraction): Promise { - const drafts = await ProposalLogic.getUserDraftProposals(interaction.interaction.user.id); - return Math.ceil(drafts.length / 25); - } - - protected async getItemsForPage(interaction: TrackedInteraction, page: number): Promise { - const drafts = await ProposalLogic.getUserDraftProposals(interaction.interaction.user.id); - return drafts.slice(page * 25, (page + 1) * 25); - } - - protected async handleOperation(interaction: TrackedInteraction, operationId: string): Promise { - switch (operationId) { - case ManageDraftsAction.OPERATIONS.SHOW_DRAFTS: - await this.handleShowDrafts(interaction); - break; - case ManageDraftsAction.OPERATIONS.SELECT_DRAFT: - await this.handleSelectDraft(interaction); - break; - case ManageDraftsAction.OPERATIONS.EDIT_DRAFT: - await this.handleEditDraft(interaction); - break; - case ManageDraftsAction.OPERATIONS.SUBMIT_EDIT: - await this.handleSubmitEdit(interaction); - break; - case ManageDraftsAction.OPERATIONS.DELETE_DRAFT: - await this.handleDeleteDraft(interaction); - break; - case ManageDraftsAction.OPERATIONS.CONFIRM_DELETE: - await this.handleConfirmDelete(interaction); - break; - case ManageDraftsAction.OPERATIONS.SUBMIT_TO_FUNDING_ROUND: - await this.handleSubmitToFundingRound(interaction); - break; - default: - await this.handleInvalidOperation(interaction, operationId); - } - } - - private async handleShowDrafts(interaction: TrackedInteraction): Promise { - const currentPage = this.getCurrentPage(interaction); - const totalPages = await this.getTotalPages(interaction); - const drafts: Proposal[] = await this.getItemsForPage(interaction, currentPage); - - if (drafts.length === 0) { - throw new EndUserInfo('You have no draft proposals.'); - } - - const embed = new EmbedBuilder() - .setColor('#0099ff') - .setTitle('Select a Draft Proposal') - .setDescription('To proceed, please select a draft proposal from the list below.'); - - const selectMenu = new StringSelectMenuBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, ManageDraftsAction.OPERATIONS.SELECT_DRAFT)) - .setPlaceholder('Select a Draft Proposal') - .addOptions(drafts.map(draft => ({ - label: draft.name, - value: draft.id.toString(), - description: `Budget: ${draft.budget}` - }))); - - const row = new ActionRowBuilder().addComponents(selectMenu); - const components = [row]; - - if (totalPages > 1) { - const paginationRow = this.getPaginationRow(interaction, currentPage, totalPages); - components.push(paginationRow); - } - - const asUpdate = CustomIDOracle.getNamedArgument(interaction.customId, 'udt') === '1'; - - if (asUpdate) { - await interaction.update({ embeds: [embed], components }); - } else { - await interaction.respond({ embeds: [embed], components }); - } - - - } - - private async handleSelectDraft(interaction: TrackedInteraction, asUpdate: boolean = false, successMessage?: string): Promise { - let proposalId: number; - - const interactionWithValues: AnyInteractionWithValues | undefined = InteractionProperties.toInteractionWithValuesOrUndefined(interaction.interaction); - if (interactionWithValues) { - proposalId = parseInt(interactionWithValues.values[0]); - } else { - const proposalIdFromCustomId: string | undefined = CustomIDOracle.getNamedArgument(interaction.customId, 'proposalId'); - if (!proposalIdFromCustomId) { - throw new EndUserError('Interaction neither has proposalId in values, nor in customId.'); - } else { - proposalId = parseInt(proposalIdFromCustomId); - } - } - - const proposal: Proposal | null = await ProposalLogic.getProposalById(proposalId); - - if (!proposal) { - throw new EndUserError('Proposal not found.'); - } - - let successEmbed: EmbedBuilder | undefined; - if (successMessage) { - successEmbed = new EmbedBuilder() - .setColor('#28a745') - .setDescription(successMessage); - } - - const embed: EmbedBuilder = new EmbedBuilder() - .setColor('#0099ff') - .setTitle(`Draft Proposal: ${proposal.name}`) - .setDescription(`Proposal URI: ${proposal.uri}`) - .addFields( - { name: 'ID (auto-assigned)', value: proposal.id.toString(), inline: true }, - { name: 'Budget', value: proposal.budget.toString(), inline: true }, - { name: 'URL', value: proposal.uri, inline: true }, - { name: 'Status', value: proposal.status, inline: true } - ); - - - const editButton = new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, ManageDraftsAction.OPERATIONS.EDIT_DRAFT, 'proposalId', proposalId.toString())) - .setLabel('Edit Proposal') - .setStyle(ButtonStyle.Primary); - - const deleteButton = new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, ManageDraftsAction.OPERATIONS.DELETE_DRAFT, 'proposalId', proposalId.toString())) - .setLabel('Delete Proposal') - .setStyle(ButtonStyle.Danger); - - const submitButton = new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, ManageDraftsAction.OPERATIONS.SUBMIT_TO_FUNDING_ROUND, 'proposalId', proposalId.toString())) - .setLabel('Submit to Funding Round') - .setStyle(ButtonStyle.Success); - - const row = new ActionRowBuilder().addComponents(editButton, deleteButton, submitButton); - - let embeds: EmbedBuilder[]; - if (successEmbed) { - embeds = [successEmbed, embed]; - } else { - embeds = [embed]; - } - - const asUpdateFromCustomId = CustomIDOracle.getNamedArgument(interaction.customId, 'udt'); - - let doUpdate: boolean = asUpdate; - - if (asUpdateFromCustomId) { - if (asUpdateFromCustomId === '1') { - doUpdate = true; - } - } - - if (doUpdate) { - await interaction.update({ embeds, components: [row] }); - } else { - await interaction.respond({ embeds, components: [row] }); - } - } - - private async handleEditDraft(interaction: TrackedInteraction): Promise { - const proposalId: string | undefined = CustomIDOracle.getNamedArgument(interaction.customId, 'proposalId'); - if (!proposalId) { - throw new EndUserError('Invalid proposal ID.'); - } - - const proposal: Proposal | null = await ProposalLogic.getProposalById(parseInt(proposalId)); - if (!proposal) { - throw new EndUserError('Proposal not found.'); - } - - const modalInteraction: AnyInteractionWithShowModal | undefined = InteractionProperties.toShowModalOrUndefined(interaction.interaction); - if (!modalInteraction) { - throw new EndUserError('This interaction does not support modals.'); - } - - const modal: ModalBuilder = new ModalBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, ManageDraftsAction.OPERATIONS.SUBMIT_EDIT, 'proposalId', proposalId)) - .setTitle('Edit Proposal'); - - const nameInput = new TextInputBuilder() - .setCustomId(ManageDraftsAction.INPUT_IDS.NAME) - .setLabel('Proposal Name') - .setStyle(TextInputStyle.Short) - .setValue(proposal.name) - .setRequired(true); - - const budgetInput = new TextInputBuilder() - .setCustomId(ManageDraftsAction.INPUT_IDS.BUDGET) - .setLabel('Budget') - .setStyle(TextInputStyle.Short) - .setValue(proposal.budget.toString()) - .setRequired(true); - - const uriInput = new TextInputBuilder() - .setCustomId(ManageDraftsAction.INPUT_IDS.URI) - .setLabel('URI (https://forums.minaprotocol.com)') - .setStyle(TextInputStyle.Short) - .setValue(proposal.uri) - .setRequired(true); - - modal.addComponents( - new ActionRowBuilder().addComponents(nameInput), - new ActionRowBuilder().addComponents(budgetInput), - new ActionRowBuilder().addComponents(uriInput) - ); - - await modalInteraction.showModal(modal); + const proposal: Proposal | null = await ProposalLogic.getProposalById(parseInt(proposalId)); + if (!proposal) { + throw new EndUserError('Proposal not found.'); } - private async handleSubmitEdit(interaction: TrackedInteraction): Promise { - const modalInteraction = InteractionProperties.toModalSubmitInteractionOrUndefined(interaction.interaction); - if (!modalInteraction) { - throw new EndUserError('Invalid interaction type.'); - } - - const proposalId = CustomIDOracle.getNamedArgument(interaction.customId, 'proposalId'); - if (!proposalId) { - throw new EndUserError('Invalid proposal ID.'); - } - - const name = modalInteraction.fields.getTextInputValue(ManageDraftsAction.INPUT_IDS.NAME); - const budget = parseFloat(modalInteraction.fields.getTextInputValue(ManageDraftsAction.INPUT_IDS.BUDGET)); - const uri = modalInteraction.fields.getTextInputValue(ManageDraftsAction.INPUT_IDS.URI); + const modalInteraction: AnyInteractionWithShowModal | undefined = InteractionProperties.toShowModalOrUndefined(interaction.interaction); + if (!modalInteraction) { + throw new EndUserError('This interaction does not support modals.'); + } - if (isNaN(budget)) { - throw new EndUserError('Invalid budget value.'); - } + const modal: ModalBuilder = new ModalBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction(this, ManageDraftsAction.OPERATIONS.SUBMIT_EDIT, 'prId', proposalId)) + .setTitle('Edit Proposal'); + + const nameInput = new TextInputBuilder() + .setCustomId(ManageDraftsAction.INPUT_IDS.NAME) + .setLabel('Proposal Name') + .setStyle(TextInputStyle.Short) + .setValue(proposal.name) + .setRequired(true); + + const budgetInput = new TextInputBuilder() + .setCustomId(ManageDraftsAction.INPUT_IDS.BUDGET) + .setLabel('Budget') + .setStyle(TextInputStyle.Short) + .setValue(proposal.budget.toString()) + .setRequired(true); + + const uriInput = new TextInputBuilder() + .setCustomId(ManageDraftsAction.INPUT_IDS.URI) + .setLabel('URI (https://forums.minaprotocol.com)') + .setStyle(TextInputStyle.Short) + .setValue(proposal.uri) + .setRequired(true); + + modal.addComponents( + new ActionRowBuilder().addComponents(nameInput), + new ActionRowBuilder().addComponents(budgetInput), + new ActionRowBuilder().addComponents(uriInput), + ); + + await modalInteraction.showModal(modal); + } + + private async handleSubmitEdit(interaction: TrackedInteraction): Promise { + const modalInteraction = InteractionProperties.toModalSubmitInteractionOrUndefined(interaction.interaction); + if (!modalInteraction) { + throw new EndUserError('Invalid interaction type.'); + } - if (!uri.startsWith('https://forums.minaprotocol.com')) { - throw new EndUserError('Invalid URI. Please use a link from https://forums.minaprotocol.com'); - } + const proposalId = CustomIDOracle.getNamedArgument(interaction.customId, 'prId'); + if (!proposalId) { + throw new EndUserError('Invalid proposal ID.'); + } - try { + const name = modalInteraction.fields.getTextInputValue(ManageDraftsAction.INPUT_IDS.NAME); + const budget = parseFloat(modalInteraction.fields.getTextInputValue(ManageDraftsAction.INPUT_IDS.BUDGET)); + const uri = modalInteraction.fields.getTextInputValue(ManageDraftsAction.INPUT_IDS.URI); - await ProposalLogic.updateProposal(parseInt(proposalId), { name, budget, uri }, this._screen); - const successMessage = `✅ '${name}' details updated successfully`; - await this.handleSelectDraft(interaction, true, successMessage); - } catch (error) { - throw new EndUserError('Error updating proposal', error); - } + if (isNaN(budget)) { + throw new EndUserError('Invalid budget value.'); } - private async handleDeleteDraft(interaction: TrackedInteraction): Promise { - const proposalId = CustomIDOracle.getNamedArgument(interaction.customId, 'proposalId'); - if (!proposalId) { - throw new EndUserError('Invalid proposal ID.'); - } + if (!uri.startsWith('https://forums.minaprotocol.com')) { + throw new EndUserError('Invalid URI. Please use a link from https://forums.minaprotocol.com'); + } - const proposal = await ProposalLogic.getProposalById(parseInt(proposalId)); - if (!proposal) { - throw new EndUserError('Proposal not found.'); - } + try { + await ProposalLogic.updateProposal(parseInt(proposalId), { name, budget, uri }, this._screen); + const successMessage = `✅ '${name}' details updated successfully`; + await this.handleSelectDraft(interaction, true, successMessage); + } catch (error) { + throw new EndUserError('Error updating proposal', error); + } + } - const embed = new EmbedBuilder() - .setColor('#ff0000') - .setTitle(`Confirm Delete: ${proposal.name}`) - .setDescription('Are you sure you want to delete this proposal? This action cannot be undone.') - .addFields( - { name: 'Name', value: proposal.name }, - { name: 'ID', value: proposal.id.toString() }, - { name: 'Budget', value: proposal.budget.toString() }, - { name: 'URL', value: proposal.uri } - ); + private async handleDeleteDraft(interaction: TrackedInteraction): Promise { + const proposalId = CustomIDOracle.getNamedArgument(interaction.customId, 'prId'); + if (!proposalId) { + throw new EndUserError('Invalid proposal ID.'); + } - const confirmButton = new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, ManageDraftsAction.OPERATIONS.CONFIRM_DELETE, 'proposalId', proposalId)) - .setLabel('Confirm Delete') - .setStyle(ButtonStyle.Danger); + const proposal = await ProposalLogic.getProposalById(parseInt(proposalId)); + if (!proposal) { + throw new EndUserError('Proposal not found.'); + } - const cancelButton = new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, ManageDraftsAction.OPERATIONS.SELECT_DRAFT, 'proposalId', proposalId, 'udt', '1')) - .setLabel('Cancel') - .setStyle(ButtonStyle.Secondary); + const embed = new EmbedBuilder() + .setColor('#ff0000') + .setTitle(`Confirm Delete: ${proposal.name}`) + .setDescription('Are you sure you want to delete this proposal? This action cannot be undone.') + .addFields( + { name: 'Name', value: proposal.name }, + { name: 'ID', value: proposal.id.toString() }, + { name: 'Budget', value: proposal.budget.toString() }, + { name: 'URL', value: proposal.uri }, + ); + + const confirmButton = new ButtonBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction(this, ManageDraftsAction.OPERATIONS.CONFIRM_DELETE, 'prId', proposalId)) + .setLabel('Confirm Delete') + .setStyle(ButtonStyle.Danger); + + const cancelButton = new ButtonBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction(this, ManageDraftsAction.OPERATIONS.SELECT_DRAFT, 'prId', proposalId, 'udt', '1')) + .setLabel('Cancel') + .setStyle(ButtonStyle.Secondary); + + const row = new ActionRowBuilder().addComponents(confirmButton, cancelButton); + + await interaction.update({ embeds: [embed], components: [row] }); + } + + private async handleConfirmDelete(interaction: TrackedInteraction): Promise { + const proposalId = CustomIDOracle.getNamedArgument(interaction.customId, 'prId'); + if (!proposalId) { + throw new EndUserError('Invalid proposal ID.'); + } - const row = new ActionRowBuilder().addComponents(confirmButton, cancelButton); + try { + await ProposalLogic.deleteProposal(parseInt(proposalId)); + await this.handleShowDrafts(interaction); + DiscordStatus.Success.success(interaction, `Proposal ${proposalId} deleted successfully.`); + } catch (error) { + throw new EndUserError('Error deleting proposal', error); + } + } - await interaction.update({ embeds: [embed], components: [row] }); + private async handleSubmitToFundingRound(interaction: TrackedInteraction): Promise { + const proposalId = CustomIDOracle.getNamedArgument(interaction.customId, 'prId'); + if (!proposalId) { + throw new EndUserError('Invalid proposal ID.'); } - private async handleConfirmDelete(interaction: TrackedInteraction): Promise { - const proposalId = CustomIDOracle.getNamedArgument(interaction.customId, 'proposalId'); - if (!proposalId) { - throw new EndUserError('Invalid proposal ID.'); - } + const eligibleFundingRounds: FundingRound[] = await FundingRoundLogic.getEligibleFundingRoundsForProposal( + parseInt(proposalId), + interaction.interaction.user.id, + ); - try { - await ProposalLogic.deleteProposal(parseInt(proposalId)); - await this.handleShowDrafts(interaction); - DiscordStatus.Success.success(interaction, `Proposal ${proposalId} deleted successfully.`) - } catch (error) { - throw new EndUserError('Error deleting proposal', error); - } + if (eligibleFundingRounds.length === 0) { + throw new EndUserInfo('There are no eligible funding rounds for this proposal at the moment.'); } - private async handleSubmitToFundingRound(interaction: TrackedInteraction): Promise { - const proposalId = CustomIDOracle.getNamedArgument(interaction.customId, 'proposalId'); - if (!proposalId) { - throw new EndUserError('Invalid proposal ID.'); - } + const selectMenu = new StringSelectMenuBuilder() + .setCustomId( + CustomIDOracle.addArgumentsToAction( + (this.screen as ProposalHomeScreen).submitProposalToFundingRoundAction, + SubmitProposalToFundingRoundAction.OPERATIONS.CONFIRM_SUBMISSION, + 'prId', + proposalId.toString(), + ), + ) + .setPlaceholder('Select a Funding Round') + .addOptions( + eligibleFundingRounds.map((fr) => ({ + label: fr.name, + value: fr.id.toString(), + description: `Budget: ${fr.budget}, Ends: ${fr.endAt.toLocaleDateString()}`, + })), + ); + + const row = new ActionRowBuilder().addComponents(selectMenu); + + await interaction.respond({ content: 'Please select a funding round to submit your proposal to:', components: [row] }); + } + + public allSubActions(): Action[] { + return []; + } + + getComponent(): ButtonBuilder { + return new ButtonBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction(this, ManageDraftsAction.OPERATIONS.SHOW_DRAFTS)) + .setLabel('Manage Drafts') + .setStyle(ButtonStyle.Primary); + } +} - const eligibleFundingRounds: FundingRound[] = await FundingRoundLogic.getEligibleFundingRoundsForProposal(parseInt(proposalId), interaction.interaction.user.id); +export class CreateNewProposalAction extends Action { + public static readonly ID = 'createNewProposal'; + + public static readonly OPERATIONS = { + SHOW_CREATE_FORM: 'showCreateForm', + SUBMIT_CREATE_FORM: 'submitCreateForm', + }; + + public static readonly INPUT_IDS = { + NAME: 'name', + BUDGET: 'budget', + URI: 'uri', + }; + + protected async handleOperation(interaction: TrackedInteraction, operationId: string): Promise { + switch (operationId) { + case CreateNewProposalAction.OPERATIONS.SHOW_CREATE_FORM: + await this.handleShowCreateForm(interaction); + break; + case CreateNewProposalAction.OPERATIONS.SUBMIT_CREATE_FORM: + await this.handleSubmitCreateForm(interaction); + break; + default: + await this.handleInvalidOperation(interaction, operationId); + } + } - if (eligibleFundingRounds.length === 0) { - throw new EndUserInfo('There are no eligible funding rounds for this proposal at the moment.'); - } + private async handleShowCreateForm(interaction: TrackedInteraction): Promise { + const modalInteraction: AnyInteractionWithShowModal | undefined = InteractionProperties.toShowModalOrUndefined(interaction.interaction); + if (!modalInteraction) { + throw new EndUserError('This interaction does not support modals.'); + } - const selectMenu = new StringSelectMenuBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction((this.screen as ProposalHomeScreen).submitProposalToFundingRoundAction, SubmitProposalToFundingRoundAction.OPERATIONS.CONFIRM_SUBMISSION, 'proposalId', proposalId.toString())) - .setPlaceholder('Select a Funding Round') - .addOptions(eligibleFundingRounds.map(fr => ({ - label: fr.name, - value: fr.id.toString(), - description: `Budget: ${fr.budget}, Ends: ${fr.endAt.toLocaleDateString()}` - }))); + const modal = new ModalBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction(this, CreateNewProposalAction.OPERATIONS.SUBMIT_CREATE_FORM)) + .setTitle('Create New Proposal'); + + const nameInput = new TextInputBuilder() + .setCustomId(CreateNewProposalAction.INPUT_IDS.NAME) + .setLabel('Proposal Name') + .setStyle(TextInputStyle.Short) + .setRequired(true); + + const budgetInput = new TextInputBuilder() + .setCustomId(CreateNewProposalAction.INPUT_IDS.BUDGET) + .setLabel('Budget') + .setStyle(TextInputStyle.Short) + .setRequired(true); + + const uriInput = new TextInputBuilder() + .setCustomId(CreateNewProposalAction.INPUT_IDS.URI) + .setLabel('URL (https://forums.minaprotocol.com/....)') + .setStyle(TextInputStyle.Short) + .setRequired(true); + + modal.addComponents( + new ActionRowBuilder().addComponents(nameInput), + new ActionRowBuilder().addComponents(budgetInput), + new ActionRowBuilder().addComponents(uriInput), + ); + + await modalInteraction.showModal(modal); + } + + private async handleSubmitCreateForm(interaction: TrackedInteraction): Promise { + const modalInteraction = InteractionProperties.toModalSubmitInteractionOrUndefined(interaction.interaction); + if (!modalInteraction) { + throw new EndUserError('Invalid interaction type.'); + } - const row = new ActionRowBuilder().addComponents(selectMenu); + const name = modalInteraction.fields.getTextInputValue(CreateNewProposalAction.INPUT_IDS.NAME); + const budget = parseFloat(modalInteraction.fields.getTextInputValue(CreateNewProposalAction.INPUT_IDS.BUDGET)); + let uri = modalInteraction.fields.getTextInputValue(CreateNewProposalAction.INPUT_IDS.URI); - await interaction.respond({ content: 'Please select a funding round to submit your proposal to:', components: [row] }); + if (uri.startsWith('http://')) { + uri = uri.replace('http://', 'https://'); + } else if (uri.startsWith('forums.minaprotocol.com/')) { + uri = 'https://' + uri; } - public allSubActions(): Action[] { - return []; + if (isNaN(budget)) { + throw new EndUserError('Invalid budget value.'); } - getComponent(): ButtonBuilder { - return new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, ManageDraftsAction.OPERATIONS.SHOW_DRAFTS)) - .setLabel('Manage Drafts') - .setStyle(ButtonStyle.Primary); + if (!uri.startsWith('https://forums.minaprotocol.com')) { + throw new EndUserError('Invalid URL. Please use a link from https://forums.minaprotocol.com'); } -} - -export class CreateNewProposalAction extends Action { - public static readonly ID = 'createNewProposal'; - - public static readonly OPERATIONS = { - SHOW_CREATE_FORM: 'showCreateForm', - SUBMIT_CREATE_FORM: 'submitCreateForm', - }; - public static readonly INPUT_IDS = { - NAME: 'name', - BUDGET: 'budget', - URI: 'uri', - }; - - protected async handleOperation(interaction: TrackedInteraction, operationId: string): Promise { - switch (operationId) { - case CreateNewProposalAction.OPERATIONS.SHOW_CREATE_FORM: - await this.handleShowCreateForm(interaction); - break; - case CreateNewProposalAction.OPERATIONS.SUBMIT_CREATE_FORM: - await this.handleSubmitCreateForm(interaction); - break; - default: - await this.handleInvalidOperation(interaction, operationId); - } - } - - private async handleShowCreateForm(interaction: TrackedInteraction): Promise { - const modalInteraction: AnyInteractionWithShowModal | undefined = InteractionProperties.toShowModalOrUndefined(interaction.interaction); - if (!modalInteraction) { - throw new EndUserError('This interaction does not support modals.'); - } - - const modal = new ModalBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, CreateNewProposalAction.OPERATIONS.SUBMIT_CREATE_FORM)) - .setTitle('Create New Proposal'); - - const nameInput = new TextInputBuilder() - .setCustomId(CreateNewProposalAction.INPUT_IDS.NAME) - .setLabel('Proposal Name') - .setStyle(TextInputStyle.Short) - .setRequired(true); - - const budgetInput = new TextInputBuilder() - .setCustomId(CreateNewProposalAction.INPUT_IDS.BUDGET) - .setLabel('Budget') - .setStyle(TextInputStyle.Short) - .setRequired(true); - - const uriInput = new TextInputBuilder() - .setCustomId(CreateNewProposalAction.INPUT_IDS.URI) - .setLabel('URL (https://forums.minaprotocol.com/....)') - .setStyle(TextInputStyle.Short) - .setRequired(true); - - modal.addComponents( - new ActionRowBuilder().addComponents(nameInput), - new ActionRowBuilder().addComponents(budgetInput), - new ActionRowBuilder().addComponents(uriInput) + try { + const newProposal = await ProposalLogic.createProposal({ + name, + budget, + uri, + proposerDuid: interaction.interaction.user.id, + status: ProposalStatus.DRAFT, + fundingRoundId: null, + forumThreadId: null, + }); + + const embed = new EmbedBuilder() + .setColor('#00FF00') + .setTitle('Proposal Created Successfully') + .setDescription('Your new proposal draft has been created.') + .addFields( + { name: 'ID (auto-assigned)', value: newProposal.id.toString() }, + { name: 'Name', value: newProposal.name }, + { name: 'Budget', value: newProposal.budget.toString() }, + { name: 'URL', value: newProposal.uri }, ); - await modalInteraction.showModal(modal); - } - - private async handleSubmitCreateForm(interaction: TrackedInteraction): Promise { - const modalInteraction = InteractionProperties.toModalSubmitInteractionOrUndefined(interaction.interaction); - if (!modalInteraction) { - throw new EndUserError('Invalid interaction type.'); - } - - const name = modalInteraction.fields.getTextInputValue(CreateNewProposalAction.INPUT_IDS.NAME); - const budget = parseFloat(modalInteraction.fields.getTextInputValue(CreateNewProposalAction.INPUT_IDS.BUDGET)); - let uri = modalInteraction.fields.getTextInputValue(CreateNewProposalAction.INPUT_IDS.URI); - - if (uri.startsWith('http://')) { - uri = uri.replace('http://', 'https://'); - } else if (uri.startsWith('forums.minaprotocol.com/')) { - uri = 'https://' + uri; - } - - if (isNaN(budget)) { - throw new EndUserError('Invalid budget value.'); - } - - if (!uri.startsWith('https://forums.minaprotocol.com')) { - throw new EndUserError('Invalid URL. Please use a link from https://forums.minaprotocol.com'); - } - - try { - const newProposal = await ProposalLogic.createProposal({ - name, - budget, - uri, - proposerDuid: interaction.interaction.user.id, - status: ProposalStatus.DRAFT, - fundingRoundId: null, - forumThreadId: null, - }); - - const embed = new EmbedBuilder() - .setColor('#00FF00') - .setTitle('Proposal Created Successfully') - .setDescription('Your new proposal draft has been created.') - .addFields( - { name: 'ID (auto-assigned)', value: newProposal.id.toString() }, - { name: 'Name', value: newProposal.name }, - { name: 'Budget', value: newProposal.budget.toString() }, - { name: 'URL', value: newProposal.uri } - ); - - const manageButton = new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction((this.screen as ProposalHomeScreen).manageDraftsAction, ManageDraftsAction.OPERATIONS.SHOW_DRAFTS, "udt", "1")) - .setLabel('Manage Drafts') - .setStyle(ButtonStyle.Primary); - - const row = new ActionRowBuilder().addComponents(manageButton); - - await interaction.respond({ embeds: [embed], components: [row] }); - } catch (error) { - throw new EndUserError('Error creating proposal', error); - } - } - - public allSubActions(): Action[] { - return []; - } - - getComponent(): ButtonBuilder { - return new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, CreateNewProposalAction.OPERATIONS.SHOW_CREATE_FORM)) - .setLabel('Create New Proposal') - .setStyle(ButtonStyle.Success); + const manageButton = new ButtonBuilder() + .setCustomId( + CustomIDOracle.addArgumentsToAction( + (this.screen as ProposalHomeScreen).manageDraftsAction, + ManageDraftsAction.OPERATIONS.SHOW_DRAFTS, + 'udt', + '1', + ), + ) + .setLabel('Manage Drafts') + .setStyle(ButtonStyle.Primary); + + const row = new ActionRowBuilder().addComponents(manageButton); + + await interaction.respond({ embeds: [embed], components: [row] }); + } catch (error) { + throw new EndUserError('Error creating proposal', error); } + } + + public allSubActions(): Action[] { + return []; + } + + getComponent(): ButtonBuilder { + return new ButtonBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction(this, CreateNewProposalAction.OPERATIONS.SHOW_CREATE_FORM)) + .setLabel('Create New Proposal') + .setStyle(ButtonStyle.Success); + } } export class SubmitProposalToFundingRoundAction extends Action { - public static readonly ID = 'submitProposalToFundingRound'; + public static readonly ID = 'submitProposalToFundingRound'; + + public static readonly OPERATIONS = { + SHOW_DRAFT_PROPOSALS: 'shDrP', + SELECT_FUNDING_ROUND: 'slFr', + CONFIRM_SUBMISSION: 'cnSb', + EXECUTE_SUBMISSION: 'exSb', + }; + + protected async handleOperation(interaction: TrackedInteraction, operationId: string): Promise { + switch (operationId) { + case SubmitProposalToFundingRoundAction.OPERATIONS.SHOW_DRAFT_PROPOSALS: + await this.handleShowDraftProposals(interaction); + break; + case SubmitProposalToFundingRoundAction.OPERATIONS.SELECT_FUNDING_ROUND: + await this.handleSelectFundingRound(interaction); + break; + case SubmitProposalToFundingRoundAction.OPERATIONS.CONFIRM_SUBMISSION: + await this.handleConfirmSubmission(interaction); + break; + case SubmitProposalToFundingRoundAction.OPERATIONS.EXECUTE_SUBMISSION: + await this.handleExecuteSubmission(interaction); + break; + default: + await this.handleInvalidOperation(interaction, operationId); + } + } - public static readonly OPERATIONS = { - SHOW_DRAFT_PROPOSALS: 'shDrP', - SELECT_FUNDING_ROUND: 'slFr', - CONFIRM_SUBMISSION: 'cnSb', - EXECUTE_SUBMISSION: 'exSb', - }; + private async handleShowDraftProposals(interaction: TrackedInteraction): Promise { + const draftProposals = await ProposalLogic.getUserDraftProposals(interaction.interaction.user.id); - protected async handleOperation(interaction: TrackedInteraction, operationId: string): Promise { - switch (operationId) { - case SubmitProposalToFundingRoundAction.OPERATIONS.SHOW_DRAFT_PROPOSALS: - await this.handleShowDraftProposals(interaction); - break; - case SubmitProposalToFundingRoundAction.OPERATIONS.SELECT_FUNDING_ROUND: - await this.handleSelectFundingRound(interaction); - break; - case SubmitProposalToFundingRoundAction.OPERATIONS.CONFIRM_SUBMISSION: - await this.handleConfirmSubmission(interaction); - break; - case SubmitProposalToFundingRoundAction.OPERATIONS.EXECUTE_SUBMISSION: - await this.handleExecuteSubmission(interaction); - break; - default: - await this.handleInvalidOperation(interaction, operationId); - } - } - - private async handleShowDraftProposals(interaction: TrackedInteraction): Promise { - const draftProposals = await ProposalLogic.getUserDraftProposals(interaction.interaction.user.id); - - if (draftProposals.length === 0) { - throw new EndUserInfo('ℹ️ You have no draft proposals to submit.'); - } - - const embed = new EmbedBuilder() - .setColor('#0099ff') - .setTitle('Select Draft Proposal') - .setDescription('Please select a draft proposal to submit to a funding round.'); - - const selectMenu = new StringSelectMenuBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, SubmitProposalToFundingRoundAction.OPERATIONS.SELECT_FUNDING_ROUND)) - .setPlaceholder('Select a Draft Proposal') - .addOptions(draftProposals.map(proposal => ({ - label: proposal.name, - value: proposal.id.toString(), - description: `Budget: ${proposal.budget}` - }))); - - const row = new ActionRowBuilder().addComponents(selectMenu); - - const asUpdate = CustomIDOracle.getNamedArgument(interaction.customId, 'udt'); - - const isUpdate: boolean = asUpdate === '1'; - - if (isUpdate) { - await interaction.update({ embeds: [embed], components: [row] }); - } else { - - await interaction.respond({ embeds: [embed], components: [row] }); - } - } - - private async handleSelectFundingRound(interaction: TrackedInteraction): Promise { - const interactionWithValues = InteractionProperties.toInteractionWithValuesOrUndefined(interaction.interaction); - if (!interactionWithValues) { - throw new EndUserError('Invalid interaction type.'); - } - - const proposalId = parseInt(interactionWithValues.values[0]); - const eligibleFundingRounds: FundingRound[] = await FundingRoundLogic.getEligibleFundingRoundsForProposal(proposalId, interaction.interaction.user.id); - - if (eligibleFundingRounds.length === 0) { - throw new EndUserError('There are no eligible funding rounds for this proposal at the moment.'); - } - - const selectMenu = new StringSelectMenuBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, SubmitProposalToFundingRoundAction.OPERATIONS.CONFIRM_SUBMISSION, 'proposalId', proposalId.toString())) - .setPlaceholder('Select a Funding Round') - .addOptions(eligibleFundingRounds.map(fr => ({ - label: fr.name, - value: fr.id.toString(), - description: `Budget: ${fr.budget}, Ends: ${fr.endAt.toLocaleDateString()}` - }))); - - const row = new ActionRowBuilder().addComponents(selectMenu); - - await interaction.respond({ content: 'Please select a funding round to submit your proposal to:', components: [row] }); - } - - private async handleConfirmSubmission(interaction: TrackedInteraction): Promise { - const interactionWithValues = InteractionProperties.toInteractionWithValuesOrUndefined(interaction.interaction); - if (!interactionWithValues) { - throw new EndUserError('Invalid interaction type.'); - } - - const fundingRoundId = parseInt(interactionWithValues.values[0]); - const proposalId = parseInt(CustomIDOracle.getNamedArgument(interaction.customId, 'proposalId') || ''); - - const proposal = await ProposalLogic.getProposalById(proposalId); - const fundingRound = await FundingRoundLogic.getFundingRoundById(fundingRoundId); - - if (!proposal || !fundingRound) { - throw new EndUserError('Proposal or Funding Round not found.'); - } - - const embed = new EmbedBuilder() - .setColor('#FFA500') - .setTitle('Confirm Proposal Submission') - .setDescription('Please review the details below and confirm your submission.') - .addFields( - { name: 'Proposal ID (auto-assigned)', value: proposal.id.toString() }, - { name: 'Proposal Name', value: proposal.name }, - { name: 'Proposal Budget', value: proposal.budget.toString() }, - { name: 'Proposal URI', value: proposal.uri }, - { name: 'Funding Round', value: fundingRound.name }, - { name: 'Funding Round Budget', value: fundingRound.budget.toString() }, - { name: 'Funding Round End Date', value: fundingRound.endAt.toLocaleDateString() } - ) - .setFooter({ text: 'Note: Once submitted, you cannot remove or reassign this proposal to another funding round. You can cancel your proposal later.' }); + if (draftProposals.length === 0) { + throw new EndUserInfo('ℹ️ You have no draft proposals to submit.'); + } + + const embed = new EmbedBuilder() + .setColor('#0099ff') + .setTitle('Select Draft Proposal') + .setDescription('Please select a draft proposal to submit to a funding round.'); + + const selectMenu = new StringSelectMenuBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction(this, SubmitProposalToFundingRoundAction.OPERATIONS.SELECT_FUNDING_ROUND)) + .setPlaceholder('Select a Draft Proposal') + .addOptions( + draftProposals.map((proposal) => ({ + label: proposal.name, + value: proposal.id.toString(), + description: `Budget: ${proposal.budget}`, + })), + ); - const confirmButton = new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, SubmitProposalToFundingRoundAction.OPERATIONS.EXECUTE_SUBMISSION, 'proposalId', proposalId.toString(), 'frId', fundingRoundId.toString())) - .setLabel('Confirm Submission') - .setStyle(ButtonStyle.Success); + const row = new ActionRowBuilder().addComponents(selectMenu); - const cancelButton = new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, SubmitProposalToFundingRoundAction.OPERATIONS.SHOW_DRAFT_PROPOSALS, "udt", "1")) - .setLabel('Cancel') - .setStyle(ButtonStyle.Danger); + const asUpdate = CustomIDOracle.getNamedArgument(interaction.customId, 'udt'); - const row = new ActionRowBuilder().addComponents(confirmButton, cancelButton); + const isUpdate: boolean = asUpdate === '1'; - await interaction.update({ embeds: [embed], components: [row] }); + if (isUpdate) { + await interaction.update({ embeds: [embed], components: [row] }); + } else { + await interaction.respond({ embeds: [embed], components: [row] }); } + } - private async handleExecuteSubmission(interaction: TrackedInteraction): Promise { - const proposalId = parseInt(CustomIDOracle.getNamedArgument(interaction.customId, 'proposalId') || ''); - const fundingRoundId = parseInt(CustomIDOracle.getNamedArgument(interaction.customId, 'frId') || ''); + private async handleSelectFundingRound(interaction: TrackedInteraction): Promise { + const interactionWithValues = InteractionProperties.toInteractionWithValuesOrUndefined(interaction.interaction); + if (!interactionWithValues) { + throw new EndUserError('Invalid interaction type.'); + } - try { - const embed = new EmbedBuilder() - .setColor('#00FF00') - .setTitle('Proposal Submitted Successfully') - .setDescription('Your proposal has been submitted to the funding round.') - .setFooter({ text: 'You can view and manage your submitted proposals in the "Manage Submitted Proposals" section.' }); + const proposalId = parseInt(interactionWithValues.values[0]); + const eligibleFundingRounds: FundingRound[] = await FundingRoundLogic.getEligibleFundingRoundsForProposal( + proposalId, + interaction.interaction.user.id, + ); - const manageButton = new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction((this.screen as ProposalHomeScreen).manageSubmittedProposalsAction, 'showFundingRounds')) - .setLabel('Manage Submitted Proposals') - .setStyle(ButtonStyle.Primary); + if (eligibleFundingRounds.length === 0) { + throw new EndUserError('There are no eligible funding rounds for this proposal at the moment.'); + } - const row = new ActionRowBuilder().addComponents(manageButton); - await interaction.update({ embeds: [embed], components: [row] }); - await ProposalLogic.submitProposalToFundingRound(proposalId, fundingRoundId, this.screen); - } catch (error) { - logger.error(error); - throw new EndUserError('Failed to submit proposal', error); - } + const selectMenu = new StringSelectMenuBuilder() + .setCustomId( + CustomIDOracle.addArgumentsToAction(this, SubmitProposalToFundingRoundAction.OPERATIONS.CONFIRM_SUBMISSION, 'prId', proposalId.toString()), + ) + .setPlaceholder('Select a Funding Round') + .addOptions( + eligibleFundingRounds.map((fr) => ({ + label: fr.name, + value: fr.id.toString(), + description: `Budget: ${fr.budget}, Ends: ${fr.endAt.toLocaleDateString()}`, + })), + ); + + const row = new ActionRowBuilder().addComponents(selectMenu); + + await interaction.respond({ content: 'Please select a funding round to submit your proposal to:', components: [row] }); + } + + private async handleConfirmSubmission(interaction: TrackedInteraction): Promise { + const interactionWithValues = InteractionProperties.toInteractionWithValuesOrUndefined(interaction.interaction); + if (!interactionWithValues) { + throw new EndUserError('Invalid interaction type.'); + } + + const fundingRoundId = parseInt(interactionWithValues.values[0]); + const proposalId = parseInt(CustomIDOracle.getNamedArgument(interaction.customId, 'prId') || ''); + const proposal = await ProposalLogic.getProposalById(proposalId); + const fundingRound = await FundingRoundLogic.getFundingRoundById(fundingRoundId); + + if (!proposal || !fundingRound) { + throw new EndUserError('Proposal or Funding Round not found.'); } - public allSubActions(): Action[] { - return []; - } - - getComponent(): ButtonBuilder { - return new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, SubmitProposalToFundingRoundAction.OPERATIONS.SHOW_DRAFT_PROPOSALS)) - .setLabel('Submit Proposal To Funding Round') - .setStyle(ButtonStyle.Primary); + const embed = new EmbedBuilder() + .setColor('#FFA500') + .setTitle('Confirm Proposal Submission') + .setDescription('Please review the details below and confirm your submission.') + .addFields( + { name: 'Proposal ID (auto-assigned)', value: proposal.id.toString() }, + { name: 'Proposal Name', value: proposal.name }, + { name: 'Proposal Budget', value: proposal.budget.toString() }, + { name: 'Proposal URI', value: proposal.uri }, + { name: 'Funding Round', value: fundingRound.name }, + { name: 'Funding Round Budget', value: fundingRound.budget.toString() }, + { name: 'Funding Round End Date', value: fundingRound.endAt.toLocaleDateString() }, + ) + .setFooter({ + text: 'Note: Once submitted, you cannot remove or reassign this proposal to another funding round. You can cancel your proposal later.', + }); + + const confirmButton = new ButtonBuilder() + .setCustomId( + CustomIDOracle.addArgumentsToAction( + this, + SubmitProposalToFundingRoundAction.OPERATIONS.EXECUTE_SUBMISSION, + 'prId', + proposalId.toString(), + 'frId', + fundingRoundId.toString(), + ), + ) + .setLabel('Confirm Submission') + .setStyle(ButtonStyle.Success); + + const cancelButton = new ButtonBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction(this, SubmitProposalToFundingRoundAction.OPERATIONS.SHOW_DRAFT_PROPOSALS, 'udt', '1')) + .setLabel('Cancel') + .setStyle(ButtonStyle.Danger); + + const row = new ActionRowBuilder().addComponents(confirmButton, cancelButton); + + await interaction.update({ embeds: [embed], components: [row] }); + } + + private async handleExecuteSubmission(interaction: TrackedInteraction): Promise { + const proposalId = parseInt(CustomIDOracle.getNamedArgument(interaction.customId, 'prId') || ''); + const fundingRoundId = parseInt(CustomIDOracle.getNamedArgument(interaction.customId, 'frId') || ''); + + try { + const embed = new EmbedBuilder() + .setColor('#00FF00') + .setTitle('Proposal Submitted Successfully') + .setDescription('Your proposal has been submitted to the funding round.') + .setFooter({ text: 'You can view and manage your submitted proposals in the "Manage Submitted Proposals" section.' }); + + const manageButton = new ButtonBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction((this.screen as ProposalHomeScreen).manageSubmittedProposalsAction, 'showFundingRounds')) + .setLabel('Manage Submitted Proposals') + .setStyle(ButtonStyle.Primary); + + const row = new ActionRowBuilder().addComponents(manageButton); + await interaction.update({ embeds: [embed], components: [row] }); + await ProposalLogic.submitProposalToFundingRound(proposalId, fundingRoundId, this.screen); + } catch (error) { + logger.error(error); + throw new EndUserError('Failed to submit proposal', error); } -} \ No newline at end of file + } + + public allSubActions(): Action[] { + return []; + } + + getComponent(): ButtonBuilder { + return new ButtonBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction(this, SubmitProposalToFundingRoundAction.OPERATIONS.SHOW_DRAFT_PROPOSALS)) + .setLabel('Submit Proposal To Funding Round') + .setStyle(ButtonStyle.Primary); + } +} From 64e9d07916d5fe43c109a7c2e5f44baa432c3208 Mon Sep 17 00:00:00 2001 From: Illya Gerasymchuk Date: Mon, 26 Aug 2024 10:10:51 +0200 Subject: [PATCH 12/12] chore: bump version to 0.0.9 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 87ca665..022547d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mina-govbot", - "version": "0.0.8", + "version": "0.0.9", "description": "Discord bot for collective decision making for Mina Protocol", "main": "index.js", "directories": {