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": { diff --git a/src/CustomIDOracle.ts b/src/CustomIDOracle.ts index e2f2008..14c9342 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; @@ -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) { @@ -142,8 +155,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/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/bot.ts b/src/bot.ts index 1a854ff..bc9303c 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; @@ -124,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()) { @@ -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}`); @@ -152,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); 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..59ff720 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,19 +352,28 @@ 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: { - status: FundingRoundStatus.VOTING, - votingOpenUntil: { - [Op.gte]: now, - }, - }, - }); - const onlyReadyFundingRounds = allFindingRoundsInVoting.filter( value => value.isReady()) - return onlyReadyFundingRounds; -} + + const userFundingRounds = await FundingRoundLogic.getFundingRoundsForUser(duid); + + const eligibleFundingRounds = userFundingRounds.filter((fr) => + fr.status === FundingRoundStatus.VOTING && + fr.votingOpenUntil >= now + ); + + 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({ @@ -846,12 +856,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..483e943 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'; @@ -188,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); @@ -204,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 }, @@ -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); } @@ -465,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() @@ -493,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); @@ -518,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, @@ -556,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..8def035 100644 --- a/src/channels/admin/screens/ManageProposalStatusesScreen.ts +++ b/src/channels/admin/screens/ManageProposalStatusesScreen.ts @@ -3,392 +3,317 @@ 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'; + 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, SelectFundingRoundAction.ID); - this.selectProposalAction = new SelectProposalAction(this, SelectProposalAction.ID); - 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 PaginationComponent { - public static readonly ID = 'selectFundingRound'; - - protected async getTotalPages(): Promise { - const fundingRounds = await FundingRoundLogic.getActiveFundingRounds(); - return Math.ceil(fundingRounds.length / 25); +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); + } + + 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 getItemsForPage(interaction: TrackedInteraction, page: number): Promise { - const fundingRounds = await FundingRoundLogic.getActiveFundingRounds(); - return fundingRounds.slice(page * 25, (page + 1) * 25); - } + private async handleShowFundingRounds(interaction: TrackedInteraction): Promise { + await this.activeFundingRoundPaginator.handlePagination(interaction); + } - protected async handleOperation(interaction: TrackedInteraction, operationId: string): Promise { - switch (operationId) { - case 'showFundingRounds': - await this.handleShowFundingRounds(interaction); - break; - case 'selectFundingRound': - await this.handleSelectFundingRound(interaction); - break; - default: - await this.handleInvalidOperation(interaction, operationId); - } - } + private async handleSelectFundingRound(interaction: TrackedInteraction): Promise { + const parsedInteraction: AnyInteractionWithValues | undefined = InteractionProperties.toInteractionWithValuesOrUndefined(interaction.interaction); - 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 }); + if (!parsedInteraction) { + await DiscordStatus.Error.error(interaction, 'Interaction does not have values'); + throw new EndUserError('Interaction does not have values'); } - 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'); - } + 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 PaginationComponent { - 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, 'fundingRoundId'); - - 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); +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, + ); + } + + 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 getItemsForPage(interaction: TrackedInteraction, page: number, frId?: string): Promise { + private async handleShowProposals(interaction: TrackedInteraction): Promise { + return await this.renderHandleShowProposals(interaction); + } - let fundingRoundId = CustomIDOracle.getNamedArgument(interaction.customId, 'fundingRoundId'); + public async renderHandleShowProposals(interaction: TrackedInteraction, frId?: string): Promise { + const fundingRoundId = frId || 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 proposals.slice(page * 25, (page + 1) * 25); - } - - protected async handleOperation(interaction: TrackedInteraction, operationId: string): Promise { - switch (operationId) { - case SelectProposalAction.OPERATIONS.showProposals: - await this.handleShowProposals(interaction); - break; - case SelectProposalAction.OPERATIONS.selectProposal: - await this.handleSelectProposal(interaction); - break; - default: - await this.handleInvalidOperation(interaction, operationId); - } + 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 { - let fundingRoundId: string | undefined = CustomIDOracle.getNamedArgument(interaction.customId, 'fundingRoundId'); - 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', 'fundingRoundId', fundingRoundId)) - .setPlaceholder('Select a Proposal') - .addOptions(proposals.map(p => ({ - label: p.name, - value: p.id.toString(), - description: `Status: ${p.status}, Budget: ${p.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 }); - } + interaction.Context.set(ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, fundingRoundId); + await this.manageProposalStatusesPaginator.handlePagination(interaction); + } - private async handleShowProposals(interaction: TrackedInteraction): Promise { - return await this.renderHandleShowProposals(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 proposalId = parsedInteraction.values[0]; + interaction.Context.set('prId', proposalId); - 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'); - } + await (this.screen as ManageProposalStatusesScreen).updateProposalStatusAction.renderShowStatusOptions(interaction, proposalId); + } - const proposalId = parsedInteraction.values[0]; - interaction.Context.set('proposalId', proposalId); + public allSubActions(): Action[] { + return []; + } - await (this.screen as ManageProposalStatusesScreen).updateProposalStatusAction.renderShowStatusOptions(interaction, proposalId); - } - - public allSubActions(): Action[] { - return []; - } - - getComponent(fundingRoundId: string): ButtonBuilder { - return new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, 'showProposals', 'fundingRoundId', 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; - } - - 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); + public async renderShowStatusOptions(interaction: TrackedInteraction, pId?: string): Promise { + const proposalIdFromCntx: string | undefined = interaction.Context.get('prId'); + const proposalIdFromCustomId = CustomIDOracle.getNamedArgument(interaction.customId, 'prId'); - 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, 'fundingRoundId', 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/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/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/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/channels/deliberate/CommitteeDeliberationHomeScreen.ts b/src/channels/deliberate/CommitteeDeliberationHomeScreen.ts index 6dcdd98..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'); } @@ -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..cc18505 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[] { @@ -166,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') @@ -422,8 +421,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); } @@ -444,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', @@ -460,12 +457,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); } @@ -495,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 { @@ -507,7 +504,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/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..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,964 +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, 'fundingRoundId') || ''); - - const proposal: Proposal | null = await ProposalLogic.getProposalById(proposalId); - const fundingRound: FundingRound | null = await FundingRoundLogic.getFundingRoundById(fundingRoundId); + try { + await ProposalLogic.cancelProposal(parseInt(proposalId), this.screen); - if (!proposal || !fundingRound) { - throw new EndUserError('Proposal or Funding Round not found.'); - } + 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(`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 backButton: ButtonBuilder = new ButtonBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction(this, ManageSubmittedProposalsAction.OPERATIONS.SHOW_PROPOSALS, 'frId', fundingRoundId)) + .setLabel('Back to My Proposals') + .setStyle(ButtonStyle.Primary); - const cancelButton: ButtonBuilder = new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, ManageSubmittedProposalsAction.OPERATIONS.CONFIRM_CANCEL_PROPOSAL, 'proposalId', proposalId.toString(), 'fundingRoundId', fundingRoundId.toString())) - .setLabel('Cancel My Proposal') - .setStyle(ButtonStyle.Danger); + const row: ActionRowBuilder = new ActionRowBuilder().addComponents(backButton); - if (proposal.status === ProposalStatus.CANCELLED) { - cancelButton.setDisabled(true); - } - - 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, 'fundingRoundId'); - - 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, 'fundingRoundId', 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, 'fundingRoundId', 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, 'fundingRoundId'); + 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, 'fundingRoundId', 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: 'showDraftProposals', - SELECT_FUNDING_ROUND: 'selectFundingRound', - CONFIRM_SUBMISSION: 'confirmSubmission', - EXECUTE_SUBMISSION: 'executeSubmission', - }; + 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 confirmButton = new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, SubmitProposalToFundingRoundAction.OPERATIONS.EXECUTE_SUBMISSION, 'proposalId', proposalId.toString(), 'fundingRoundId', fundingRoundId.toString())) - .setLabel('Confirm Submission') - .setStyle(ButtonStyle.Success); + const embed = new EmbedBuilder() + .setColor('#0099ff') + .setTitle('Select Draft Proposal') + .setDescription('Please select a draft proposal to submit to a funding round.'); - const cancelButton = new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, SubmitProposalToFundingRoundAction.OPERATIONS.SHOW_DRAFT_PROPOSALS, "udt", "1")) - .setLabel('Cancel') - .setStyle(ButtonStyle.Danger); + 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(confirmButton, cancelButton); + const row = new ActionRowBuilder().addComponents(selectMenu); - await interaction.update({ embeds: [embed], components: [row] }); - } + const asUpdate = CustomIDOracle.getNamedArgument(interaction.customId, 'udt'); - private async handleExecuteSubmission(interaction: TrackedInteraction): Promise { - const proposalId = parseInt(CustomIDOracle.getNamedArgument(interaction.customId, 'proposalId') || ''); - const fundingRoundId = parseInt(CustomIDOracle.getNamedArgument(interaction.customId, 'fundingRoundId') || ''); + const isUpdate: boolean = asUpdate === '1'; - try { - await ProposalLogic.submitProposalToFundingRound(proposalId, fundingRoundId, this.screen); + if (isUpdate) { + await interaction.update({ embeds: [embed], components: [row] }); + } else { + await interaction.respond({ embeds: [embed], components: [row] }); + } + } - 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.' }); + private async handleSelectFundingRound(interaction: TrackedInteraction): Promise { + const interactionWithValues = InteractionProperties.toInteractionWithValuesOrUndefined(interaction.interaction); + if (!interactionWithValues) { + throw new EndUserError('Invalid interaction type.'); + } - const manageButton = new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction((this.screen as ProposalHomeScreen).manageSubmittedProposalsAction, 'showFundingRounds')) - .setLabel('Manage Submitted Proposals') - .setStyle(ButtonStyle.Primary); + const proposalId = parseInt(interactionWithValues.values[0]); + const eligibleFundingRounds: FundingRound[] = await FundingRoundLogic.getEligibleFundingRoundsForProposal( + proposalId, + interaction.interaction.user.id, + ); - const row = new ActionRowBuilder().addComponents(manageButton); + if (eligibleFundingRounds.length === 0) { + throw new EndUserError('There are no eligible funding rounds for this proposal at the moment.'); + } - await interaction.update({ embeds: [embed], components: [row] }); - } 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.'); } - public allSubActions(): Action[] { - return []; + 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.'); } - 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); + } +} 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 09bebf9..15d7029 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); } @@ -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..2b7db71 100644 --- a/src/channels/vote/screens/ProjectVotingScreen.ts +++ b/src/channels/vote/screens/ProjectVotingScreen.ts @@ -1,363 +1,374 @@ // 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'; 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'; - - 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('fundingRoundId'); - 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('phase', activePhases[0]); - interaction.Context.set('fundingRoundId', 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, 'fundingRoundId') || ''); - 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', 'fundingRoundId', 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', 'fundingRoundId', 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 = 'selectProject'; - - public static readonly OPERATIONS = { - showProjects: 'showProjects', - selectProject: 'selectProject', - 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, 'fundingRoundId', fundingRoundId.toString(), 'phase', 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, 'fundingRoundId'); - if (!fundingRoundIdRaw) { - throw new EndUserError('fundingRoundId not passed in customId'); - } - const fundingRoundId: number = parseInt(fundingRoundIdRaw); - - const phase: string | undefined = CustomIDOracle.getNamedArgument(interaction.customId, 'phase'); - 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, 'projectId') - - const fundingRoundIdFromCI: string = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID); - - - const fundingRoundId: number = parseInt(fundingRoundIdFromCI); - const phase = ArgumentOracle.getNamedArgument(interaction, 'phase'); - - - interaction.Context.set('projectId', projectId.toString()); - interaction.Context.set('fundingRoundId', fundingRoundId.toString()); - interaction.Context.set('phase', 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, 'fundingRoundId', fundingRoundId.toString(), 'phase', 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 @@ -370,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. @@ -400,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, 'projectId', projectId.toString(), 'fundingRoundId', 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, 'projectId'); - const fundingRoundIdRaw = CustomIDOracle.getNamedArgument(interaction.customId, 'fundingRoundId'); - - 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, 'projectId', projectId.toString(), 'fundingRoundId', 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, 'projectId'); - const fundingRoundIdRaw = CustomIDOracle.getNamedArgument(interaction.customId, 'fundingRoundId'); + 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', 'projectId', projectId.toString(), 'fundingRoundId', fundingRoundId.toString(), 'phase', 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 14ca6cf..d950a1a 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"; @@ -42,6 +43,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 +81,28 @@ export class InVotingFundingRoundPaginator extends FundingRoundPaginator { public async getItems(interaction: TrackedInteraction): Promise { - return await FundingRoundLogic.getEligibleVotingRounds(); + 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); + } +} + +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/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/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/core/BaseClasses.ts b/src/core/BaseClasses.ts index f5893c2..1de0e4c 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); } @@ -356,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 { 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 }, }, 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"]], + }); + } +} 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 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; + } + 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