diff --git a/src/CustomIDOracle.ts b/src/CustomIDOracle.ts index 606eeda..14c9342 100644 --- a/src/CustomIDOracle.ts +++ b/src/CustomIDOracle.ts @@ -82,6 +82,19 @@ export class CustomIDOracle { return outputCustomId; } + static addArgumentsToActionCustomDashboardId(dashboardId: string, action: Action, operation?: string, ...args: string[]): string { + if (args.length % 2 !== 0) { + throw new EndUserError('Arguments must be key-value pairs'); + } + const customId = this.customIdFromRawParts(dashboardId, action.screen.ID, action.ID, operation, ...args); + + if (customId.length > this.MAX_LENGTH) { + throw new EndUserError(`Custom ID exceeds maximum length of ${this.MAX_LENGTH} characters by ${customId.length - this.MAX_LENGTH} characters`); + } + + return customId; + } + static getNamedArgument(customId: string, argName: string): string | undefined { const args = this.getArguments(customId); for (let i = 0; i < args.length; i += 2) { diff --git a/src/channels/admin/screens/ManageProposalStatusesScreen.ts b/src/channels/admin/screens/ManageProposalStatusesScreen.ts index 087e90c..4709a83 100644 --- a/src/channels/admin/screens/ManageProposalStatusesScreen.ts +++ b/src/channels/admin/screens/ManageProposalStatusesScreen.ts @@ -3,16 +3,17 @@ import { Screen, Action, Dashboard, Permission, TrackedInteraction } from '../../../core/BaseClasses'; import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, InteractionWebhook, StringSelectMenuBuilder } from 'discord.js'; import { CustomIDOracle } from '../../../CustomIDOracle'; -import { FundingRoundLogic } from './FundingRoundLogic'; import { AdminProposalLogic } from '../../../logic/AdminProposalLogic'; import { PaginationComponent } from '../../../components/PaginationComponent'; import { DiscordStatus } from '../../DiscordStatus'; -import { FundingRound, Proposal } from '../../../models'; +import { Proposal } from '../../../models'; import { AnyInteractionWithValues } from '../../../types/common'; import { InteractionProperties } from '../../../core/Interaction'; import { ProposalStatus } from '../../../types'; -import { errorMonitor } from 'events'; import { EndUserError } from '../../../Errors'; +import { ActiveFundingRoundPaginator } from '../../../components/FundingRoundPaginator'; +import { ManageProposalStatusesPaginator } from '../../../components/ProposalsPaginator'; +import { ArgumentOracle } from '../../../CustomIDOracle'; export class ManageProposalStatusesScreen extends Screen { public static readonly ID = 'manageProposalStatuses'; @@ -25,8 +26,8 @@ export class ManageProposalStatusesScreen extends Screen { constructor(dashboard: Dashboard, screenId: string) { super(dashboard, screenId); - this.selectFundingRoundAction = new SelectFundingRoundAction(this, SelectFundingRoundAction.ID); - this.selectProposalAction = new SelectProposalAction(this, SelectProposalAction.ID); + this.selectFundingRoundAction = new SelectFundingRoundAction(this); + this.selectProposalAction = new SelectProposalAction(this); this.updateProposalStatusAction = new UpdateProposalStatusAction(this, UpdateProposalStatusAction.ID); } @@ -60,23 +61,25 @@ export class ManageProposalStatusesScreen extends Screen { } } - -export class SelectFundingRoundAction extends PaginationComponent { +export class SelectFundingRoundAction extends Action { public static readonly ID = 'selectFundingRound'; - protected async getTotalPages(): Promise { - const fundingRounds = await FundingRoundLogic.getActiveFundingRounds(); - return Math.ceil(fundingRounds.length / 25); - } + private activeFundingRoundPaginator: ActiveFundingRoundPaginator; - protected async getItemsForPage(interaction: TrackedInteraction, page: number): Promise { - const fundingRounds = await FundingRoundLogic.getActiveFundingRounds(); - return fundingRounds.slice(page * 25, (page + 1) * 25); + constructor(screen: ManageProposalStatusesScreen) { + super(screen, SelectFundingRoundAction.ID); + this.activeFundingRoundPaginator = new ActiveFundingRoundPaginator( + this.screen, + this, + 'selectFundingRound', + ActiveFundingRoundPaginator.ID + ); } protected async handleOperation(interaction: TrackedInteraction, operationId: string): Promise { switch (operationId) { case 'showFundingRounds': + case PaginationComponent.PAGINATION_ARG: await this.handleShowFundingRounds(interaction); break; case 'selectFundingRound': @@ -88,33 +91,7 @@ export class SelectFundingRoundAction extends PaginationComponent { } private async handleShowFundingRounds(interaction: TrackedInteraction): Promise { - const currentPage = this.getCurrentPage(interaction); - const totalPages = await this.getTotalPages(); - const fundingRounds = await this.getItemsForPage(interaction, currentPage); - - if (fundingRounds.length === 0) { - await DiscordStatus.Info.info(interaction, 'There are no active funding rounds.'); - return; - } - - const selectMenu = new StringSelectMenuBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, 'selectFundingRound')) - .setPlaceholder('Select a Funding Round') - .addOptions(fundingRounds.map(fr => ({ - label: fr.name, - value: fr.id.toString(), - description: `Status: ${fr.status}, Budget: ${fr.budget}` - }))); - - const row = new ActionRowBuilder().addComponents(selectMenu); - const components: ActionRowBuilder[] = [row]; - - if (totalPages > 1) { - const paginationRow = this.getPaginationRow(interaction, currentPage, totalPages); - components.push(paginationRow); - } - - await interaction.update({ components }); + await this.activeFundingRoundPaginator.handlePagination(interaction); } private async handleSelectFundingRound(interaction: TrackedInteraction): Promise { @@ -142,52 +119,34 @@ export class SelectFundingRoundAction extends PaginationComponent { } } -export class SelectProposalAction extends PaginationComponent { +export class SelectProposalAction extends Action { public static readonly ID = 'selectProposal'; + public static readonly OPERATIONS = { showProposals: 'showProposals', selectProposal: 'selectProposal' } - protected async getTotalPages(interaction: TrackedInteraction, frId?: string): Promise { - let fundingRoundId = CustomIDOracle.getNamedArgument(interaction.customId, 'frId'); - - if (frId) { - fundingRoundId = frId.toString(); - } - - if (!fundingRoundId) { - await DiscordStatus.Error.error(interaction, 'Funding Round ID not found in customId'); - throw new EndUserError('Funding Round ID not found in customId'); - } - const proposals = await AdminProposalLogic.getProposalsForFundingRound(parseInt(fundingRoundId)); - return Math.ceil(proposals.length / 25); - } - - protected async getItemsForPage(interaction: TrackedInteraction, page: number, frId?: string): Promise { - - let fundingRoundId = CustomIDOracle.getNamedArgument(interaction.customId, 'frId'); - - if (frId) { - fundingRoundId = frId.toString(); - } - - if (!fundingRoundId) { - await DiscordStatus.Error.error(interaction, 'Funding Round ID not found in customId'); - throw new EndUserError('Funding Round ID not found in customId'); - } + private manageProposalStatusesPaginator: ManageProposalStatusesPaginator; - const proposals = await AdminProposalLogic.getProposalsForFundingRound(parseInt(fundingRoundId)); - return proposals.slice(page * 25, (page + 1) * 25); + constructor(screen: ManageProposalStatusesScreen) { + super(screen, SelectProposalAction.ID); + this.manageProposalStatusesPaginator = new ManageProposalStatusesPaginator( + this.screen, + this, + 'selectProposal', + ManageProposalStatusesPaginator.ID + ); } protected async handleOperation(interaction: TrackedInteraction, operationId: string): Promise { switch (operationId) { - case SelectProposalAction.OPERATIONS.showProposals: + case 'showProposals': + case PaginationComponent.PAGINATION_ARG: await this.handleShowProposals(interaction); break; - case SelectProposalAction.OPERATIONS.selectProposal: + case 'selectProposal': await this.handleSelectProposal(interaction); break; default: @@ -195,56 +154,27 @@ export class SelectProposalAction extends PaginationComponent { } } - public async renderHandleShowProposals(interaction: TrackedInteraction, frId?: string): Promise { - let fundingRoundId: string | undefined = CustomIDOracle.getNamedArgument(interaction.customId, 'frId'); - if (frId) { - fundingRoundId = frId; - } - - if (!fundingRoundId) { - await DiscordStatus.Error.error(interaction, 'Funding Round ID not found in customId or arg'); - throw new EndUserError(`Funding Round ID not found in customId or context or arg`); - } - - const currentPage = this.getCurrentPage(interaction); - const totalPages = await this.getTotalPages(interaction, frId); - const proposals = await this.getItemsForPage(interaction, currentPage, frId); - if (proposals.length === 0) { - await DiscordStatus.Info.info(interaction, 'There are no proposals for this funding round.'); - return; - } - - const selectMenu = new StringSelectMenuBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, 'selectProposal', 'frId', fundingRoundId)) - .setPlaceholder('Select a Proposal') - .addOptions(proposals.map(p => ({ - label: p.name, - value: p.id.toString(), - description: `Status: ${p.status}, Budget: ${p.budget}` - }))); + private async handleShowProposals(interaction: TrackedInteraction): Promise { + return await this.renderHandleShowProposals(interaction); + } - const row = new ActionRowBuilder().addComponents(selectMenu); - const components: ActionRowBuilder[] = [row]; + public async renderHandleShowProposals(interaction: TrackedInteraction, frId?: string): Promise { + const fundingRoundId = frId || CustomIDOracle.getNamedArgument(interaction.customId, 'frId'); - if (totalPages > 1) { - const paginationRow = this.getPaginationRow(interaction, currentPage, totalPages); - components.push(paginationRow); + if (!fundingRoundId) { + await DiscordStatus.Error.error(interaction, 'Funding Round ID not found'); + throw new EndUserError('Funding Round ID not found'); } - await interaction.update({ components }); - } - - private async handleShowProposals(interaction: TrackedInteraction): Promise { - return await this.renderHandleShowProposals(interaction); + interaction.Context.set(ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, fundingRoundId); + await this.manageProposalStatusesPaginator.handlePagination(interaction); } private async handleSelectProposal(interaction: TrackedInteraction): Promise { - - const parsedInteraction: AnyInteractionWithValues | undefined = InteractionProperties.toInteractionWithValuesOrUndefined(interaction.interaction); + const parsedInteraction = InteractionProperties.toInteractionWithValuesOrUndefined(interaction.interaction); if (!parsedInteraction) { - await DiscordStatus.Error.error(interaction, 'Interaction does not have values'); - throw new EndUserError('Interaction does not have values'); + throw new EndUserError('Invalid interaction type.'); } const proposalId = parsedInteraction.values[0]; diff --git a/src/channels/vote/screens/ProjectVotingScreen.ts b/src/channels/vote/screens/ProjectVotingScreen.ts index e4e823f..2b7db71 100644 --- a/src/channels/vote/screens/ProjectVotingScreen.ts +++ b/src/channels/vote/screens/ProjectVotingScreen.ts @@ -1,11 +1,21 @@ // src/channels/vote/screens/ProjectVotingScreen.ts import { Screen, Action, Dashboard, Permission, TrackedInteraction, RenderArgs } from '../../../core/BaseClasses'; -import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, MessageActionRowComponentBuilder, StringSelectMenuBuilder, ModalBuilder, TextInputBuilder, TextInputStyle } from 'discord.js'; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + MessageActionRowComponentBuilder, + StringSelectMenuBuilder, + ModalBuilder, + TextInputBuilder, + TextInputStyle, +} from 'discord.js'; import { ArgumentOracle, CustomIDOracle } from '../../../CustomIDOracle'; import { ProposalLogic } from '../../../logic/ProposalLogic'; import { VoteLogic } from '../../../logic/VoteLogic'; -import { GPTSummarizerVoteLog, Proposal } from '../../../models'; +import { FundingRound, GPTSummarizerVoteLog, Proposal } from '../../../models'; import { PaginationComponent } from '../../../components/PaginationComponent'; import { InteractionProperties } from '../../../core/Interaction'; import { FundingRoundLogic } from '../../admin/screens/FundingRoundLogic'; @@ -14,348 +24,351 @@ import logger from '../../../logging'; import { EndUserError, EndUserInfo } from '../../../Errors'; import { proposalStatusToPhase } from '../../proposals/ProposalsForumManager'; - export class ProjectVotingScreen extends Screen { - public static readonly ID = 'prVt'; - - protected permissions: Permission[] = []; - - public readonly selectPhaseAction: SelectPhaseAction; - public readonly selectProjectAction: SelectProjectAction; - public readonly voteProjectAction: VoteProjectAction; - - constructor(dashboard: Dashboard, screenId: string) { - super(dashboard, screenId); - this.selectPhaseAction = new SelectPhaseAction(this, SelectPhaseAction.ID); - this.selectProjectAction = new SelectProjectAction(this, SelectProjectAction.ID); - this.voteProjectAction = new VoteProjectAction(this, VoteProjectAction.ID); + public static readonly ID = 'prVt'; + + protected permissions: Permission[] = []; + + public readonly selectPhaseAction: SelectPhaseAction; + public readonly selectProjectAction: SelectProjectAction; + public readonly voteProjectAction: VoteProjectAction; + + constructor(dashboard: Dashboard, screenId: string) { + super(dashboard, screenId); + this.selectPhaseAction = new SelectPhaseAction(this, SelectPhaseAction.ID); + this.selectProjectAction = new SelectProjectAction(this, SelectProjectAction.ID); + this.voteProjectAction = new VoteProjectAction(this, VoteProjectAction.ID); + } + + protected allSubScreens(): Screen[] { + return []; + } + + protected allActions(): Action[] { + return [this.selectPhaseAction, this.selectProjectAction, this.voteProjectAction]; + } + + protected async getResponse(interaction: TrackedInteraction, args?: RenderArgs): Promise { + const fundingRoundIdFromContext: string | undefined = interaction?.Context.get('frId'); + if (!fundingRoundIdFromContext) { + return { + content: 'FundingRoundID not passed in context', + ephemeral: true, + }; } - protected allSubScreens(): Screen[] { - return []; - } + const fundingRoundId = parseInt(fundingRoundIdFromContext); - protected allActions(): Action[] { - return [ - this.selectPhaseAction, - this.selectProjectAction, - this.voteProjectAction, - ]; + if (!fundingRoundId) { + return { + content: 'Invalid funding round ID.', + ephemeral: true, + }; } - protected async getResponse(interaction: TrackedInteraction, args?: RenderArgs): Promise { - const fundingRoundIdFromContext: string | undefined = interaction?.Context.get('frId'); - if (!fundingRoundIdFromContext) { - return { - content: 'FundingRoundID not passed in context', - ephemeral: true - }; - } - - const fundingRoundId = parseInt(fundingRoundIdFromContext); - - if (!fundingRoundId) { - return { - content: 'Invalid funding round ID.', - ephemeral: true - }; - } - - const fundingRound = await FundingRoundLogic.getFundingRoundById(fundingRoundId); - if (!fundingRound) { - return { - content: 'Funding round not found.', - ephemeral: true - }; - } - - const activePhases = await FundingRoundLogic.getActiveFundingRoundPhases(fundingRoundId); - const shouldSelectPhase = activePhases.length > 1; - - let description: string = `In this section you can cast your votes on approval or rejections of projects in ${fundingRound.name}.`; - if (shouldSelectPhase) { - description += 'Since there is a more than one phase to vote on, you will need to select the phase first. '; - } else { - description += 'Begin by selecting a project to vote '; - } - - const embed = new EmbedBuilder() - .setColor('#0099ff') - .setTitle(`Vote on Projects In ${fundingRound.name}`) - .setDescription(description); - - let components: ActionRowBuilder[] = []; - - if (activePhases.length > 1) { - // TODO: This wasn't tested. Also, will this ever happen? - // NOTE for reader: this branch handles the display of phase to vote on, if there is more than one active phase - const selectPhaseButton = this.selectPhaseAction.getComponent(fundingRoundId); - const displayData = this.selectProjectAction.getSelectProjectComponent(interaction, fundingRoundId, activePhases[0]); - components.push(new ActionRowBuilder().addComponents(selectPhaseButton)); - } else if (activePhases.length === 1) { - const selectProjectButton = this.selectProjectAction.getComponent(fundingRoundId, activePhases[0]); - interaction.Context.set('ph', activePhases[0]); - interaction.Context.set('frId', fundingRoundId.toString()); - const displayData = await this.selectProjectAction.getSelectProjectComponent(interaction, fundingRoundId, activePhases[0]); - components = displayData.components; - } else { - embed.setDescription('There are no active voting phases for this funding round at the moment.'); - } - - return { - embeds: [embed], - components, - ephemeral: true - }; + const fundingRound = await FundingRoundLogic.getFundingRoundById(fundingRoundId); + if (!fundingRound) { + return { + content: 'Funding round not found.', + ephemeral: true, + }; } -} - -class SelectPhaseAction extends Action { - public static readonly ID = 'selectPhase'; - - public static readonly OPERATIONS = { - showPhases: 'showPhases', - selectPhase: 'selectPhase', - }; - - protected async handleOperation(interaction: TrackedInteraction, operationId: string): Promise { - const fundingRoundId = parseInt(CustomIDOracle.getNamedArgument(interaction.customId, 'frId') || ''); - const activePhases = await FundingRoundLogic.getActiveFundingRoundPhases(fundingRoundId); - const options = activePhases.map(phase => ({ - label: phase.charAt(0).toUpperCase() + phase.slice(1), - value: phase, - })); + const activePhases = await FundingRoundLogic.getActiveFundingRoundPhases(fundingRoundId); + const shouldSelectPhase = activePhases.length > 1; - const selectMenu = new StringSelectMenuBuilder() - .setCustomId(CustomIDOracle.generateCustomId(this.screen.dashboard, this.screen, (this.screen as ProjectVotingScreen).selectProjectAction, SelectProjectAction.OPERATIONS.showProjects)) - .setCustomId(CustomIDOracle.addArgumentsToAction(this, 'selectPhase', 'frId', fundingRoundId.toString())) - .setPlaceholder('Select a Voting Phase') - .addOptions(options); - - const row = new ActionRowBuilder().addComponents(selectMenu); - - await interaction.respond({ components: [row], ephemeral: true }); - } - - public allSubActions(): Action[] { - return []; + let description: string = `In this section you can cast your votes on approval or rejections of projects in ${fundingRound.name}.`; + if (shouldSelectPhase) { + description += 'Since there is a more than one phase to vote on, you will need to select the phase first. '; + } else { + description += 'Begin by selecting a project to vote '; } - getComponent(fundingRoundId: number): ButtonBuilder { - return new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, 'selectPhase', 'frId', fundingRoundId.toString())) - .setLabel('Select Voting Phase') - .setStyle(ButtonStyle.Primary); + const embed = new EmbedBuilder().setColor('#0099ff').setTitle(`Vote on Projects In ${fundingRound.name}`).setDescription(description); + + let components: ActionRowBuilder[] = []; + + if (activePhases.length > 1) { + // TODO: This wasn't tested. Also, will this ever happen? + // NOTE for reader: this branch handles the display of phase to vote on, if there is more than one active phase + const selectPhaseButton = this.selectPhaseAction.getComponent(fundingRoundId); + const displayData = this.selectProjectAction.getSelectProjectComponent(interaction, fundingRoundId, activePhases[0]); + components.push(new ActionRowBuilder().addComponents(selectPhaseButton)); + } else if (activePhases.length === 1) { + const selectProjectButton = this.selectProjectAction.getComponent(fundingRoundId, activePhases[0]); + interaction.Context.set('ph', activePhases[0]); + interaction.Context.set('frId', fundingRoundId.toString()); + const displayData = await this.selectProjectAction.getSelectProjectComponent(interaction, fundingRoundId, activePhases[0]); + components = displayData.components; + } else { + embed.setDescription('There are no active voting phases for this funding round at the moment.'); } -} -export class SelectProjectAction extends PaginationComponent { - public static readonly ID = 'slPr'; - - public static readonly OPERATIONS = { - showProjects: 'showProjects', - selectProject: 'slPr', - paginate: 'paginate', + return { + embeds: [embed], + components, + ephemeral: true, }; + } +} - protected async getTotalPages(interaction: TrackedInteraction, phaseArg?: string): Promise { - const fundingRoundIdRaw: string = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID); - - let fundingRoundId: number = parseInt(fundingRoundIdRaw); - - - let phase: string = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.PHASE); - phase = phase.toLowerCase(); - - const projects = await FundingRoundLogic.getActiveProposalsForPhase(fundingRoundId, phase); - return Math.ceil(projects.length / 25); - } - - protected async getItemsForPage(interaction: TrackedInteraction, page: number): Promise { - const fundingRoundIdRaw: string = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID); - - let fundingRoundId: number = parseInt(fundingRoundIdRaw); - - - - const phase: string = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.PHASE); - - const projects = await FundingRoundLogic.getActiveProposalsForPhase(fundingRoundId, phase); - return projects.slice(page * 25, (page + 1) * 25); - } +class SelectPhaseAction extends Action { + public static readonly ID = 'selectPhase'; + + public static readonly OPERATIONS = { + showPhases: 'showPhases', + selectPhase: 'selectPhase', + }; + + protected async handleOperation(interaction: TrackedInteraction, operationId: string): Promise { + const fundingRoundId = parseInt(CustomIDOracle.getNamedArgument(interaction.customId, 'frId') || ''); + const activePhases = await FundingRoundLogic.getActiveFundingRoundPhases(fundingRoundId); + + const options = activePhases.map((phase) => ({ + label: phase.charAt(0).toUpperCase() + phase.slice(1), + value: phase, + })); + + const selectMenu = new StringSelectMenuBuilder() + .setCustomId( + CustomIDOracle.generateCustomId( + this.screen.dashboard, + this.screen, + (this.screen as ProjectVotingScreen).selectProjectAction, + SelectProjectAction.OPERATIONS.showProjects, + ), + ) + .setCustomId(CustomIDOracle.addArgumentsToAction(this, 'selectPhase', 'frId', fundingRoundId.toString())) + .setPlaceholder('Select a Voting Phase') + .addOptions(options); + + const row = new ActionRowBuilder().addComponents(selectMenu); + + await interaction.respond({ components: [row], ephemeral: true }); + } + + public allSubActions(): Action[] { + return []; + } + + getComponent(fundingRoundId: number): ButtonBuilder { + return new ButtonBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction(this, 'selectPhase', 'frId', fundingRoundId.toString())) + .setLabel('Select Voting Phase') + .setStyle(ButtonStyle.Primary); + } +} - public async handleOperation(interaction: TrackedInteraction, operationId: string): Promise { - switch (operationId) { - case SelectProjectAction.OPERATIONS.showProjects: - await this.handleShowProjects(interaction); - break; - case SelectProjectAction.OPERATIONS.selectProject: - await this.handleSelectProject(interaction); - break; - case SelectProjectAction.OPERATIONS.paginate: - await this.handlePagination(interaction); - break; - default: - await this.handleInvalidOperation(interaction, operationId); - } +export class SelectProjectAction extends PaginationComponent { + public static readonly ID = 'slPr'; + + public static readonly OPERATIONS = { + showProjects: 'showProjects', + selectProject: 'slPr', + paginate: 'paginate', + }; + + protected async getTotalPages(interaction: TrackedInteraction, phaseArg?: string): Promise { + const fundingRoundIdRaw: string = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID); + + let fundingRoundId: number = parseInt(fundingRoundIdRaw); + + let phase: string = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.PHASE); + phase = phase.toLowerCase(); + + const projects = await FundingRoundLogic.getActiveProposalsForPhase(fundingRoundId, phase); + return Math.ceil(projects.length / 25); + } + + protected async getItemsForPage(interaction: TrackedInteraction, page: number): Promise { + const fundingRoundIdRaw: string = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID); + + let fundingRoundId: number = parseInt(fundingRoundIdRaw); + + const phase: string = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.PHASE); + + const projects = await FundingRoundLogic.getActiveProposalsForPhase(fundingRoundId, phase); + return projects.slice(page * 25, (page + 1) * 25); + } + + public async handleOperation(interaction: TrackedInteraction, operationId: string): Promise { + switch (operationId) { + case SelectProjectAction.OPERATIONS.showProjects: + await this.handleShowProjects(interaction); + break; + case SelectProjectAction.OPERATIONS.selectProject: + await this.handleSelectProject(interaction); + break; + case SelectProjectAction.OPERATIONS.paginate: + await this.handlePagination(interaction); + break; + default: + await this.handleInvalidOperation(interaction, operationId); } - - public async getSelectProjectComponent(interaction: TrackedInteraction, fundingRoundId: number, phase: string) { - const currentPage = this.getCurrentPage(interaction); - const totalPages = await this.getTotalPages(interaction, phase); - const projects = await this.getItemsForPage(interaction, currentPage); - - const embed = new EmbedBuilder() - .setColor('#0099ff') - .setTitle('Select a Project to Vote On') - .setDescription(`Here, you can select a project that you can vote on. A vote can either an approval or rejection. Page ${currentPage + 1} of ${totalPages}`); - - const embeds = [embed] - - const options = projects.map(p => ({ - label: p.name, - value: p.id.toString(), - description: `Budget: ${p.budget}` - })); - - const selectMenu: StringSelectMenuBuilder = new StringSelectMenuBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, SelectProjectAction.OPERATIONS.selectProject, 'frId', fundingRoundId.toString(), 'ph', phase)) - .setPlaceholder('Select a Project to Vote On') - .addOptions(options); - - const row = new ActionRowBuilder().addComponents(selectMenu); - const components: ActionRowBuilder[] = [row]; - - if (totalPages > 1) { - const paginationRow = this.getPaginationRow(interaction, currentPage, totalPages); - components.push(paginationRow); - } - - return { embeds, components, ephemeral: true } + } + + public async getSelectProjectComponent(interaction: TrackedInteraction, fundingRoundId: number, phase: string) { + const currentPage = this.getCurrentPage(interaction); + const totalPages = await this.getTotalPages(interaction, phase); + const projects = await this.getItemsForPage(interaction, currentPage); + + const embed = new EmbedBuilder() + .setColor('#0099ff') + .setTitle('Select a Project to Vote On') + .setDescription( + `Here, you can select a project that you can vote on. A vote can either an approval or rejection. Page ${currentPage + 1} of ${totalPages}`, + ); + + const embeds = [embed]; + + const options = projects.map((p) => ({ + label: p.name, + value: p.id.toString(), + description: `Budget: ${p.budget}`, + })); + + const selectMenu: StringSelectMenuBuilder = new StringSelectMenuBuilder() + .setCustomId( + CustomIDOracle.addArgumentsToAction(this, SelectProjectAction.OPERATIONS.selectProject, 'frId', fundingRoundId.toString(), 'ph', phase), + ) + .setPlaceholder('Select a Project to Vote On') + .addOptions(options); + + const row = new ActionRowBuilder().addComponents(selectMenu); + const components: ActionRowBuilder[] = [row]; + + if (totalPages > 1) { + const paginationRow = this.getPaginationRow(interaction, currentPage, totalPages); + components.push(paginationRow); } - private async handleShowProjects(interaction: TrackedInteraction): Promise { - const currentPage = this.getCurrentPage(interaction); - const totalPages = await this.getTotalPages(interaction); - const projects = await this.getItemsForPage(interaction, currentPage); - - const fundingRoundIdRaw: string | undefined = CustomIDOracle.getNamedArgument(interaction.customId, 'frId'); - if (!fundingRoundIdRaw) { - throw new EndUserError('fundingRoundId not passed in customId'); - } - const fundingRoundId: number = parseInt(fundingRoundIdRaw); - - const phase: string | undefined = CustomIDOracle.getNamedArgument(interaction.customId, 'ph'); - let parsedPhase: string; + return { embeds, components, ephemeral: true }; + } - if (!phase) { - const parsedInteraction = InteractionProperties.toInteractionWithValuesOrUndefined(interaction.interaction); - if (!parsedInteraction) { - throw new EndUserError('phase neither passed in customId, nor the interaction has values'); - } - const chosenPhase: string = parsedInteraction.values[0]; - parsedPhase = chosenPhase.toLocaleLowerCase(); - } else { - parsedPhase = phase.toLocaleLowerCase(); - } + private async handleShowProjects(interaction: TrackedInteraction): Promise { + const currentPage = this.getCurrentPage(interaction); + const totalPages = await this.getTotalPages(interaction); + const projects = await this.getItemsForPage(interaction, currentPage); - - - if (projects.length === 0) { - throw new EndUserInfo('ℹ️ There are no active projects for voting in this phase at the moment.'); - } - - const displayData = this.getSelectProjectComponent(interaction, fundingRoundId, parsedPhase); - await interaction.respond(displayData); + const fundingRoundIdRaw: string | undefined = CustomIDOracle.getNamedArgument(interaction.customId, 'frId'); + if (!fundingRoundIdRaw) { + throw new EndUserError('fundingRoundId not passed in customId'); } - - private async handleSelectProject(interaction: TrackedInteraction): Promise { - const projectId = ArgumentOracle.getNamedArgument(interaction, 'prId') - - const fundingRoundIdFromCI: string = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID); - - - const fundingRoundId: number = parseInt(fundingRoundIdFromCI); - const phase = ArgumentOracle.getNamedArgument(interaction, 'ph'); - - - interaction.Context.set('prId', projectId.toString()); - interaction.Context.set('frId', fundingRoundId.toString()); - interaction.Context.set('ph', phase); - await (this.screen as ProjectVotingScreen).voteProjectAction.handleOperation( - interaction, - 'showVoteOptions', - { projectId, fundingRoundId, phase } - ); + const fundingRoundId: number = parseInt(fundingRoundIdRaw); + + const phase: string | undefined = CustomIDOracle.getNamedArgument(interaction.customId, 'ph'); + let parsedPhase: string; + + if (!phase) { + const parsedInteraction = InteractionProperties.toInteractionWithValuesOrUndefined(interaction.interaction); + if (!parsedInteraction) { + throw new EndUserError('phase neither passed in customId, nor the interaction has values'); + } + const chosenPhase: string = parsedInteraction.values[0]; + parsedPhase = chosenPhase.toLocaleLowerCase(); + } else { + parsedPhase = phase.toLocaleLowerCase(); } - public allSubActions(): Action[] { - return []; + if (projects.length === 0) { + throw new EndUserInfo('ℹ️ There are no active projects for voting in this phase at the moment.'); } - getComponent(fundingRoundId: number, phase: string): ButtonBuilder { - return new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, SelectProjectAction.OPERATIONS.showProjects, 'frId', fundingRoundId.toString(), 'ph', phase)) - .setLabel('Select Project') - .setStyle(ButtonStyle.Primary); - } + const displayData = this.getSelectProjectComponent(interaction, fundingRoundId, parsedPhase); + await interaction.respond(displayData); + } + + private async handleSelectProject(interaction: TrackedInteraction): Promise { + const projectId = ArgumentOracle.getNamedArgument(interaction, 'prId'); + + const fundingRoundIdFromCI: string = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID); + + const fundingRoundId: number = parseInt(fundingRoundIdFromCI); + const phase = ArgumentOracle.getNamedArgument(interaction, 'ph'); + + interaction.Context.set('prId', projectId.toString()); + interaction.Context.set('frId', fundingRoundId.toString()); + interaction.Context.set('ph', phase); + await (this.screen as ProjectVotingScreen).voteProjectAction.handleOperation(interaction, 'showVoteOptions', { + projectId, + fundingRoundId, + phase, + }); + } + + public allSubActions(): Action[] { + return []; + } + + getComponent(fundingRoundId: number, phase: string): ButtonBuilder { + return new ButtonBuilder() + .setCustomId( + CustomIDOracle.addArgumentsToAction(this, SelectProjectAction.OPERATIONS.showProjects, 'frId', fundingRoundId.toString(), 'ph', phase), + ) + .setLabel('Select Project') + .setStyle(ButtonStyle.Primary); + } } class VoteProjectAction extends Action { - public static readonly ID = 'voteProject'; - - public static readonly OPERATIONS = { - showVoteOptions: 'showVoteOptions', - submitDeliberationReasoning: 'submitDeliberationReasoning', - submitReasoningModal: 'submitReasoningModal', - }; - - public static readonly MODAL_FIELDS = { - reasoning: 'reasoning', - updateReasoning: 'update_reasoning', - }; - - public async handleOperation(interaction: TrackedInteraction, operationId: string, args?: any): Promise { - switch (operationId) { - case VoteProjectAction.OPERATIONS.showVoteOptions: - await this.handleShowVoteOptions(interaction, args); - break; - case VoteProjectAction.OPERATIONS.submitDeliberationReasoning: - await this.handleSubmitDeliberationReasoning(interaction); - break; - case VoteProjectAction.OPERATIONS.submitReasoningModal: - await this.handleModalSubmit(interaction); - break; - default: - await this.handleInvalidOperation(interaction, operationId); - } + public static readonly ID = 'voteProject'; + + public static readonly OPERATIONS = { + showVoteOptions: 'showVoteOptions', + submitDeliberationReasoning: 'submitDeliberationReasoning', + submitReasoningModal: 'submitReasoningModal', + }; + + public static readonly MODAL_FIELDS = { + reasoning: 'reasoning', + updateReasoning: 'update_reasoning', + }; + + public async handleOperation(interaction: TrackedInteraction, operationId: string, args?: any): Promise { + switch (operationId) { + case VoteProjectAction.OPERATIONS.showVoteOptions: + await this.handleShowVoteOptions(interaction, args); + break; + case VoteProjectAction.OPERATIONS.submitDeliberationReasoning: + await this.handleSubmitDeliberationReasoning(interaction); + break; + case VoteProjectAction.OPERATIONS.submitReasoningModal: + await this.handleModalSubmit(interaction); + break; + default: + await this.handleInvalidOperation(interaction, operationId); } + } - private async handleShowVoteOptions(interaction: TrackedInteraction, args: { projectId: number, fundingRoundId: number, phase: string }): Promise { - const { projectId, fundingRoundId} = args; - const project = await ProposalLogic.getProposalByIdOrError(projectId); - logger.info(`Funding Round ID: ${fundingRoundId}`); - - const fundingRoundPhases = await FundingRoundLogic.getActiveFundingRoundPhases(fundingRoundId); - const proposalPhase: string = proposalStatusToPhase(project.status); - - if (!fundingRoundPhases) { - throw new EndUserError("All voting is currently closed in the Funding Round"); - } + private async handleShowVoteOptions( + interaction: TrackedInteraction, + args: { projectId: number; fundingRoundId: number; phase: string }, + ): Promise { + const { projectId, fundingRoundId } = args; + const project = await ProposalLogic.getProposalByIdOrError(projectId); + logger.info(`Funding Round ID: ${fundingRoundId}`); - if (!fundingRoundPhases.includes(proposalPhase)) { - throw new EndUserError(`Voting for this project is unavailable. Funding Round has voting open in ${fundingRoundPhases.join(', ')}, but proposal is in ${proposalPhase}.`) - } + const fundingRoundPhases = await FundingRoundLogic.getActiveFundingRoundPhases(fundingRoundId); + const proposalPhase: string = proposalStatusToPhase(project.status); + if (!fundingRoundPhases) { + throw new EndUserError('All voting is currently closed in the Funding Round'); + } - const hasUserSubmittedReasoning: boolean = await VoteLogic.hasUserSubmittedDeliberationReasoning(interaction.interaction.user.id, projectId); - const gptResponseButtonLabel: string = hasUserSubmittedReasoning ? '✏️ Update Reasoning' : '✍️ Submit Reasoning'; - const gptResponseButtonLabelWithoutEmoji: string = hasUserSubmittedReasoning ? 'Update Reasoning' : 'Submit Reasoning'; + if (!fundingRoundPhases.includes(proposalPhase)) { + throw new EndUserError( + `Voting for this project is unavailable. Funding Round has voting open in ${fundingRoundPhases.join( + ', ', + )}, but proposal is in ${proposalPhase}.`, + ); + } + const hasUserSubmittedReasoning: boolean = await VoteLogic.hasUserSubmittedDeliberationReasoning(interaction.interaction.user.id, projectId); + const gptResponseButtonLabel: string = hasUserSubmittedReasoning ? '✏️ Update Reasoning' : '✍️ Submit Reasoning'; + const gptResponseButtonLabelWithoutEmoji: string = hasUserSubmittedReasoning ? 'Update Reasoning' : 'Submit Reasoning'; - // assuming consideration phase - let description: string = ` + // assuming consideration phase + let description: string = ` Voting Stage: 1️⃣/3️⃣ Current Phase: ${proposalPhase} Next Phase: deliberation @@ -368,23 +381,23 @@ class VoteProjectAction extends Action { - Reject - Remove most recent 'Approve' vote, if exists. The voting is done on-chain. Click the button below to vote. - ` + `; - if (proposalPhase === 'deliberation') { - description = ` + if (proposalPhase === 'deliberation') { + description = ` Voting Stage: 2️⃣/3️⃣ Current Phase: ${proposalPhase} Previous Phase: consideration Next Phase: funding The current phase is the deliberation phase. In this phase, you can submit your reasoning for why you believe the project should be funded or not. Your reasoning and discord user ID will be stored for internal records and may be analyzed by third-party systems. By pressing the "${gptResponseButtonLabelWithoutEmoji}" button below, you agree to these terms. - ` - } + `; + } - if (proposalPhase === 'funding') { - description = ` + if (proposalPhase === 'funding') { + description = ` Voting Stage: 3️⃣/3️⃣ - Curernt Phase: ${proposalPhase} Previous Phase: deliberation + Current Phase: ${proposalPhase} Previous Phase: deliberation ⚠️ This is the final voting stage. The votes in this stage decide which projects will be funded. @@ -398,150 +411,165 @@ class VoteProjectAction extends Action { - Reject - Remove most recent 'Approve' vote, if exists. The voting is done on-chain. Click the button below to vote. - ` - } - // TODO: add description for each phase - const embed = new EmbedBuilder() - .setColor('#0099ff') - .setTitle(`Vote on Project: ${project.name}`) - .setDescription(description) - .addFields( - { name: 'Budget', value: project.budget.toString(), inline: true }, - { name: 'Status', value: project.status, inline: true }, - { name: 'URI', value: project.uri, inline: true }, - { name: 'Proposer Discord ID', value: project.proposerDuid, inline: true } - ); - - let components: ActionRowBuilder[] = []; - switch (proposalPhase.toLowerCase()) { - case 'consideration': - case 'funding': - const voteLink = OCVLinkGenerator.generateProjectVoteLink(projectId, proposalPhase); - const voteButton = new ButtonBuilder() - .setLabel('🗳️ Vote On-Chain') - .setStyle(ButtonStyle.Link) - .setURL(voteLink); - components.push(new ActionRowBuilder().addComponents(voteButton)); - break; - case 'deliberation': - const deliberationButton = new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, VoteProjectAction.OPERATIONS.submitDeliberationReasoning, 'prId', projectId.toString(), 'frId', fundingRoundId.toString())) - .setLabel(gptResponseButtonLabel) - .setStyle(ButtonStyle.Primary); - components.push(new ActionRowBuilder().addComponents(deliberationButton)); - break; - } - - await interaction.respond({ embeds: [embed], components, ephemeral: true }); + `; } - - private async handleSubmitDeliberationReasoning(interaction: TrackedInteraction): Promise { - const projectIdRaw = CustomIDOracle.getNamedArgument(interaction.customId, 'prId'); - const fundingRoundIdRaw = CustomIDOracle.getNamedArgument(interaction.customId, 'frId'); - - if (!projectIdRaw) { - throw new EndUserError('projectId not passed in customId'); - } - - if (!fundingRoundIdRaw) { - throw new EndUserError('fundingRoundId not passed in customId'); - } - - const projectId: number = parseInt(projectIdRaw); - const fundingRoundId: number = parseInt(fundingRoundIdRaw); - - const hasUserSubmittedReasoning: boolean = await VoteLogic.hasUserSubmittedDeliberationReasoning(interaction.interaction.user.id, projectId); - - const title: string = hasUserSubmittedReasoning ? '✏️ Update Reasoning' : '✍️ Submit Reasoning'; - - const modal = new ModalBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, VoteProjectAction.OPERATIONS.submitReasoningModal, 'prId', projectId.toString(), 'frId', fundingRoundId.toString())) - .setTitle(title); - - const reasoningInput = new TextInputBuilder() - .setCustomId('reasoning') - .setLabel('Should this project be funded? Why?') - .setStyle(TextInputStyle.Paragraph) - .setRequired(true); - - modal.addComponents(new ActionRowBuilder().addComponents(reasoningInput)); - - if (hasUserSubmittedReasoning) { - const reasonInput = new TextInputBuilder() - .setCustomId(VoteProjectAction.MODAL_FIELDS.updateReasoning) - .setLabel('I am updating my reasoning because...') - .setStyle(TextInputStyle.Paragraph) - .setRequired(true); - - modal.addComponents(new ActionRowBuilder().addComponents(reasonInput)); - } - - - const modalInteraction = InteractionProperties.toShowModalOrUndefined(interaction.interaction); - if (!modalInteraction) { - throw new EndUserError('Failed to show modal. Please try again.'); - } - - await modalInteraction.showModal(modal); + // TODO: add description for each phase + const embed = new EmbedBuilder().setColor('#0099ff').setTitle(`Vote on Project: ${project.name}`).setDescription(description).addFields( + { name: 'Budget', value: project.budget.toString(), inline: true }, + { name: 'Status', value: project.status, inline: true }, + { name: 'URI', value: project.uri, inline: true }, + { + name: 'Proposer Discord ID', + value: project.proposerDuid, + inline: true, + }, + ); + + let components: ActionRowBuilder[] = []; + switch (proposalPhase.toLowerCase()) { + case 'consideration': + case 'funding': + const voteLink = OCVLinkGenerator.generateProjectVoteLink(projectId, proposalPhase); + const voteButton = new ButtonBuilder().setLabel('🗳️ Vote On-Chain').setStyle(ButtonStyle.Link).setURL(voteLink); + components.push(new ActionRowBuilder().addComponents(voteButton)); + break; + case 'deliberation': + const fundingRound: FundingRound = await FundingRoundLogic.getFundingRoundByIdOrError(fundingRoundId); + const customId = CustomIDOracle.customIdFromRawParts( + fundingRound.forumChannelId, + this.screen.ID, + VoteProjectAction.ID, + VoteProjectAction.OPERATIONS.submitDeliberationReasoning, + 'prId', + projectId.toString(), + 'frId', + fundingRoundId.toString(), + ); + const deliberationButton = new ButtonBuilder().setCustomId(customId).setLabel(gptResponseButtonLabel).setStyle(ButtonStyle.Primary); + components.push(new ActionRowBuilder().addComponents(deliberationButton)); + break; } - public async handleModalSubmit(interaction: TrackedInteraction): Promise { - const modalInteraction = InteractionProperties.toModalSubmitInteractionOrUndefined(interaction.interaction); - if (!modalInteraction) { - throw new EndUserError('Invalid interaction type.'); - } - - const projectIdRaw = CustomIDOracle.getNamedArgument(interaction.customId, 'prId'); - const fundingRoundIdRaw = CustomIDOracle.getNamedArgument(interaction.customId, 'frId'); + await interaction.respond({ embeds: [embed], components, ephemeral: true }); + } - if (!projectIdRaw) { - throw new EndUserError('projectId not passed in customId'); - } + private async handleSubmitDeliberationReasoning(interaction: TrackedInteraction): Promise { + const projectIdRaw = CustomIDOracle.getNamedArgument(interaction.customId, 'prId'); + const fundingRoundIdRaw = CustomIDOracle.getNamedArgument(interaction.customId, 'frId'); - if (!fundingRoundIdRaw) { - throw new EndUserError('fundingRoundId not passed in customId'); - } + if (!projectIdRaw) { + throw new EndUserError('projectId not passed in customId'); + } - const projectId: number = parseInt(projectIdRaw); - const fundingRoundId: number = parseInt(fundingRoundIdRaw); + if (!fundingRoundIdRaw) { + throw new EndUserError('fundingRoundId not passed in customId'); + } + const projectId: number = parseInt(projectIdRaw); + const fundingRoundId: number = parseInt(fundingRoundIdRaw); + + const hasUserSubmittedReasoning: boolean = await VoteLogic.hasUserSubmittedDeliberationReasoning(interaction.interaction.user.id, projectId); + + const title: string = hasUserSubmittedReasoning ? '✏️ Update Reasoning' : '✍️ Submit Reasoning'; + + const fundingRound: FundingRound = await FundingRoundLogic.getFundingRoundByIdOrError(fundingRoundId); + const modal = new ModalBuilder() + .setCustomId( + CustomIDOracle.addArgumentsToActionCustomDashboardId( + fundingRound.forumChannelId, + this, + VoteProjectAction.OPERATIONS.submitReasoningModal, + 'prId', + projectId.toString(), + 'frId', + fundingRoundId.toString(), + ), + ) + .setTitle(title); + + const reasoningInput = new TextInputBuilder() + .setCustomId('reasoning') + .setLabel('Should this project be funded? Why?') + .setStyle(TextInputStyle.Paragraph) + .setRequired(true); + + modal.addComponents(new ActionRowBuilder().addComponents(reasoningInput)); + + if (hasUserSubmittedReasoning) { + const reasonInput = new TextInputBuilder() + .setCustomId(VoteProjectAction.MODAL_FIELDS.updateReasoning) + .setLabel('I am updating my reasoning because...') + .setStyle(TextInputStyle.Paragraph) + .setRequired(true); + + modal.addComponents(new ActionRowBuilder().addComponents(reasonInput)); + } - const reasoning = modalInteraction.fields.getTextInputValue(VoteProjectAction.MODAL_FIELDS.reasoning); - let updateReasoning: string | null = null; - try { - updateReasoning = modalInteraction.fields.getTextInputValue(VoteProjectAction.MODAL_FIELDS.updateReasoning); - } catch (error) { - // reason not present in modal, it's safe to ignore + const modalInteraction = InteractionProperties.toShowModalOrUndefined(interaction.interaction); + if (!modalInteraction) { + throw new EndUserError('Failed to show modal. Please try again.'); + } - } + await modalInteraction.showModal(modal); + } + public async handleModalSubmit(interaction: TrackedInteraction): Promise { + const modalInteraction = InteractionProperties.toModalSubmitInteractionOrUndefined(interaction.interaction); + if (!modalInteraction) { + throw new EndUserError('Invalid interaction type.'); + } - try { - await VoteLogic.submitDeliberationReasoning(interaction.interaction.user.id, projectId, fundingRoundId, reasoning, updateReasoning); + const projectIdRaw = CustomIDOracle.getNamedArgument(interaction.customId, 'prId'); + const fundingRoundIdRaw = CustomIDOracle.getNamedArgument(interaction.customId, 'frId'); - const embed = new EmbedBuilder() - .setColor('#28a745') - .setTitle('Reasoning Submitted Successfully') - .setDescription('Your reasoning has been recorded and will be submitted to the GPTSummarizer bot for analysis.') - .addFields( - { name: 'Warning', value: 'Your submitted data will be stored for internal records and may be analyzed by our or third-party systems.' } - ); + if (!projectIdRaw) { + throw new EndUserError('projectId not passed in customId'); + } - await interaction.respond({ embeds: [embed], ephemeral: true }); - } catch (error) { - logger.error('Error submitting reasoning'); - throw new EndUserError('Failed to submit reasoning', error); - } + if (!fundingRoundIdRaw) { + throw new EndUserError('fundingRoundId not passed in customId'); } - public allSubActions(): Action[] { - return []; + const projectId: number = parseInt(projectIdRaw); + const fundingRoundId: number = parseInt(fundingRoundIdRaw); + + const reasoning = modalInteraction.fields.getTextInputValue(VoteProjectAction.MODAL_FIELDS.reasoning); + let updateReasoning: string | null = null; + try { + updateReasoning = modalInteraction.fields.getTextInputValue(VoteProjectAction.MODAL_FIELDS.updateReasoning); + } catch (error) { + // reason not present in modal, it's safe to ignore } - getComponent(projectId: number, fundingRoundId: number, phase: string): ButtonBuilder { - return new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, 'showVoteOptions', 'prId', projectId.toString(), 'frId', fundingRoundId.toString(), 'ph', phase)) - .setLabel('Vote on Project') - .setStyle(ButtonStyle.Primary); + try { + await VoteLogic.submitDeliberationReasoning(interaction.interaction.user.id, projectId, fundingRoundId, reasoning, updateReasoning); + + const embed = new EmbedBuilder() + .setColor('#28a745') + .setTitle('Reasoning Submitted Successfully') + .setDescription('Your reasoning has been recorded and will be submitted to the GPTSummarizer bot for analysis.') + .addFields({ + name: 'Warning', + value: 'Your submitted data will be stored for internal records and may be analyzed by our or third-party systems.', + }); + + await interaction.respond({ embeds: [embed], ephemeral: true }); + } catch (error) { + logger.error('Error submitting reasoning'); + throw new EndUserError('Failed to submit reasoning', error); } -} \ No newline at end of file + } + + public allSubActions(): Action[] { + return []; + } + + getComponent(projectId: number, fundingRoundId: number, phase: string): ButtonBuilder { + return new ButtonBuilder() + .setCustomId( + CustomIDOracle.addArgumentsToAction(this, 'showVoteOptions', 'prId', projectId.toString(), 'frId', fundingRoundId.toString(), 'ph', phase), + ) + .setLabel('Vote on Project') + .setStyle(ButtonStyle.Primary); + } +} diff --git a/src/components/FundingRoundPaginator.ts b/src/components/FundingRoundPaginator.ts index f55c769..d950a1a 100644 --- a/src/components/FundingRoundPaginator.ts +++ b/src/components/FundingRoundPaginator.ts @@ -94,4 +94,15 @@ export class ConsiderationFundingRoundPaginator extends FundingRoundPaginator { const duid: string = interaction.discordUserId; return await ConsiderationLogic.getEligibleFundingRounds(duid); } +} + +export class ActiveFundingRoundPaginator extends FundingRoundPaginator { + public static readonly ID = 'activeFRPag'; + + public args: string[] = [] + public title: string = "Select an Active Funding Round"; + + public async getItems(interaction: TrackedInteraction): Promise { + return await FundingRoundLogic.getActiveFundingRounds(); + } } \ No newline at end of file diff --git a/src/components/ProposalsPaginator.ts b/src/components/ProposalsPaginator.ts index 3cb0691..3fe1277 100644 --- a/src/components/ProposalsPaginator.ts +++ b/src/components/ProposalsPaginator.ts @@ -37,7 +37,26 @@ export class EditMySubmittedProposalsPaginator extends ProposalsPaginator { ); return eligibleProposals; } +} +export class ManageProposalStatusesPaginator extends ProposalsPaginator { + public static readonly ID = "ManPropStatPag"; + public args: string[] = []; + public title: string = "Select a Proposal to Manage"; + public readonly REQUIRED_ARGUMENTS: string[] = [ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID]; + public async getItems(interaction: TrackedInteraction): Promise { + const fundingRoundId: string = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID); + const fundingRoundIdNum: number = parseInt(fundingRoundId); + return await ProposalLogic.getProposalsForFundingRound(fundingRoundIdNum); + } + + protected async getOptions(interaction: TrackedInteraction, items: Proposal[]): Promise { + return items.map((p: Proposal) => ({ + label: p.name, + value: p.id.toString(), + description: `Status: ${p.status}, Budget: ${p.budget}` + })); + } } \ No newline at end of file diff --git a/src/logic/ProposalLogic.ts b/src/logic/ProposalLogic.ts index 7e0623e..0d5c127 100644 --- a/src/logic/ProposalLogic.ts +++ b/src/logic/ProposalLogic.ts @@ -1,17 +1,27 @@ -import { EndUserError } from '../Errors'; -import logger from '../logging'; -import { Proposal, FundingRound, TopicSMEGroupProposalCreationLimiter, SMEGroupMembership } from '../models'; -import { FundingRoundStatus, ProposalAttributes, ProposalCreationAttributes, ProposalStatus } from '../types'; -import { Screen } from '../core/BaseClasses'; +import { EndUserError } from "../Errors"; +import logger from "../logging"; +import { + Proposal, + FundingRound, + TopicSMEGroupProposalCreationLimiter, + SMEGroupMembership, +} from "../models"; +import { + FundingRoundStatus, + ProposalAttributes, + ProposalCreationAttributes, + ProposalStatus, +} from "../types"; +import { Screen } from "../core/BaseClasses"; export class ProposalLogic { static async getUserDraftProposals(userId: string): Promise { return await Proposal.findAll({ where: { proposerDuid: userId, - status: ProposalStatus.DRAFT + status: ProposalStatus.DRAFT, }, - order: [['createdAt', 'DESC']] + order: [["createdAt", "DESC"]], }); } @@ -29,14 +39,20 @@ export class ProposalLogic { return proposal; } - static async createProposal(data: ProposalCreationAttributes): Promise { + static async createProposal( + data: ProposalCreationAttributes + ): Promise { return await Proposal.create({ ...data, - status: ProposalStatus.DRAFT + status: ProposalStatus.DRAFT, }); } - static async updateProposal(id: number, data: Partial, screen?: any): Promise { + static async updateProposal( + id: number, + data: Partial, + screen?: any + ): Promise { const proposal = await Proposal.findByPk(id); if (!proposal) { return null; @@ -45,13 +61,15 @@ export class ProposalLogic { if (screen && proposal.fundingRoundId && proposal.forumThreadId) { try { - const { ProposalsForumManager } = await import('../channels/proposals/ProposalsForumManager'); + const { ProposalsForumManager } = await import( + "../channels/proposals/ProposalsForumManager" + ); await ProposalsForumManager.refreshThread(proposal, screen); } catch (error) { - logger.error('Error refreshing forum thread:', error); + logger.error("Error refreshing forum thread:", error); } } - return proposal + return proposal; } static async deleteProposal(id: number): Promise { @@ -62,10 +80,12 @@ export class ProposalLogic { if (proposal.forumThreadId) { try { - const { ProposalsForumManager } = await import('../channels/proposals/ProposalsForumManager'); + const { ProposalsForumManager } = await import( + "../channels/proposals/ProposalsForumManager" + ); await ProposalsForumManager.deleteThread(proposal); } catch (error) { - logger.error('Error deleting forum thread:', error); + logger.error("Error deleting forum thread:", error); } } @@ -73,7 +93,11 @@ export class ProposalLogic { return true; } - static async submitProposalToFundingRound(proposalId: number, fundingRoundId: number, screen?: any): Promise { + static async submitProposalToFundingRound( + proposalId: number, + fundingRoundId: number, + screen?: any + ): Promise { const proposal = await Proposal.findByPk(proposalId); const fundingRound = await FundingRound.findByPk(fundingRoundId); @@ -82,98 +106,133 @@ export class ProposalLogic { } if (proposal.status !== ProposalStatus.DRAFT) { - throw new EndUserError('Only draft proposals can be submitted to funding rounds.'); + throw new EndUserError( + "Only draft proposals can be submitted to funding rounds." + ); } // Check if the user has permission to submit to this funding round - const hasPermission = await this.userHasPermissionToSubmit(proposal.proposerDuid, fundingRound.topicId); + const hasPermission = await this.userHasPermissionToSubmit( + proposal.proposerDuid, + fundingRound.topicId + ); if (!hasPermission) { - throw new EndUserError('You do not have permission to submit proposals to this funding round.'); + throw new EndUserError( + "You do not have permission to submit proposals to this funding round." + ); } await proposal.update({ fundingRoundId: fundingRoundId, - status: ProposalStatus.CONSIDERATION_PHASE + status: ProposalStatus.CONSIDERATION_PHASE, }); if (screen) { - try { - const { ProposalsForumManager } = await import('../channels/proposals/ProposalsForumManager'); - await ProposalsForumManager.createThread(proposal, fundingRound, screen); + const { ProposalsForumManager } = await import( + "../channels/proposals/ProposalsForumManager" + ); + await ProposalsForumManager.createThread( + proposal, + fundingRound, + screen + ); } catch (error) { - logger.error('Error creating forum thread for proposal:', error); + logger.error("Error creating forum thread for proposal:", error); //TODO: Consider whether to revert the proposal update or not? } - } return proposal; } - static async getUserProposalsForFundingRound(userId: string, fundingRoundId: number): Promise { + static async getUserProposalsForFundingRound( + userId: string, + fundingRoundId: number + ): Promise { return await Proposal.findAll({ where: { proposerDuid: userId, - fundingRoundId: fundingRoundId + fundingRoundId: fundingRoundId, }, - order: [['createdAt', 'DESC']] + order: [["createdAt", "DESC"]], }); } - static async cancelProposal(proposalId: number, screen?: Screen): Promise { + static async cancelProposal( + proposalId: number, + screen?: Screen + ): Promise { const proposal = await Proposal.findByPk(proposalId); if (!proposal) { throw new EndUserError(`Proposal with ID ${proposalId} not found.`); } - if (proposal.status === ProposalStatus.DRAFT || proposal.status === ProposalStatus.CANCELLED) { - throw new EndUserError('Cannot cancel a draft or already cancelled proposal.'); + if ( + proposal.status === ProposalStatus.DRAFT || + proposal.status === ProposalStatus.CANCELLED + ) { + throw new EndUserError( + "Cannot cancel a draft or already cancelled proposal." + ); } if (!proposal.fundingRoundId) { throw new EndUserError(`Cannot cancel proposal without a funding round.`); } - // Not passing the screen to prevent update to the forum thread, as we want to delete the thread - const updatedProposal: Proposal = await this.updateProposalStatus(proposalId, ProposalStatus.CANCELLED); + const updatedProposal: Proposal = await this.updateProposalStatus( + proposalId, + ProposalStatus.CANCELLED + ); if (screen) { try { - const { ProposalsForumManager } = await import('../channels/proposals/ProposalsForumManager'); + const { ProposalsForumManager } = await import( + "../channels/proposals/ProposalsForumManager" + ); await ProposalsForumManager.deleteThread(proposal); } catch (error) { - throw new EndUserError('Error deleting forum thread', error); + throw new EndUserError("Error deleting forum thread", error); } } return updatedProposal; } - static async updateProposalStatus(proposalId: number, status: ProposalStatus, screen?: Screen) { + static async updateProposalStatus( + proposalId: number, + status: ProposalStatus, + screen?: Screen + ) { const proposal = await Proposal.findByPk(proposalId); if (!proposal) { - throw new EndUserError('Proposal not found'); + throw new EndUserError("Proposal not found"); } await proposal.update({ status }); if (screen && proposal.fundingRoundId && proposal.forumThreadId) { try { - const { ProposalsForumManager } = await import('../channels/proposals/ProposalsForumManager'); + const { ProposalsForumManager } = await import( + "../channels/proposals/ProposalsForumManager" + ); await ProposalsForumManager.refreshThread(proposal, screen); } catch (error) { - logger.error('Error refreshing forum thread:', error); + logger.error("Error refreshing forum thread:", error); } } return proposal; } - private static async userHasPermissionToSubmit(userId: string, topicId: number): Promise { + private static async userHasPermissionToSubmit( + userId: string, + topicId: number + ): Promise { // Check if there are any limitations for this topic const limitations = await TopicSMEGroupProposalCreationLimiter.findAll({ - where: { topicId: topicId } + where: { topicId: topicId }, }); // If there are no limitations, everyone can submit @@ -183,13 +242,27 @@ export class ProposalLogic { // Check if the user belongs to any of the allowed SME groups const userMemberships = await SMEGroupMembership.findAll({ - where: { duid: userId } + where: { duid: userId }, }); - const userSMEGroupIds = userMemberships.map(membership => membership.smeGroupId); - const allowedSMEGroupIds = limitations.map(limitation => limitation.smeGroupId); + const userSMEGroupIds = userMemberships.map( + (membership) => membership.smeGroupId + ); + const allowedSMEGroupIds = limitations.map( + (limitation) => limitation.smeGroupId + ); - return userSMEGroupIds.some(id => allowedSMEGroupIds.includes(id)); + return userSMEGroupIds.some((id) => allowedSMEGroupIds.includes(id)); } -} \ No newline at end of file + static async getProposalsForFundingRound( + fundingRoundId: number + ): Promise { + return await Proposal.findAll({ + where: { + fundingRoundId: fundingRoundId, + }, + order: [["createdAt", "DESC"]], + }); + } +}