From 50519cb62e2df451bb0d31fe7af1ac5937487868 Mon Sep 17 00:00:00 2001 From: Illya Gerasymchuk Date: Tue, 13 Aug 2024 20:58:17 +0100 Subject: [PATCH 01/15] feat: add arguments interface to CustomIDOracle --- src/CustomIDOracle.ts | 70 ++++++++++++++++++++++++++--------- src/Errors.ts | 4 ++ src/channels/DiscordStatus.ts | 1 - 3 files changed, 57 insertions(+), 18 deletions(-) diff --git a/src/CustomIDOracle.ts b/src/CustomIDOracle.ts index 949fac8..e2f2008 100644 --- a/src/CustomIDOracle.ts +++ b/src/CustomIDOracle.ts @@ -1,6 +1,7 @@ +import { ExclusionConstraintError } from 'sequelize'; import type { Dashboard, Screen, Action, TrackedInteraction } from './core/BaseClasses'; import { InteractionProperties } from './core/Interaction'; -import { EndUserError } from './Errors'; +import { EndUserError, NotFoundEndUserError } from './Errors'; import logger from './logging'; import { AnyInteractionWithValues } from './types/common'; @@ -8,6 +9,11 @@ import { AnyInteractionWithValues } from './types/common'; export class CustomIDOracle { static readonly SEPARATOR = ':'; static readonly MAX_LENGTH = 100; + protected customId: string; + + constructor(customId: string) { + this.customId = customId; + } static generateCustomId(dashboard: Dashboard, screen?: Screen, action?: Action, operation?: string, ...args: string[]): string { const parts = [dashboard.ID]; @@ -15,23 +21,23 @@ export class CustomIDOracle { if (screen) { parts.push(screen.ID); } - + if (action) { parts.push(action.ID); } - + if (operation) { parts.push(operation); } - + parts.push(...args); - + 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.`); } - + return customId; } @@ -41,23 +47,23 @@ export class CustomIDOracle { if (screenId) { parts.push(screenId); } - + if (actionId) { parts.push(actionId); } - + if (operationId) { parts.push(operationId); } - + parts.push(...args); - + 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.`); } - + return customId; } @@ -86,6 +92,22 @@ export class CustomIDOracle { return undefined; } + /** + * Sets or updates an argument in the interaction's custom ID. If the argument doesn't exist, it will be added. If it does exist, it will be updated. + */ + public setArgument(argName: string, value: string): string { + const args: string[] = CustomIDOracle.getArguments(this.customId); + const argIndex: number = args.indexOf(argName); + if (argIndex === -1) { + args.push(argName, value); + } else { + args[argIndex + 1] = value; + } + const updatedCustomId: string = CustomIDOracle.customIdFromRawParts(CustomIDOracle.getDashboardId(this.customId), CustomIDOracle.getScreenId(this.customId), CustomIDOracle.getActionId(this.customId), CustomIDOracle.getOperationId(this.customId), ...args); + + return updatedCustomId; + } + static parseCustomId(customId: string): string[] { return customId.split(this.SEPARATOR); } @@ -124,9 +146,22 @@ export class ArgumentOracle { PHASE: 'phase', } - static getNamedArgument(intreaction: TrackedInteraction, argName: string, valuesIndex?:number): string { + static isArgumentEquals(intreaction: TrackedInteraction, argName: string, value: string): boolean { + try { + const argValue = this.getNamedArgument(intreaction, argName); + const result: boolean = argValue.toLowerCase() === value.toLowerCase(); + logger.debug(`Comparing argument ${argName}'s ${argValue} with ${value}. Result: ${result}`); + return result; + } catch (error) { + const argValueFromContext: string | undefined = intreaction.Context.get(argName); + const result: boolean = argValueFromContext?.toLowerCase() === value.toLowerCase(); + return result; + } + } + + static getNamedArgument(intreaction: TrackedInteraction, argName: string, valuesIndex?: number, inputIndex?: string): string { const argFromCustomId: string | undefined = CustomIDOracle.getNamedArgument(intreaction.customId, argName); - + if (argFromCustomId) { logger.debug(`Found argument ${argName} in custom ID: ${argFromCustomId}`); return argFromCustomId.toLowerCase(); @@ -145,10 +180,11 @@ export class ArgumentOracle { logger.debug(`Found argument ${argName} in values: ${argFromValues}`); return argFromValues.toLowerCase(); } else { - throw new EndUserError(`Argument ${argName} not found in custom ID, context or values.`); + throw new NotFoundEndUserError(`Argument ${argName} not found in custom ID, context or values.`); } } - throw new EndUserError(`Argument ${argName} not found in custom ID or context.`); + throw new NotFoundEndUserError(`Argument ${argName} not found in custom ID or context.`); } + } \ No newline at end of file diff --git a/src/Errors.ts b/src/Errors.ts index 1cec340..7eac3a8 100644 --- a/src/Errors.ts +++ b/src/Errors.ts @@ -18,5 +18,9 @@ export class EndUserError extends GovBotError { } +export class NotFoundEndUserError extends EndUserError { +} + + export class EndUserInfo extends GovBotError { } \ No newline at end of file diff --git a/src/channels/DiscordStatus.ts b/src/channels/DiscordStatus.ts index 18a52d7..b610a08 100644 --- a/src/channels/DiscordStatus.ts +++ b/src/channels/DiscordStatus.ts @@ -26,7 +26,6 @@ export class DiscordStatus { }, async handleError(interaction: TrackedInteraction, error: EndUserError | Error | unknown): Promise { - if (error instanceof EndUserError) { const parentError: Error | undefined | unknown = error.parentError; let message: string; From 7935e9b32bf88a91a5a6bdffbc9a3c42782ff0a9 Mon Sep 17 00:00:00 2001 From: Illya Gerasymchuk Date: Wed, 14 Aug 2024 19:49:20 +0100 Subject: [PATCH 02/15] feat: add defenition of 'READY' for FundingRound ORM model --- src/models/index.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/models/index.ts b/src/models/index.ts index cc9fd78..6e02600 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -320,6 +320,23 @@ class FundingRound extends Model Promise; public createDeliberationPhase!: (phase: DeliberationPhase) => Promise; public createFundingVotingPhase!: (phase: FundingVotingPhase) => Promise; + + public async isReady(): Promise { + return !!( + this.name && + this.description && + this.budget && + this.stakingLedgerEpoch && + this.topicId && + this.startAt && + this.endAt && + this.votingOpenUntil && + await this.getConsiderationPhase() && + await this.getConsiderationPhase() && + await this.getFundingVotingPhase() && + this.forumChannelId + ); + } } FundingRound.init( From b55c047226dcc8f4821305c4a656ef357407ce3f Mon Sep 17 00:00:00 2001 From: Illya Gerasymchuk Date: Wed, 14 Aug 2024 19:50:11 +0100 Subject: [PATCH 03/15] refactor: funding round creation and editing + unificiation --- .../screens/ManageFundingRoundsScreen.ts | 1310 ++++++++--------- 1 file changed, 598 insertions(+), 712 deletions(-) diff --git a/src/channels/admin/screens/ManageFundingRoundsScreen.ts b/src/channels/admin/screens/ManageFundingRoundsScreen.ts index 68513bb..a4cfc12 100644 --- a/src/channels/admin/screens/ManageFundingRoundsScreen.ts +++ b/src/channels/admin/screens/ManageFundingRoundsScreen.ts @@ -1,16 +1,20 @@ import { Screen, Action, Dashboard, Permission, TrackedInteraction, RenderArgs } from '../../../core/BaseClasses'; -import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, StringSelectMenuBuilder, MessageActionRowComponentBuilder, TextInputStyle, TextInputBuilder, ModalBuilder, UserSelectMenuBuilder, User } from 'discord.js'; +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, StringSelectMenuBuilder, MessageActionRowComponentBuilder, TextInputStyle, TextInputBuilder, ModalBuilder, UserSelectMenuBuilder, User, ForumChannel, ChannelType } from 'discord.js'; import { FundingRoundLogic } from './FundingRoundLogic'; import { ArgumentOracle, CustomIDOracle } from '../../../CustomIDOracle'; -import { FundingRound, FundingRoundDeliberationCommitteeSelection, SMEGroup, Topic, TopicCommittee } from '../../../models'; +import { ConsiderationPhase, DeliberationPhase, FundingRound, FundingVotingPhase, SMEGroup, Topic, TopicCommittee } from '../../../models'; import { InteractionProperties } from '../../../core/Interaction'; import { PaginationComponent } from '../../../components/PaginationComponent'; -import { FundingRoundPhase } from '../../../types'; +import { FundingRoundPhase, FundingRoundStatus } from '../../../types'; import { TopicLogic } from './ManageTopicLogicScreen'; import logger from '../../../logging'; -import { EndUserError } from '../../../Errors'; +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 { FundingRoundPaginator } from '../../../components/FundingRoundPaginator'; + export class ManageFundingRoundsScreen extends Screen { @@ -18,7 +22,7 @@ export class ManageFundingRoundsScreen extends Screen { protected permissions: Permission[] = []; // TODO: Implement proper admin permissions - public readonly createFundingRoundAction: CreateFundingRoundAction; + public readonly createFundingRoundAction: CreateOrEditFundingRoundAction; public readonly modifyFundingRoundAction: ModifyFundingRoundAction; public readonly setFundingRoundCommitteeAction: SetFundingRoundCommitteeAction; public readonly removeFundingRoundCommitteeAction: RemoveFundingRoundCommitteeAction; @@ -28,10 +32,19 @@ export class ManageFundingRoundsScreen extends Screen { public readonly editFundingRoundInformationAction: EditFundingRoundInformationAction; public readonly editFundingRoundPhasesAction: EditFundingRoundPhasesAction; public readonly editFundingRoundTopicAction: EditFundingRoundTopicAction; + public readonly selectTopicAction: SelectTopicAction; + public readonly coreInformationAction: CoreInformationAction; + public readonly setPhaseAction: SetPhaseAction; + public readonly selectForumChannelAction: SelectForumChannelAction; + + public readonly crudFRPaginatorAction: FundingRoundPaginator; + public readonly committeeFRPaginator: FundingRoundPaginator; + public readonly committeeDeleteFRPaginator: FundingRoundPaginator; + public readonly approveRejectFRPaginator: FundingRoundPaginator; constructor(dashboard: Dashboard, screenId: string) { super(dashboard, screenId); - this.createFundingRoundAction = new CreateFundingRoundAction(this, CreateFundingRoundAction.ID); + this.createFundingRoundAction = new CreateOrEditFundingRoundAction(this, CreateOrEditFundingRoundAction.ID); this.modifyFundingRoundAction = new ModifyFundingRoundAction(this, ModifyFundingRoundAction.ID); this.setFundingRoundCommitteeAction = new SetFundingRoundCommitteeAction(this, SetFundingRoundCommitteeAction.ID); this.removeFundingRoundCommitteeAction = new RemoveFundingRoundCommitteeAction(this, RemoveFundingRoundCommitteeAction.ID); @@ -41,6 +54,16 @@ export class ManageFundingRoundsScreen extends Screen { this.editFundingRoundInformationAction = new EditFundingRoundInformationAction(this, EditFundingRoundInformationAction.ID); this.editFundingRoundPhasesAction = new EditFundingRoundPhasesAction(this, EditFundingRoundPhasesAction.ID); this.editFundingRoundTopicAction = new EditFundingRoundTopicAction(this, EditFundingRoundTopicAction.ID); + + this.selectTopicAction = new SelectTopicAction(this, SelectTopicAction.ID); + this.coreInformationAction = new CoreInformationAction(this, CoreInformationAction.ID); + this.setPhaseAction = new SetPhaseAction(this, SetPhaseAction.ID); + this.selectForumChannelAction = new SelectForumChannelAction(this, SelectForumChannelAction.ID); + + this.crudFRPaginatorAction = new FundingRoundPaginator(this, this.createFundingRoundAction, CreateOrEditFundingRoundAction.OPERATIONS.SHOW_PROGRESS, [], 'Select a Funding Round To Edit'); + this.committeeFRPaginator = new FundingRoundPaginator(this, this.setFundingRoundCommitteeAction , SetFundingRoundCommitteeAction.OPERATIONS.SELECT_FUNDING_ROUND, [], "Select a Funding Round To Manage Committee") + this.committeeDeleteFRPaginator = new FundingRoundPaginator(this, this.removeFundingRoundCommitteeAction, RemoveFundingRoundCommitteeAction.OPERATIONS.SELECT_FUNDING_ROUND, [], "Select a Funding Round To Remove Committee") + this.approveRejectFRPaginator = new FundingRoundPaginator(this, this.approveFundingRoundAction, ApproveFundingRoundAction.OPERATIONS.SELECT_FUNDING_ROUND, [], "Select a Funding Round To Approve/Reject") } protected allSubScreens(): Screen[] { @@ -59,6 +82,12 @@ export class ManageFundingRoundsScreen extends Screen { this.editFundingRoundInformationAction, this.editFundingRoundPhasesAction, this.editFundingRoundTopicAction, + this.selectTopicAction, + this.coreInformationAction, + this.setPhaseAction, + this.selectForumChannelAction, + + this.crudFRPaginatorAction ]; } @@ -113,294 +142,128 @@ export class ManageFundingRoundsScreen extends Screen { } } -export class CreateFundingRoundAction extends Action { +export class CreateOrEditFundingRoundAction extends Action { public static readonly ID = 'createFundingRound'; - private static readonly OPERATIONS = { - SHOW_BASIC_INFO_FORM: 'showBasicInfoForm', - SUBMIT_BASIC_INFO: 'submitBasicInfo', - SHOW_PHASE_FORM: 'showPhaseForm', - SUBMIT_PHASE: 'submitPhase', + public static readonly OPERATIONS = { + START: 'start', + SHOW_PROGRESS: 'showProgress', }; - private static readonly INPUT_IDS = { - NAME: 'name', - DESCRIPTION: 'description', - TOPIC_NAME: 'topicName', - BUDGET: 'budget', - STAKING_LEDGER_EPOCH_NUM: 'stLdEpNum', - START_DATE: 'startDate', - END_DATE: 'endDate', - ROUND_START_DATE: 'rSD', - ROUND_END_DATE: 'rED', - }; + public static BOOLEANS = { + TRUE_VALUE: 'T', + ARGUMENTS: { + ONLY_SHOW_PHASES: 'bOSP', + FORCE_REPLY: 'bFR', + } + } - private static readonly PHASE_NAMES = { - CONSIDERATION: 'Consideration', - DELIBERATION: 'Deliberation', - VOTING: 'Voting', - ROUND: 'Round', - }; - protected async handleOperation(interaction: TrackedInteraction, operationId: string): Promise { + + public async handleOperation(interaction: TrackedInteraction, operationId: string): Promise { switch (operationId) { - case CreateFundingRoundAction.OPERATIONS.SHOW_BASIC_INFO_FORM: - await this.handleShowBasicInfoForm(interaction); - break; - case CreateFundingRoundAction.OPERATIONS.SUBMIT_BASIC_INFO: - await this.handleSubmitBasicInfo(interaction); - break; - case CreateFundingRoundAction.OPERATIONS.SHOW_PHASE_FORM: - await this.handleShowPhaseForm(interaction); + case CreateOrEditFundingRoundAction.OPERATIONS.START: + await this.handleStart(interaction); break; - case CreateFundingRoundAction.OPERATIONS.SUBMIT_PHASE: - await this.handleSubmitPhase(interaction); + case CreateOrEditFundingRoundAction.OPERATIONS.SHOW_PROGRESS: + await this.handleShowProgress(interaction); break; default: await this.handleInvalidOperation(interaction, operationId); } } - private async handleShowBasicInfoForm(interaction: TrackedInteraction): Promise { - const modalInteraction = InteractionProperties.toShowModalOrUndefined(interaction.interaction); - if (!modalInteraction) { - throw new EndUserError('This interaction does not support modals.'); - } - - const modal = new ModalBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, CreateFundingRoundAction.OPERATIONS.SUBMIT_BASIC_INFO)) - .setTitle('Create New Funding Round'); - - const nameInput = new TextInputBuilder() - .setCustomId(CreateFundingRoundAction.INPUT_IDS.NAME) - .setLabel('Funding Round Name') - .setStyle(TextInputStyle.Short) - .setRequired(true); - - const descriptionInput = new TextInputBuilder() - .setCustomId(CreateFundingRoundAction.INPUT_IDS.DESCRIPTION) - .setLabel('Description') - .setStyle(TextInputStyle.Paragraph) - .setRequired(true); - - const topicNameInput = new TextInputBuilder() - .setCustomId(CreateFundingRoundAction.INPUT_IDS.TOPIC_NAME) - .setLabel('Topic Name') - .setStyle(TextInputStyle.Short) - .setRequired(true); - - const budgetInput = new TextInputBuilder() - .setCustomId(CreateFundingRoundAction.INPUT_IDS.BUDGET) - .setLabel('Budget') - .setStyle(TextInputStyle.Short) - .setRequired(true); - - const stakingLedgerNumInput = new TextInputBuilder() - .setCustomId(CreateFundingRoundAction.INPUT_IDS.STAKING_LEDGER_EPOCH_NUM) - .setLabel('Staking Ledger Epoch Number (For Voting)') - .setStyle(TextInputStyle.Short) - .setRequired(true); - - modal.addComponents( - new ActionRowBuilder().addComponents(nameInput), - new ActionRowBuilder().addComponents(descriptionInput), - new ActionRowBuilder().addComponents(topicNameInput), - new ActionRowBuilder().addComponents(budgetInput), - new ActionRowBuilder().addComponents(stakingLedgerNumInput) - ); - - await modalInteraction.showModal(modal); + private async handleStart(interaction: TrackedInteraction): Promise { + const isForceReply: boolean = ArgumentOracle.isArgumentEquals(interaction, CreateOrEditFundingRoundAction.BOOLEANS.ARGUMENTS.FORCE_REPLY, CreateOrEditFundingRoundAction.BOOLEANS.TRUE_VALUE); + await (this.screen as ManageFundingRoundsScreen).selectTopicAction.handlePagination(interaction, isForceReply); } - private async handleSubmitBasicInfo(interaction: TrackedInteraction): Promise { - const modalInteraction = InteractionProperties.toModalSubmitInteractionOrUndefined(interaction.interaction); - if (!modalInteraction) { - throw new EndUserError('Invalid interaction type: submit interaction required'); - } - - const name = modalInteraction.fields.getTextInputValue(CreateFundingRoundAction.INPUT_IDS.NAME); - const description = modalInteraction.fields.getTextInputValue(CreateFundingRoundAction.INPUT_IDS.DESCRIPTION); - const topicName = modalInteraction.fields.getTextInputValue(CreateFundingRoundAction.INPUT_IDS.TOPIC_NAME); - const budget = parseFloat(modalInteraction.fields.getTextInputValue(CreateFundingRoundAction.INPUT_IDS.BUDGET)); - const stakingLedgerEpochNum = modalInteraction.fields.getTextInputValue(CreateFundingRoundAction.INPUT_IDS.STAKING_LEDGER_EPOCH_NUM); - - if (isNaN(budget)) { - throw new EndUserError('Invalid budget value. Please enter a valid number.'); - } - - if (isNaN(parseInt(stakingLedgerEpochNum))) { - throw new EndUserError('Invalid staking ledger epoch number. Please enter a valid number.'); - } - - const ledgerNum: number = parseInt(stakingLedgerEpochNum); - - - - try { - const fundingRound = await FundingRoundLogic.createFundingRound(name, description, topicName, budget, ledgerNum); - const embed = new EmbedBuilder() - .setColor('#0099ff') - .setTitle('Funding Round Created') - .setDescription('Please set the dates for each phase:') - .addFields( - { name: 'Name', value: fundingRound.name }, - { name: 'Description', value: fundingRound.description }, - { name: 'Topic', value: topicName }, - { name: 'Budget', value: fundingRound.budget.toString() }, - { name: 'Staking Ledger Epoch (For Voting)', value: fundingRound.stakingLedgerEpoch.toString() } - ); - - const considerationButton = new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, CreateFundingRoundAction.OPERATIONS.SHOW_PHASE_FORM, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, fundingRound.id.toString(), ArgumentOracle.COMMON_ARGS.PHASE, CreateFundingRoundAction.PHASE_NAMES.CONSIDERATION)) - .setLabel('Set Consideration Phase') - .setStyle(ButtonStyle.Primary); - - const deliberationButton = new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, CreateFundingRoundAction.OPERATIONS.SHOW_PHASE_FORM, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, fundingRound.id.toString(), ArgumentOracle.COMMON_ARGS.PHASE, CreateFundingRoundAction.PHASE_NAMES.DELIBERATION)) - .setLabel('Set Deliberation Phase') - .setStyle(ButtonStyle.Primary); - - const votingButton = new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, CreateFundingRoundAction.OPERATIONS.SHOW_PHASE_FORM, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, fundingRound.id.toString(), ArgumentOracle.COMMON_ARGS.PHASE, CreateFundingRoundAction.PHASE_NAMES.VOTING)) - .setLabel('Set Voting Phase') - .setStyle(ButtonStyle.Primary); - - const row = new ActionRowBuilder() - .addComponents(considerationButton, deliberationButton, votingButton); + private formatStringForPhase(phase: ConsiderationPhase | DeliberationPhase | FundingVotingPhase): string { - await interaction.update({ embeds: [embed], components: [row] }); - } catch (error) { - throw new EndUserError(`Error creating funding round: ${(error as Error).message}`); - } + return `✅\nStart: ${phase.startAt.toUTCString()}\nEnd: ${phase.endAt.toUTCString()}\nEpoch: ${phase.stakingLedgerEpoch}`; } - private async handleShowPhaseForm(interaction: TrackedInteraction): Promise { - const fundingRoundId = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID); - const phase = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.PHASE); - - const modalInteraction = InteractionProperties.toShowModalOrError(interaction.interaction); - - const existingPhase = await FundingRoundLogic.getFundingRoundPhase(parseInt(fundingRoundId), phase); - - const modal = new ModalBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, ModifyFundingRoundAction.OPERATIONS.SUBMIT_PHASE, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, fundingRoundId, ArgumentOracle.COMMON_ARGS.PHASE, phase)) - .setTitle(`Modify ${phase} Phase Dates`); - - const startDateInput = new TextInputBuilder() - .setCustomId(ModifyFundingRoundAction.INPUT_IDS.START_DATE) - .setLabel('Start Date (YYYY-MM-DD HH:MM)') - .setStyle(TextInputStyle.Short) - .setValue(existingPhase ? existingPhase.startAt.toISOString().slice(0, 16).replace('T', ' ') : '') - .setRequired(true); - - const endDateInput = new TextInputBuilder() - .setCustomId(ModifyFundingRoundAction.INPUT_IDS.END_DATE) - .setLabel('End Date (YYYY-MM-DD HH:MM)') - .setStyle(TextInputStyle.Short) - .setValue(existingPhase ? existingPhase.endAt.toISOString().slice(0, 16).replace('T', ' ') : '') - .setRequired(true); - - modal.addComponents( - new ActionRowBuilder().addComponents(startDateInput), - new ActionRowBuilder().addComponents(endDateInput) - ); - - await modalInteraction.showModal(modal); + private formatStringForRound(fundingRound: FundingRound): string { + return `✅\nStart: ${fundingRound.startAt.toUTCString()}\nEnd: ${fundingRound.endAt.toUTCString()}\nVoting Open Until: ${fundingRound.votingOpenUntil.toUTCString()}\nEpoch: ${fundingRound.stakingLedgerEpoch}`; } - private async handleSubmitPhase(interaction: TrackedInteraction): Promise { - const modalInteraction = InteractionProperties.toModalSubmitInteractionOrUndefined(interaction.interaction); - if (!modalInteraction) { - throw new EndUserError('Invalid interaction type.'); - } - - const fundingRoundId: string = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID); - const phase: string = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.PHASE); - - const startDate = new Date(modalInteraction.fields.getTextInputValue(ModifyFundingRoundAction.INPUT_IDS.START_DATE)); - const endDate = new Date(modalInteraction.fields.getTextInputValue(ModifyFundingRoundAction.INPUT_IDS.END_DATE)); - const stakingLedgerEpoch = parseInt(modalInteraction.fields.getTextInputValue(ModifyFundingRoundAction.INPUT_IDS.STAKING_LEDGER_EPOCH)); - - if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { - throw new EndUserError('Invalid date format. Please use YYYY-MM-DD HH:MM.'); - } - - if (startDate >= endDate) { - throw new EndUserError('Start date must be before end date.'); - } - - try { - const fundingRound = await FundingRoundLogic.getFundingRoundById(parseInt(fundingRoundId)); - if (!fundingRound) { - throw new EndUserError('Funding round not found.'); - } + private async handleShowProgress(interaction: TrackedInteraction): Promise { + const fundingRoundId = ArgumentOracle.getNamedArgument(interaction, 'fundingRoundId', 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); - const existingPhases = await FundingRoundLogic.getFundingRoundPhases(parseInt(fundingRoundId)); + const considerationPhase = await fundingRound.getConsiderationPhase(); + const deliberationPhase = await fundingRound.getDeliberationPhase(); + const votingPhase = await fundingRound.getFundingVotingPhase(); - // Check phase order - if (phase === ModifyFundingRoundAction.PHASE_NAMES.DELIBERATION) { - const considerationPhase = existingPhases.find(p => p.phase === ModifyFundingRoundAction.PHASE_NAMES.CONSIDERATION); - if (considerationPhase && startDate < considerationPhase.endDate) { - throw new EndUserError('Deliberation phase must start after Consideration phase ends.'); - } - } else if (phase === ModifyFundingRoundAction.PHASE_NAMES.VOTING) { - const deliberationPhase = existingPhases.find(p => p.phase === ModifyFundingRoundAction.PHASE_NAMES.DELIBERATION); - if (deliberationPhase && startDate < deliberationPhase.endDate) { - throw new EndUserError('Voting phase must start after Deliberation phase ends.'); - } - } + const progress: string = await fundingRound.isReady() ? '✅ READY\n\nThe Funding Round information is complete & valid.' : '⚠️ NOT READY\nSome Funding Round information is missing (marked with ❌). Please complete filling all of the information below'; + const embed = new EmbedBuilder() + .setColor('#0099ff') + .setTitle('Create or Edit Funding Round') + .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: '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 }, + { name: 'Voting Phase', value: votingPhase ? this.formatStringForPhase(votingPhase) : '❌', inline: true }, + { name: 'Forum Channel', value: fundingRound.forumChannelId ? `✅ ${fundingRound.forumChannelId}` : '❌', inline: true }, + ); - const lowerPase: string = phase.toLowerCase(); - if ((lowerPase !== 'consideration') && (lowerPase !== 'deliberation') && (lowerPase !== 'voting')) { - throw new EndUserError('Invalid phase.'); - } - await FundingRoundLogic.setFundingRoundPhase(parseInt(fundingRoundId), lowerPase, stakingLedgerEpoch, startDate, endDate); + const manageDatesCustomId: string = CustomIDOracle.addArgumentsToAction(this, CreateOrEditFundingRoundAction.OPERATIONS.SHOW_PROGRESS, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, fundingRoundId, CreateOrEditFundingRoundAction.BOOLEANS.ARGUMENTS.ONLY_SHOW_PHASES, CreateOrEditFundingRoundAction.BOOLEANS.TRUE_VALUE); + logger.info(manageDatesCustomId) + const initScreenButtons = [ + new ButtonBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction((this.screen as ManageFundingRoundsScreen).selectTopicAction, SelectTopicAction.OPERATIONS.SHOW_TOPICS, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, fundingRoundId)) + .setLabel(fundingRound.topicId ? 'Edit Topic' : 'Select Topic') + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction((this.screen as ManageFundingRoundsScreen).selectForumChannelAction, SelectForumChannelAction.OPERATIONS.SHOW_CHANNELS, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, fundingRoundId)) + .setLabel(fundingRound.forumChannelId ? 'Edit Forum Channel' : 'Set Forum Channel') + .setStyle(ButtonStyle.Primary), - const updatedPhases = await FundingRoundLogic.getFundingRoundPhases(parseInt(fundingRoundId)); - const allPhasesSet = [ - ModifyFundingRoundAction.PHASE_NAMES.CONSIDERATION, - ModifyFundingRoundAction.PHASE_NAMES.DELIBERATION, - ModifyFundingRoundAction.PHASE_NAMES.VOTING - ].every(phaseName => updatedPhases.some(p => p.phase === phaseName)); + new ButtonBuilder() + .setCustomId(manageDatesCustomId) + .setLabel('Manage Phases & Dates') + .setStyle(ButtonStyle.Primary), - const embed = new EmbedBuilder() - .setColor('#00FF00') - .setTitle('Funding Round Phase Updated') - .setDescription(`The ${lowerPase} phase has been updated successfully.`) - .addFields( - { name: 'Name', value: fundingRound.name }, - { name: 'Description', value: fundingRound.description }, - { name: 'Budget', value: fundingRound.budget.toString() }, - { name: 'Staking Ledger Epoch Number (For Voting)', value: fundingRound.stakingLedgerEpoch.toString() }, - { name: 'Start Date', value: fundingRound.startAt ? fundingRound.startAt.toISOString() : '❌ Not set' }, - { name: 'End Date', value: fundingRound.endAt ? fundingRound.endAt.toISOString() : '❌ Not set' }, - ...updatedPhases.map(p => ({ name: `${p.phase} Phase`, value: `Start: ${p.startDate.toISOString()}\nEnd: ${p.endDate.toISOString()}`, inline: true })) - ); + new ButtonBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction((this.screen as ManageFundingRoundsScreen).coreInformationAction, CoreInformationAction.OPERATIONS.SHOW_FORM, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, fundingRoundId)) + .setLabel('Edit Core Information') + .setStyle(ButtonStyle.Primary), - if (allPhasesSet) { - embed.addFields({ name: 'Status', value: 'All phases have been set for the funding round.' }); - } else { - const remainingPhases = [ - ModifyFundingRoundAction.PHASE_NAMES.CONSIDERATION, - ModifyFundingRoundAction.PHASE_NAMES.DELIBERATION, - ModifyFundingRoundAction.PHASE_NAMES.VOTING - ].filter(phaseName => !updatedPhases.some(p => p.phase === phaseName)); + ]; - embed.addFields({ name: 'Remaining Phases', value: remainingPhases.join(', ') }); - } + const phaseDatesOnlyButtons = [ + new ButtonBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction((this.screen as ManageFundingRoundsScreen).setPhaseAction, SetPhaseAction.OPERATIONS.SHOW_FORM, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, fundingRoundId, SetPhaseAction.ARGUMENTS.PHASE, FundingRoundMI.PHASES.ROUND)) + .setLabel(fundingRound.startAt ? 'Edit Funding Round Dates' : 'Set Funding Round Dates') + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction((this.screen as ManageFundingRoundsScreen).setPhaseAction, SetPhaseAction.OPERATIONS.SHOW_FORM, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, fundingRoundId, SetPhaseAction.ARGUMENTS.PHASE, FundingRoundMI.PHASES.CONSIDERATION)) + .setLabel(considerationPhase ? 'Edit Consideration Phase' : 'Set Consideration Phase') + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction((this.screen as ManageFundingRoundsScreen).setPhaseAction, SetPhaseAction.OPERATIONS.SHOW_FORM, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, fundingRoundId, SetPhaseAction.ARGUMENTS.PHASE, FundingRoundMI.PHASES.DELIBERATION)) + .setLabel(deliberationPhase ? 'Edit Deliberation Phase' : 'Set Deliberation Phase') + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction((this.screen as ManageFundingRoundsScreen).setPhaseAction, SetPhaseAction.OPERATIONS.SHOW_FORM, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, fundingRoundId, SetPhaseAction.ARGUMENTS.PHASE, FundingRoundMI.PHASES.VOTING)) + .setLabel(votingPhase ? 'Edit Voting Phase' : 'Set Voting Phase') + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction(this, CreateOrEditFundingRoundAction.OPERATIONS.SHOW_PROGRESS, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, fundingRoundId)) + .setLabel('Go Back') + .setStyle(ButtonStyle.Secondary) + ] - const backButton = new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction((this.screen as ManageFundingRoundsScreen).modifyFundingRoundAction, ModifyFundingRoundAction.OPERATIONS.SELECT_FUNDING_ROUND, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, fundingRoundId)) - .setLabel('Back to Funding Round') - .setStyle(ButtonStyle.Secondary); + const buttons = onlyShowPhases ? phaseDatesOnlyButtons : initScreenButtons; - const row = new ActionRowBuilder().addComponents(backButton); + const rows = buttons.map(button => new ActionRowBuilder().addComponents(button)); - await interaction.update({ embeds: [embed], components: [row] }); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred'; - await interaction.respond({ content: `Error updating phase: ${errorMessage}`, ephemeral: true }); - } + await interaction.update({ embeds: [embed], components: rows, ephemeral: true }); } public allSubActions(): Action[] { @@ -409,528 +272,590 @@ export class CreateFundingRoundAction extends Action { getComponent(): ButtonBuilder { return new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, CreateFundingRoundAction.OPERATIONS.SHOW_BASIC_INFO_FORM)) + .setCustomId(CustomIDOracle.addArgumentsToAction(this, CreateOrEditFundingRoundAction.OPERATIONS.START)) .setLabel('Create Funding Round') .setStyle(ButtonStyle.Success); } } -class FundingRoundPaginationAction extends PaginationComponent { - public static readonly ID = 'fundingRoundPagination'; +export class SelectTopicAction extends PaginationComponent { + public static readonly ID = 'selectTopic'; + + public static readonly OPERATIONS = { + SHOW_TOPICS: 'showTopics', + SELECT_TOPIC: 'selectTopic', + UPDATE_TOPIC: 'upTp', + }; + + public static readonly INPUT_IDS = { + TOPIC: 'topic', + }; protected async getTotalPages(interaction: TrackedInteraction): Promise { - const fundingRounds = await FundingRoundLogic.getPresentAndFutureFundingRounds(); - return Math.ceil(fundingRounds.length / 25); + const topics = await TopicLogic.getAllTopics(); + return Math.ceil(topics.length / 25); } - protected async getItemsForPage(interaction: TrackedInteraction, page: number): Promise { - const fundingRounds = await FundingRoundLogic.getPresentAndFutureFundingRounds(); - return fundingRounds.slice(page * 25, (page + 1) * 25); + protected async getItemsForPage(interaction: TrackedInteraction, page: number): Promise { + const topics = await TopicLogic.getAllTopics(); + return topics.slice(page * 25, (page + 1) * 25); } - public async handlePagination(interaction: TrackedInteraction): Promise { + public async handlePagination(interaction: TrackedInteraction, isForceReply:boolean=false): Promise { const currentPage = this.getCurrentPage(interaction); const totalPages = await this.getTotalPages(interaction); - const fundingRounds = await this.getItemsForPage(interaction, currentPage); + const topics = await this.getItemsForPage(interaction, currentPage); + + + let fundingRoundId: string | undefined; + try { + fundingRoundId = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID); + } catch(error) { + if (error instanceof NotFoundEndUserError) { + logger.debug('No funding round ID found in arguments, it means a new funding round is being created'); + } + } + + const description: string = fundingRoundId ? 'Select a topic to update the Funding Round topic.' : 'Select a topic under which the new Funding Round will be created.'; + const customId: string = fundingRoundId ? CustomIDOracle.addArgumentsToAction(this, SelectTopicAction.OPERATIONS.UPDATE_TOPIC, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, fundingRoundId) : CustomIDOracle.addArgumentsToAction(this, SelectTopicAction.OPERATIONS.SELECT_TOPIC); + + const infoEmbed: EmbedBuilder = new EmbedBuilder() + .setTitle('Select a Topic') + .setDescription(description); const selectMenu = new StringSelectMenuBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction((this.screen as ManageFundingRoundsScreen).modifyFundingRoundAction, ModifyFundingRoundAction.OPERATIONS.SELECT_FUNDING_ROUND)) - .setPlaceholder('Select a Funding Round') - .addOptions(fundingRounds.map((fr: FundingRound) => ({ - label: fr.name, - value: fr.id.toString(), - description: `Budget: ${fr.budget}, Status: ${fr.status}` + .setCustomId(customId) + .setPlaceholder('Select a Topic') + .addOptions(topics.map(topic => ({ + label: topic.name, + value: topic.id.toString(), + description: topic.description.substring(0, 100) }))); - const components: ActionRowBuilder[] = [ + const components: ActionRowBuilder[] = [ new ActionRowBuilder().addComponents(selectMenu) ]; + const args: string[] = fundingRoundId ? [ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, fundingRoundId] : []; + if (totalPages > 1) { - const paginationRow = this.getPaginationRow(interaction, currentPage, totalPages); + const paginationRow = this.getPaginationRow(interaction, currentPage, totalPages, ...args); components.push(paginationRow); } - await interaction.update({ components }); + const data = { components, embeds: [infoEmbed], ephemeral: true }; + + if (isForceReply) { + await interaction.respond(data); + } else { + await interaction.update(data); + } + } + + public async handleOperation(interaction: TrackedInteraction, operationId: string): Promise { + switch (operationId) { + case SelectTopicAction.OPERATIONS.SHOW_TOPICS: + case PaginationComponent.PAGINATION_ARG: + await this.handlePagination(interaction); + break; + case SelectTopicAction.OPERATIONS.SELECT_TOPIC: + await this.handleSelectTopic(interaction); + break; + case SelectTopicAction.OPERATIONS.UPDATE_TOPIC: + await this.handleUpdateTopic(interaction); + break; + default: + await this.handleInvalidOperation(interaction, operationId); + } + } + + private async handleUpdateTopic(interaction: TrackedInteraction): Promise { + const fundingRoundId = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID); + const topicId = ArgumentOracle.getNamedArgument(interaction, SelectTopicAction.INPUT_IDS.TOPIC, 0); + + await FundingRoundLogic.setTopic(parseInt(fundingRoundId), parseInt(topicId)); + + interaction.Context.set(ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, fundingRoundId); + await (this.screen as ManageFundingRoundsScreen).createFundingRoundAction.handleOperation(interaction, CreateOrEditFundingRoundAction.OPERATIONS.SHOW_PROGRESS); + await DiscordStatus.Success.success(interaction, 'Topic updated successfully'); + } + + private async handleSelectTopic(interaction: TrackedInteraction): Promise { + const interactionWithValues = InteractionProperties.toInteractionWithValuesOrError(interaction.interaction); + const topicId = interactionWithValues.values[0]; + + interaction.Context.set(CoreInformationAction.INPUT_IDS.TOPIC, topicId); + + await (this.screen as ManageFundingRoundsScreen).coreInformationAction.handleOperation(interaction, CoreInformationAction.OPERATIONS.SHOW_FORM); } public allSubActions(): Action[] { return []; } - getComponent(...args: any[]): StringSelectMenuBuilder { - return new StringSelectMenuBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, 'paginate')) - .setPlaceholder('Select a Funding Round'); + getComponent(): ButtonBuilder { + return new ButtonBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction(this, SelectTopicAction.OPERATIONS.SHOW_TOPICS)) + .setLabel('Select Topic') + .setStyle(ButtonStyle.Primary); } } -export class ModifyFundingRoundAction extends Action { - public static readonly ID = 'modifyFundingRound'; +export class CoreInformationAction extends Action { + public static readonly ID = 'coreInformation'; public static readonly OPERATIONS = { - SHOW_FUNDING_ROUNDS: 'showFundingRounds', - SELECT_FUNDING_ROUND: 'selectFundingRound', - SHOW_BASIC_INFO_FORM: 'showBasicInfoForm', - SUBMIT_BASIC_INFO: 'submitBasicInfo', - SHOW_PHASE_FORM: 'showPhaseForm', - SUBMIT_PHASE: 'submitPhase', - EDIT_FUNDING_ROUND: 'editFundingRound', + SHOW_FORM: 'showForm', + SUBMIT_FORM: 'submitForm', }; public static readonly INPUT_IDS = { NAME: 'name', DESCRIPTION: 'description', - TOPIC_NAME: 'topicName', BUDGET: 'budget', - STAKING_LEDGER_EPOCH: 'stLdEpNum', - START_DATE: 'startDate', - END_DATE: 'endDate', - ROUND_START_DATE: 'rSD', - ROUND_END_DATE: 'rED', - }; - - public static readonly PHASE_NAMES = { - CONSIDERATION: 'Consideration', - DELIBERATION: 'Deliberation', - VOTING: 'Voting', + STAKING_LEDGER_EPOCH: 'stakingLedgerEpoch', + TOPIC: 'topic', }; - private fundingRoundPaginationAction: FundingRoundPaginationAction; - - constructor(screen: Screen, actionId: string) { - super(screen, actionId); - this.fundingRoundPaginationAction = new FundingRoundPaginationAction(screen, FundingRoundPaginationAction.ID); - } - - protected async handleOperation(interaction: TrackedInteraction, operationId: string): Promise { + public async handleOperation(interaction: TrackedInteraction, operationId: string): Promise { switch (operationId) { - case ModifyFundingRoundAction.OPERATIONS.SHOW_FUNDING_ROUNDS: - await this.handleShowFundingRounds(interaction); - break; - case ModifyFundingRoundAction.OPERATIONS.SELECT_FUNDING_ROUND: - await this.handleSelectFundingRound(interaction); - break; - case ModifyFundingRoundAction.OPERATIONS.SHOW_BASIC_INFO_FORM: - await this.handleShowBasicInfoForm(interaction); + case CoreInformationAction.OPERATIONS.SHOW_FORM: + await this.handleShowForm(interaction); break; - case ModifyFundingRoundAction.OPERATIONS.SUBMIT_BASIC_INFO: - await this.handleSubmitBasicInfo(interaction); - break; - case ModifyFundingRoundAction.OPERATIONS.SHOW_PHASE_FORM: - await this.handleShowPhaseForm(interaction); - break; - case ModifyFundingRoundAction.OPERATIONS.SUBMIT_PHASE: - await this.handleSubmitPhase(interaction); - break; - case ModifyFundingRoundAction.OPERATIONS.EDIT_FUNDING_ROUND: - await this.handleEditFundingRound(interaction); + case CoreInformationAction.OPERATIONS.SUBMIT_FORM: + await this.handleSubmitForm(interaction); break; default: await this.handleInvalidOperation(interaction, operationId); } } - private async handleShowFundingRounds(interaction: TrackedInteraction): Promise { - await this.fundingRoundPaginationAction.handlePagination(interaction); - } - - private async handleSelectFundingRound(interaction: TrackedInteraction): Promise { - const interactionWithValues = InteractionProperties.toInteractionWithValuesOrUndefined(interaction.interaction); + private async handleShowForm(interaction: TrackedInteraction): Promise { + let topicId: string | undefined; + let fundingRoundId: string | undefined; + try { + topicId = ArgumentOracle.getNamedArgument(interaction, CoreInformationAction.INPUT_IDS.TOPIC); + } catch (error) { + if (error instanceof NotFoundEndUserError) { + try { + fundingRoundId = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID); + } catch (error) { + if (error instanceof NotFoundEndUserError) { + throw new EndUserError('Neither topic, nor funding round ID found in arguments.'); + } + } + } + } - let fundingRoundId: number; - if (!interactionWithValues) { - const fundingRoundIdArg: string = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, 0); - - fundingRoundId = parseInt(fundingRoundIdArg); + let nameValue = ''; + let descriptionValue = ''; + let budgetValue = ''; + let stakingLedgerEpochValue = ''; + let parsedTopicId: string; + if (fundingRoundId) { + const fundingRound: FundingRound = await FundingRoundLogic.getFundingRoundByIdOrError(parseInt(fundingRoundId)); + nameValue = fundingRound.name; + descriptionValue = fundingRound.description; + budgetValue = fundingRound.budget.toString(); + stakingLedgerEpochValue = fundingRound.stakingLedgerEpoch.toString(); + parsedTopicId = fundingRound.topicId.toString(); } else { - fundingRoundId = parseInt(interactionWithValues.values[0]); + parsedTopicId = topicId as string; } - const fundingRound = await FundingRoundLogic.getFundingRoundById(fundingRoundId); - if (!fundingRound) { - throw new EndUserError('Funding round not found.'); - } + const modal = new ModalBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction(this, CoreInformationAction.OPERATIONS.SUBMIT_FORM, CoreInformationAction.INPUT_IDS.TOPIC, parsedTopicId)) + .setTitle('Funding Round Core Information'); + + const nameInput = new TextInputBuilder() + .setCustomId(CoreInformationAction.INPUT_IDS.NAME) + .setLabel('Funding Round Name') + .setStyle(TextInputStyle.Short) + .setValue(nameValue) + .setRequired(true); + const descriptionInput = new TextInputBuilder() + .setCustomId(CoreInformationAction.INPUT_IDS.DESCRIPTION) + .setLabel('Description') + .setStyle(TextInputStyle.Paragraph) + .setValue(descriptionValue) + .setRequired(true); - const updatedPhases = await FundingRoundLogic.getFundingRoundPhases(fundingRoundId); - let allPhasesSet: boolean = ['consideration', 'deliberation', 'voting'] - .every(phaseName => updatedPhases.some((p: FundingRoundPhase) => p.phase === phaseName)); + const budgetInput = new TextInputBuilder() + .setCustomId(CoreInformationAction.INPUT_IDS.BUDGET) + .setLabel('Budget') + .setStyle(TextInputStyle.Short) + .setValue(budgetValue) + .setRequired(true); - allPhasesSet = allPhasesSet && (fundingRound.startAt !== null) && (fundingRound.endAt !== null); + const stakingLedgerEpochInput = new TextInputBuilder() + .setCustomId(CoreInformationAction.INPUT_IDS.STAKING_LEDGER_EPOCH) + .setLabel('Staking Ledger Epoch Number') + .setStyle(TextInputStyle.Short) + .setValue(stakingLedgerEpochValue) + .setRequired(true); - const topic: Topic = await fundingRound.getTopic(); + modal.addComponents( + new ActionRowBuilder().addComponents(nameInput), + new ActionRowBuilder().addComponents(descriptionInput), + new ActionRowBuilder().addComponents(budgetInput), + new ActionRowBuilder().addComponents(stakingLedgerEpochInput) + ); - const embed = new EmbedBuilder() - .setColor('#0099ff') - .setTitle(`Modify Funding Round: ${fundingRound.name} (${fundingRound.id})`) - .setDescription('Select an action to modify the funding round:') - .addFields( - { name: 'Description', value: fundingRound.description }, - { name: 'Topic', value: topic.name }, - { name: 'Budget', value: fundingRound.budget.toString() }, - { name: 'Status', value: fundingRound.status }, - { name: 'Staking Ledger Epoch Number (For Voting)', value: fundingRound.stakingLedgerEpoch.toString() }, - { name: 'Start Date', value: fundingRound.startAt ? fundingRound.startAt.toISOString() : '❌ Not set' }, - { name: 'End Date', value: fundingRound.endAt ? fundingRound.endAt.toISOString() : '❌ Not set' }, - ...updatedPhases.map((p: FundingRoundPhase) => ({ name: `${p.phase.charAt(0).toUpperCase() + p.phase.slice(1)} Phase`, value: `Start: ${this.formatDate(p.startDate)}\nEnd: ${this.formatDate(p.endDate)}`, inline: true })) - ); + await interaction.showModal(modal); + } - if (allPhasesSet) { - embed.addFields({ name: 'Status', value: 'All phases have been set for the funding round.' }); - } else { - let remainingPhases = ['consideration', 'deliberation', 'voting'] - .filter(phaseName => !updatedPhases.some((p: FundingRoundPhase) => p.phase === phaseName)); + private async handleSubmitForm(interaction: TrackedInteraction): Promise { + const modalInteraction = InteractionProperties.toModalSubmitInteractionOrError(interaction.interaction); - if (fundingRound.startAt === null || fundingRound.endAt === null) { - remainingPhases = ['Funding Round Duration', ...remainingPhases]; - } + const topicId = ArgumentOracle.getNamedArgument(interaction, CoreInformationAction.INPUT_IDS.TOPIC); - embed.addFields({ name: 'Remaining Phases', value: remainingPhases.join(', ') }); - } + const name = modalInteraction.fields.getTextInputValue(CoreInformationAction.INPUT_IDS.NAME); + const description = modalInteraction.fields.getTextInputValue(CoreInformationAction.INPUT_IDS.DESCRIPTION); + const budget = parseFloat(modalInteraction.fields.getTextInputValue(CoreInformationAction.INPUT_IDS.BUDGET)); + const stakingLedgerEpoch = parseInt(modalInteraction.fields.getTextInputValue(CoreInformationAction.INPUT_IDS.STAKING_LEDGER_EPOCH)); - const basicInfoButton = new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, ModifyFundingRoundAction.OPERATIONS.SHOW_BASIC_INFO_FORM, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, fundingRoundId.toString())) - .setLabel('Modify Basic Info') - .setStyle(ButtonStyle.Primary); + 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 phaseButtons = Object.values(ModifyFundingRoundAction.PHASE_NAMES).map(phase => - new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, ModifyFundingRoundAction.OPERATIONS.SHOW_PHASE_FORM, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, fundingRoundId.toString(), ArgumentOracle.COMMON_ARGS.PHASE, phase)) - .setLabel(`Modify ${phase} Phase`) - .setStyle(ButtonStyle.Secondary) + await (this.screen as ManageFundingRoundsScreen).createFundingRoundAction.handleOperation( + interaction, + CreateOrEditFundingRoundAction.OPERATIONS.SHOW_PROGRESS ); + } - const roundDurationButton = new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, ModifyFundingRoundAction.OPERATIONS.SHOW_PHASE_FORM, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, fundingRoundId.toString(), ArgumentOracle.COMMON_ARGS.PHASE, 'round')) - .setLabel('Modify Round Duration') - .setStyle(ButtonStyle.Secondary); - phaseButtons = [roundDurationButton, ...phaseButtons]; - const rows = [ - new ActionRowBuilder().addComponents(basicInfoButton), - new ActionRowBuilder().addComponents(phaseButtons) - ]; + public allSubActions(): Action[] { + return []; + } + + getComponent(): ButtonBuilder { + return new ButtonBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction(this, CoreInformationAction.OPERATIONS.SHOW_FORM)) + .setLabel('Edit Core Information') + .setStyle(ButtonStyle.Primary); + } +} + + +export class SetPhaseAction extends Action { + public static readonly ID = 'setPhase'; + + public static readonly OPERATIONS = { + SHOW_FORM: 'showForm', + SUBMIT_FORM: 'submitForm', + }; + + private static readonly INPUT_IDS = { + START_DATE: 'stDt', + END_DATE: 'edDt', + VOTING_OPEN_UNTIL: 'vou', + STAKING_LEDGER_EPOCH: 'stLdEp', + }; - await interaction.update({ embeds: [embed], components: rows }); + public static readonly ARGUMENTS = { + PHASE: 'phase', } - private async handleShowBasicInfoForm(interaction: TrackedInteraction): Promise { - const fundingRoundId = CustomIDOracle.getNamedArgument(interaction.customId, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID); - if (!fundingRoundId) { - throw new EndUserError('Invalid funding round ID.'); - } + public static PHASE_OPTIONS = { + CONSIDERATION: FundingRoundMI.PHASES.CONSIDERATION, + DELIBERATION: FundingRoundMI.PHASES.DELIBERATION, + VOTING: FundingRoundMI.PHASES.VOTING, + ROUND: FundingRoundMI.PHASES.ROUND, + } - const fundingRound = await FundingRoundLogic.getFundingRoundById(parseInt(fundingRoundId)); - if (!fundingRound) { - throw new EndUserError('Funding round not found.'); + public async handleOperation(interaction: TrackedInteraction, operationId: string): Promise { + switch (operationId) { + case SetPhaseAction.OPERATIONS.SHOW_FORM: + await this.handleShowForm(interaction); + break; + case SetPhaseAction.OPERATIONS.SUBMIT_FORM: + await this.handleSubmitForm(interaction); + break; + default: + await this.handleInvalidOperation(interaction, operationId); } + } - const topic: Topic = await fundingRound.getTopic() - - const modalInteraction = InteractionProperties.toShowModalOrUndefined(interaction.interaction); - if (!modalInteraction) { - throw new EndUserError('This interaction does not support modals.'); + private async handleShowForm(interaction: TrackedInteraction): Promise { + const fundingRoundId = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID); + const phase = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.PHASE); + const parsedPhase = FundingRoundMI.toFundingRoundPhaseFromString(phase); + + let startValue = ''; + let endValue = ''; + let stakingLedgerEpochValue = ''; + let votingOpenUntilValue = ''; + if (phase === FundingRoundMI.PHASES.ROUND) { + const fundingRound: FundingRound = await FundingRoundLogic.getFundingRoundByIdOrError(parseInt(fundingRoundId)); + startValue = this.formatDate(fundingRound.startAt); + endValue = this.formatDate(fundingRound.endAt); + votingOpenUntilValue = this.formatDate(fundingRound.votingOpenUntil); + stakingLedgerEpochValue = fundingRound.stakingLedgerEpoch.toString(); + } else { + const phaseData = await FundingRoundLogic.getFundingRoundPhase(parseInt(fundingRoundId), parsedPhase); + if (phaseData) { + startValue = this.formatDate(phaseData.startAt); + endValue = this.formatDate(phaseData.endAt); + stakingLedgerEpochValue = phaseData.stakingLedgerEpoch.toString(); + } } - const modal = new ModalBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, ModifyFundingRoundAction.OPERATIONS.SUBMIT_BASIC_INFO, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, fundingRoundId)) - .setTitle('Modify Funding Round'); - const nameInput = new TextInputBuilder() - .setCustomId(ModifyFundingRoundAction.INPUT_IDS.NAME) - .setLabel('Funding Round Name') - .setStyle(TextInputStyle.Short) - .setValue(fundingRound.name) - .setRequired(true); - const descriptionInput = new TextInputBuilder() - .setCustomId(ModifyFundingRoundAction.INPUT_IDS.DESCRIPTION) - .setLabel('Description') - .setStyle(TextInputStyle.Paragraph) - .setValue(fundingRound.description) - .setRequired(true); + const modal = new ModalBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction(this, SetPhaseAction.OPERATIONS.SUBMIT_FORM, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, fundingRoundId, ArgumentOracle.COMMON_ARGS.PHASE, phase)) + .setTitle(`Set ${phase.charAt(0).toUpperCase() + phase.slice(1)} Phase`); - const budgetInput = new TextInputBuilder() - .setCustomId(ModifyFundingRoundAction.INPUT_IDS.BUDGET) - .setLabel('Budget') + const startDateInput = new TextInputBuilder() + .setCustomId(SetPhaseAction.INPUT_IDS.START_DATE) + .setLabel('Start Date (YYYY-MM-DD HH:MM)') .setStyle(TextInputStyle.Short) - .setValue(fundingRound.budget.toString()) + .setValue(startValue) .setRequired(true); - const topicIntput = new TextInputBuilder() - .setCustomId(ModifyFundingRoundAction.INPUT_IDS.TOPIC_NAME) - .setLabel('Topic Name') + const endDateInput = new TextInputBuilder() + .setCustomId(SetPhaseAction.INPUT_IDS.END_DATE) + .setLabel('End Date (YYYY-MM-DD HH:MM)') .setStyle(TextInputStyle.Short) - .setValue(topic.name) + .setValue(endValue) .setRequired(true); - const stLedgerInput = new TextInputBuilder() - .setCustomId(ModifyFundingRoundAction.INPUT_IDS.STAKING_LEDGER_EPOCH) - .setLabel('Staking Ledger Epoch Number (For Voting)') + + const stakingLedgerEpochInput = new TextInputBuilder() + .setCustomId(SetPhaseAction.INPUT_IDS.STAKING_LEDGER_EPOCH) + .setLabel('Staking Ledger Epoch For Voting') .setStyle(TextInputStyle.Short) - .setValue(fundingRound.stakingLedgerEpoch.toString()) + .setValue(stakingLedgerEpochValue) .setRequired(true); + modal.addComponents( - new ActionRowBuilder().addComponents(nameInput), - new ActionRowBuilder().addComponents(descriptionInput), - new ActionRowBuilder().addComponents(topicIntput), - new ActionRowBuilder().addComponents(budgetInput), - new ActionRowBuilder().addComponents(stLedgerInput) + new ActionRowBuilder().addComponents(startDateInput), + new ActionRowBuilder().addComponents(endDateInput), + new ActionRowBuilder().addComponents(stakingLedgerEpochInput), ); - await modalInteraction.showModal(modal); - } - - private async handleSubmitBasicInfo(interaction: TrackedInteraction): Promise { - const modalInteraction = InteractionProperties.toModalSubmitInteractionOrUndefined(interaction.interaction); - if (!modalInteraction) { - throw new EndUserError('Invalid interaction type.') - throw new EndUserError('Invalid interaction type ' + interaction.interaction); + if (phase === FundingRoundMI.PHASES.ROUND) { + const votingOpenUntilInput = new TextInputBuilder() + .setCustomId(SetPhaseAction.INPUT_IDS.VOTING_OPEN_UNTIL) + .setLabel('Voting Open Until (YYYY-MM-DD HH:MM)') + .setStyle(TextInputStyle.Short) + .setValue(votingOpenUntilValue) + .setRequired(true); + modal.addComponents( + new ActionRowBuilder().addComponents(votingOpenUntilInput) + ); } - const fundingRoundId = CustomIDOracle.getNamedArgument(interaction.customId, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID); - if (!fundingRoundId) { - throw new EndUserError('Invalid funding round ID.'); - } + await interaction.showModal(modal); + } + + private async handleSubmitForm(interaction: TrackedInteraction): Promise { + const modalInteraction = InteractionProperties.toModalSubmitInteractionOrError(interaction.interaction); - const name = modalInteraction.fields.getTextInputValue(ModifyFundingRoundAction.INPUT_IDS.NAME); - const description = modalInteraction.fields.getTextInputValue(ModifyFundingRoundAction.INPUT_IDS.DESCRIPTION); - const budget = parseFloat(modalInteraction.fields.getTextInputValue(ModifyFundingRoundAction.INPUT_IDS.BUDGET)); - const stLedger = modalInteraction.fields.getTextInputValue(ModifyFundingRoundAction.INPUT_IDS.STAKING_LEDGER_EPOCH); - const topicName = modalInteraction.fields.getTextInputValue(ModifyFundingRoundAction.INPUT_IDS.TOPIC_NAME); + const fundingRoundId = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID); + const phase = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.PHASE); - const topic = await TopicLogic.getTopicByName(topicName) + const startDate = InputDate.toDate(modalInteraction.fields.getTextInputValue(SetPhaseAction.INPUT_IDS.START_DATE)); + const endDate = InputDate.toDate(modalInteraction.fields.getTextInputValue(SetPhaseAction.INPUT_IDS.END_DATE)); + const stakingLedgerEpoch = parseInt(modalInteraction.fields.getTextInputValue(SetPhaseAction.INPUT_IDS.STAKING_LEDGER_EPOCH)); - if (!topic) { - await interaction.respond({ content: `No Topic with name ${topicName} found`, ephemeral: true }); - return; - } - if (isNaN(budget)) { - throw new EndUserError('Invalid budget value. Please enter a valid number.'); + if (isNaN(stakingLedgerEpoch)) { + await DiscordStatus.Error.error(interaction, 'Staking Ledger Epoch must be a number'); + return; } - if (isNaN(parseInt(stLedger))) { - throw new EndUserError('Invalid staking ledger epoch number. Please enter a valid number.'); + if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { + await DiscordStatus.Error.error(interaction, 'Invalid date format. Please use YYYY-MM-DD HH:MM'); + return; } - const ledgerNum: number = parseInt(stLedger); + const parsedPhase = FundingRoundMI.toFundingRoundPhaseFromString(phase); + if (parsedPhase == FundingRoundMI.PHASES.ROUND) { + // 1. Handle Funding Round core dates + const votingOpenUntil = InputDate.toDate(modalInteraction.fields.getTextInputValue(SetPhaseAction.INPUT_IDS.VOTING_OPEN_UNTIL)); + await FundingRoundLogic.updateFundingRoundVoteData(parseInt(fundingRoundId), startDate, endDate, votingOpenUntil, stakingLedgerEpoch); + } else { + // 2. Handle phase dates + const fundingRound: FundingRound = await FundingRoundLogic.updateFundingRoundPhase(parseInt(fundingRoundId), parsedPhase, stakingLedgerEpoch, startDate, endDate); + interaction.Context.set(ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, fundingRound.id.toString()); + } try { - const updatedFundingRound = await FundingRoundLogic.updateFundingRound(parseInt(fundingRoundId), { - name, - description, - budget, - stakingLedgerEpoch: ledgerNum, - topicId: topic.id, - }); - - if (!updatedFundingRound) { - throw new EndUserError('Funding round not found.'); - }; + interaction.Context.set(CreateOrEditFundingRoundAction.BOOLEANS.ARGUMENTS.ONLY_SHOW_PHASES, CreateOrEditFundingRoundAction.BOOLEANS.TRUE_VALUE); + await (this.screen as ManageFundingRoundsScreen).createFundingRoundAction.handleOperation( + interaction, + CreateOrEditFundingRoundAction.OPERATIONS.SHOW_PROGRESS + ); - const embed = new EmbedBuilder() - .setColor('#00FF00') - .setTitle('Funding Round Updated') - .setDescription('The funding round has been successfully updated.') - .addFields( - { name: 'Name', value: updatedFundingRound.name }, - { name: 'Description', value: updatedFundingRound.description }, - { name: 'Topic', value: topic.name }, - { name: 'Budget', value: updatedFundingRound.budget.toString() }, - { name: 'Staking Ledger Epoch Number (For Voting)', value: updatedFundingRound.stakingLedgerEpoch.toString() } - ); + await DiscordStatus.Success.success(interaction, `Phase data updated successfully`); - await interaction.update({ embeds: [embed], components: [] }); } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred'; - await interaction.respond({ content: `Error updating funding round: ${errorMessage}`, ephemeral: true }); + throw error; } } - private async handleShowPhaseForm(interaction: TrackedInteraction): Promise { - const fundingRoundId = CustomIDOracle.getNamedArgument(interaction.customId, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID); - let phase = CustomIDOracle.getNamedArgument(interaction.customId, ArgumentOracle.COMMON_ARGS.PHASE) as 'consideration' | 'deliberation' | 'voting' | 'round'; - phase = phase.toLowerCase() as 'consideration' | 'deliberation' | 'voting' | 'round'; - - if (!fundingRoundId || !phase) { - throw new EndUserError('Invalid funding round ID or phase.'); - } - - const fundingRound = await FundingRoundLogic.getFundingRoundById(parseInt(fundingRoundId)); - if (!fundingRound) { - throw new EndUserError('Funding round not found.'); - } - - const modalInteraction = InteractionProperties.toShowModalOrUndefined(interaction.interaction); - if (!modalInteraction) { - throw new EndUserError('This interaction does not support modals.'); + private formatDate(date: Date): string { + if (!date) { + return ''; } + return date.toISOString().slice(0, 16).replace('T', ' '); + } - const existingPhases = await FundingRoundLogic.getFundingRoundPhases(parseInt(fundingRoundId)); - const existingPhase = existingPhases.find((p: FundingRoundPhase) => p.phase === phase); - - const title: string = phase === 'round' ? 'Modify Round Dates' : `Modify ${phase.charAt(0).toUpperCase() + phase.slice(1)} Phase Dates`; - const startDateCustomId: string = phase === 'round' ? ModifyFundingRoundAction.INPUT_IDS.ROUND_START_DATE : ModifyFundingRoundAction.INPUT_IDS.START_DATE; - const endDateCustomId: string = phase === 'round' ? ModifyFundingRoundAction.INPUT_IDS.ROUND_END_DATE : ModifyFundingRoundAction.INPUT_IDS.END_DATE; - - const startDateValue: string = phase === 'round' && fundingRound.startAt ? this.formatDate(fundingRound.startAt) : existingPhase ? this.formatDate(existingPhase.startDate) : ''; - const endDateValue: string = phase === 'round' && fundingRound.startAt ? this.formatDate(fundingRound.endAt) : existingPhase ? this.formatDate(existingPhase.endDate) : ''; - - logger.info(`startDateValue: ${startDateValue} endDateValue: ${endDateValue}`); + public allSubActions(): Action[] { + return []; + } - const modal = new ModalBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, ModifyFundingRoundAction.OPERATIONS.SUBMIT_PHASE, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, fundingRoundId, ArgumentOracle.COMMON_ARGS.PHASE, phase)) - .setTitle(title); + getComponent(phase: string): ButtonBuilder { + return new ButtonBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction(this, SetPhaseAction.OPERATIONS.SHOW_FORM, ArgumentOracle.COMMON_ARGS.PHASE, phase)) + .setLabel(`Set ${phase.charAt(0).toUpperCase() + phase.slice(1)} Phase`) + .setStyle(ButtonStyle.Primary); + } +} - const startDateInput = new TextInputBuilder() - .setCustomId(startDateCustomId) - .setLabel('Start Date (YYYY-MM-DD HH:MM)') - .setStyle(TextInputStyle.Short) - .setValue(startDateValue) - .setRequired(true); +export class SelectForumChannelAction extends PaginationComponent { + public static readonly ID = 'selectForumChannel'; - const endDateInput = new TextInputBuilder() - .setCustomId(endDateCustomId) - .setLabel('End Date (YYYY-MM-DD HH:MM)') - .setStyle(TextInputStyle.Short) - .setValue(endDateValue) - .setRequired(true); + public static readonly OPERATIONS = { + SHOW_CHANNELS: 'showChannels', + SELECT_CHANNEL: 'selectChannel', + }; - modal.addComponents( - new ActionRowBuilder().addComponents(startDateInput), - new ActionRowBuilder().addComponents(endDateInput) - ); + protected async getTotalPages(interaction: TrackedInteraction): Promise { + const forumChannels = await this.getGuildForumChannels(interaction); + return Math.ceil(forumChannels.length / 25); + } - await modalInteraction.showModal(modal); + protected async getItemsForPage(interaction: TrackedInteraction, page: number): Promise { + const forumChannels = await this.getGuildForumChannels(interaction); + return forumChannels.slice(page * 25, (page + 1) * 25); } - private async handleSubmitPhase(interaction: TrackedInteraction): Promise { - const modalInteraction = InteractionProperties.toModalSubmitInteractionOrUndefined(interaction.interaction); - if (!modalInteraction) { - throw new EndUserError('Invalid interaction type.') + private async getGuildForumChannels(interaction: TrackedInteraction): Promise { + const guild = interaction.interaction.guild; + if (!guild) { + throw new EndUserError('This command can only be used in a server'); } + return Array.from(guild.channels.cache.filter(channel => channel.type === ChannelType.GuildForum).values()) as ForumChannel[]; + } + public async handlePagination(interaction: TrackedInteraction): Promise { + const currentPage = this.getCurrentPage(interaction); + const totalPages = await this.getTotalPages(interaction); + const forumChannels = await this.getItemsForPage(interaction, currentPage); const fundingRoundId = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID); - const phase = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.PHASE) as 'consideration' | 'deliberation' | 'voting' | 'round'; - let startDate: Date; - let endDate: Date; - if (phase === 'round') { - startDate = new Date(modalInteraction.fields.getTextInputValue(ModifyFundingRoundAction.INPUT_IDS.ROUND_START_DATE)); - endDate = new Date(modalInteraction.fields.getTextInputValue(ModifyFundingRoundAction.INPUT_IDS.ROUND_END_DATE)); - } else { - startDate = new Date(modalInteraction.fields.getTextInputValue(ModifyFundingRoundAction.INPUT_IDS.START_DATE)); - endDate = new Date(modalInteraction.fields.getTextInputValue(ModifyFundingRoundAction.INPUT_IDS.END_DATE)); - } + const selectMenu = new StringSelectMenuBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction(this, SelectForumChannelAction.OPERATIONS.SELECT_CHANNEL, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, fundingRoundId)) + .setPlaceholder('Select a Forum Channel') + .addOptions(forumChannels.map(channel => ({ + label: channel.name, + value: channel.id, + description: `ID: ${channel.id}` + }))); + const components: ActionRowBuilder[] = [ + new ActionRowBuilder().addComponents(selectMenu) + ]; - if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { - throw new EndUserError('Invalid date format. Please use YYYY-MM-DD HH:MM.'); + if (totalPages > 1) { + const paginationRow = this.getPaginationRow(interaction, currentPage, totalPages); + components.push(paginationRow); } - if (startDate >= endDate) { - throw new EndUserError('Start date must be before end date.'); + await interaction.update({ components }); + } + + public async handleOperation(interaction: TrackedInteraction, operationId: string): Promise { + switch (operationId) { + case SelectForumChannelAction.OPERATIONS.SHOW_CHANNELS: + case PaginationComponent.PAGINATION_ARG: + await this.handlePagination(interaction); + break; + case SelectForumChannelAction.OPERATIONS.SELECT_CHANNEL: + await this.handleSelectChannel(interaction); + break; + default: + await this.handleInvalidOperation(interaction, operationId); } + } - const stakingLedgerEpochNum: number = parseInt(modalInteraction.fields.getTextInputValue(ModifyFundingRoundAction.INPUT_IDS.STAKING_LEDGER_EPOCH)); + private async handleSelectChannel(interaction: TrackedInteraction): Promise { + const interactionWithValues = InteractionProperties.toInteractionWithValuesOrError(interaction.interaction); + const forumChannelId = interactionWithValues.values[0]; - try { - const fundingRound = await FundingRoundLogic.getFundingRoundById(parseInt(fundingRoundId)); - if (!fundingRound) { - throw new EndUserError('Funding round not found.'); - } + const fundingRoundId: string = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID); - const existingPhases = await FundingRoundLogic.getFundingRoundPhases(parseInt(fundingRoundId)); + try { + const fundingRound: FundingRound = await FundingRoundLogic.updateFundingRound(parseInt(fundingRoundId), { forumChannelId }); + interaction.Context.set(ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, fundingRound.id.toString()); - // Check phase order - if (phase === 'deliberation') { - const considerationPhase = existingPhases.find((p: FundingRoundPhase) => p.phase === 'consideration'); - if (considerationPhase && startDate < considerationPhase.endDate) { - throw new EndUserError('Deliberation phase must start after Consideration phase ends.'); - } - } else if (phase === 'voting') { - const deliberationPhase = existingPhases.find((p: FundingRoundPhase) => p.phase === 'deliberation'); - if (deliberationPhase && startDate < deliberationPhase.endDate) { - throw new EndUserError('Voting phase must start after Deliberation phase ends.'); - } - } + await (this.screen as ManageFundingRoundsScreen).createFundingRoundAction.handleOperation( + interaction, + CreateOrEditFundingRoundAction.OPERATIONS.SHOW_PROGRESS + ); + await DiscordStatus.Success.success(interaction, 'Forum channel selected successfully'); + } catch (error) { + await DiscordStatus.Error.handleError(interaction, error); + } + } - await FundingRoundLogic.setFundingRoundPhase(parseInt(fundingRoundId), phase, stakingLedgerEpochNum, startDate, endDate); + public allSubActions(): Action[] { + return []; + } - const updatedPhases = await FundingRoundLogic.getFundingRoundPhases(parseInt(fundingRoundId)); - let allPhasesSet: boolean = ['consideration', 'deliberation', 'voting'] - .every(phaseName => updatedPhases.some((p: FundingRoundPhase) => p.phase === phaseName)); + getComponent(): ButtonBuilder { + return new ButtonBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction(this, SelectForumChannelAction.OPERATIONS.SHOW_CHANNELS)) + .setLabel('Select Forum Channel') + .setStyle(ButtonStyle.Primary); + } +} - allPhasesSet = allPhasesSet && (fundingRound.startAt !== null) && (fundingRound.endAt !== null); - fundingRound.reload(); - const embed = new EmbedBuilder() - .setColor('#00FF00') - .setTitle('Funding Round Phase Updated') - .setDescription(`The ${phase} phase has been updated successfully.`) - .addFields( - { name: 'Name', value: fundingRound.name }, - { name: 'Description', value: fundingRound.description }, - { name: 'Budget', value: fundingRound.budget.toString() }, - { name: 'Staking Ledger Epoch Number (For Voting)', value: fundingRound.stakingLedgerEpoch.toString() }, - { name: 'Start Date', value: fundingRound.startAt ? this.formatDate(fundingRound.startAt) : '❌ Not set' }, - { name: 'End Date', value: fundingRound.endAt ? this.formatDate(fundingRound.endAt) : '❌ Not set' }, - ...updatedPhases.map((p: FundingRoundPhase) => ({ name: `${p.phase.charAt(0).toUpperCase() + p.phase.slice(1)} Phase`, value: `Start: ${this.formatDate(p.startDate)}\nEnd: ${this.formatDate(p.endDate)}`, inline: true })) - ); +export class ModifyFundingRoundAction extends Action { + public static readonly ID = 'modifyFundingRound'; - if (allPhasesSet) { - embed.addFields({ name: 'Status', value: 'All phases have been set for the funding round.' }); - } else { - const remainingPhases = ['consideration', 'deliberation', 'voting'] - .filter(phaseName => !updatedPhases.some((p: FundingRoundPhase) => p.phase === phaseName)); + public static readonly OPERATIONS = { + SHOW_FUNDING_ROUNDS: 'showFundingRounds', + }; - embed.addFields({ name: 'Remaining Phases', value: remainingPhases.join(', ') }); - if (fundingRound.startAt === null || fundingRound.endAt === null) { - embed.addFields({ name: 'Remaining Phases', value: 'Funding Round Duration' }); - } - } - const backButton = new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, ModifyFundingRoundAction.OPERATIONS.SELECT_FUNDING_ROUND, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, fundingRoundId)) - .setLabel('Back to Funding Round') - .setStyle(ButtonStyle.Secondary); - const row = new ActionRowBuilder().addComponents(backButton); + constructor(screen: Screen, actionId: string) { + super(screen, actionId); + } - await interaction.update({ embeds: [embed], components: [row] }); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred'; - await interaction.respond({ content: `Error updating phase: ${errorMessage}`, ephemeral: true }); - throw error; + protected async handleOperation(interaction: TrackedInteraction, operationId: string): Promise { + switch (operationId) { + case ModifyFundingRoundAction.OPERATIONS.SHOW_FUNDING_ROUNDS: + await this.handleShowFundingRounds(interaction); + break; + default: + await this.handleInvalidOperation(interaction, operationId); } } - private formatDate(date: Date): string { - return date.toISOString().slice(0, 16).replace('T', ' '); + private async handleShowFundingRounds(interaction: TrackedInteraction): Promise { + await (this.screen as ManageFundingRoundsScreen).crudFRPaginatorAction.handlePagination(interaction); } - public allSubActions(): Action[] { - return [this.fundingRoundPaginationAction]; - } - private async handleEditFundingRound(interaction: TrackedInteraction): Promise { - await (this.screen as ManageFundingRoundsScreen).selectFundingRoundToEditAction.handleOperation( - interaction, - SelectFundingRoundToEditAction.OPERATIONS.SHOW_FUNDING_ROUNDS - ); + public allSubActions(): Action[] { + return []; } getComponent(): ButtonBuilder { return new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, ModifyFundingRoundAction.OPERATIONS.EDIT_FUNDING_ROUND)) + .setCustomId(CustomIDOracle.addArgumentsToAction(this, ModifyFundingRoundAction.OPERATIONS.SHOW_FUNDING_ROUNDS)) .setLabel('Edit Funding Round') .setStyle(ButtonStyle.Primary); } @@ -941,6 +866,7 @@ export class ModifyFundingRoundAction extends Action { export class SetFundingRoundCommitteeAction extends PaginationComponent { public static readonly ID = 'setFundingRoundCommittee'; + public static readonly OPERATIONS = { SHOW_FUNDING_ROUNDS: 'showFundingRounds', SELECT_FUNDING_ROUND: 'selectFundingRound', @@ -948,6 +874,7 @@ export class SetFundingRoundCommitteeAction extends PaginationComponent { CONFIRM_COMMITTEE: 'confirmCommittee', }; + protected async getTotalPages(interaction: TrackedInteraction, fundingRoundId?: number): Promise { let parsedFundingRoundId: number; if (fundingRoundId === undefined) { @@ -1031,34 +958,11 @@ export class SetFundingRoundCommitteeAction extends PaginationComponent { } private async handleShowFundingRounds(interaction: TrackedInteraction): Promise { - const fundingRounds = await FundingRoundLogic.getPresentAndFutureFundingRounds(); - - const selectMenu = new StringSelectMenuBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, SetFundingRoundCommitteeAction.OPERATIONS.SELECT_FUNDING_ROUND)) - .setPlaceholder('Select a Funding Round') - .addOptions(fundingRounds.map(fr => ({ - label: fr.name, - value: fr.id.toString(), - description: `Status: ${fr.status}` - }))); - - const row = new ActionRowBuilder().addComponents(selectMenu); - - await interaction.update({ components: [row] }); + await (this.screen as ManageFundingRoundsScreen).committeeFRPaginator.handlePagination(interaction); } private async handleSelectFundingRound(interaction: TrackedInteraction): Promise { - let fundingRoundId: string | undefined; - const interactionWithValues = InteractionProperties.toInteractionWithValuesOrUndefined(interaction.interaction); - if (!interactionWithValues) { - fundingRoundId = CustomIDOracle.getNamedArgument(interaction.customId, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID); - if (!fundingRoundId) { - throw new EndUserError('No values and no fundingRound in customId') - } - } else { - fundingRoundId = interactionWithValues.values[0]; - } - + let fundingRoundId: string = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, 0); const parsedFundingRoundId = parseInt(fundingRoundId); await this.showCommitteeMemberSelection(interaction, parsedFundingRoundId); } @@ -1201,11 +1105,12 @@ export class SetFundingRoundCommitteeAction extends PaginationComponent { export class ApproveFundingRoundAction extends Action { public static readonly ID = 'approveFundingRound'; - private static readonly OPERATIONS = { + public static readonly OPERATIONS = { SHOW_FUNDING_ROUNDS: 'showFundingRounds', CONFIRM_APPROVAL: 'confirmApproval', EXECUTE_APPROVAL: 'executeApproval', EXECUTE_REJECTION: 'executeRejection', + SELECT_FUNDING_ROUND: 'selFR', }; protected async handleOperation(interaction: TrackedInteraction, operationId: string): Promise { @@ -1213,6 +1118,9 @@ export class ApproveFundingRoundAction extends Action { case ApproveFundingRoundAction.OPERATIONS.SHOW_FUNDING_ROUNDS: await this.handleShowFundingRounds(interaction); break; + case ApproveFundingRoundAction.OPERATIONS.SELECT_FUNDING_ROUND: + await this.handleSelectFundingRound(interaction); + break case ApproveFundingRoundAction.OPERATIONS.CONFIRM_APPROVAL: await this.handleConfirmApproval(interaction); break; @@ -1228,34 +1136,17 @@ export class ApproveFundingRoundAction extends Action { } private async handleShowFundingRounds(interaction: TrackedInteraction): Promise { - const fundingRounds = await FundingRoundLogic.getPresentAndFutureFundingRounds(); - - const selectMenu = new StringSelectMenuBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, ApproveFundingRoundAction.OPERATIONS.CONFIRM_APPROVAL)) - .setPlaceholder('Select a Funding Round') - .addOptions(fundingRounds.map(fr => ({ - label: fr.name, - value: fr.id.toString(), - description: `Status: ${fr.status}` - }))); - - const row = new ActionRowBuilder().addComponents(selectMenu); + await (this.screen as ManageFundingRoundsScreen).approveRejectFRPaginator.handlePagination(interaction); + } - await interaction.update({ components: [row] }); + private async handleSelectFundingRound(interaction: TrackedInteraction): Promise { + await this.handleConfirmApproval(interaction); } private async handleConfirmApproval(interaction: TrackedInteraction): Promise { - const interactionWithValues = InteractionProperties.toInteractionWithValuesOrUndefined(interaction.interaction); - if (!interactionWithValues) { - throw new EndUserError('Invalid interaction type.') - } - - const fundingRoundId = parseInt(interactionWithValues.values[0]); - const fundingRound = await FundingRoundLogic.getFundingRoundById(fundingRoundId); + const fundingRoundId = parseInt(ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, 0)); - if (!fundingRound) { - throw new EndUserError('Funding round not found.'); - } + const fundingRound = await FundingRoundLogic.getFundingRoundByIdOrError(fundingRoundId); const embed = new EmbedBuilder() .setColor('#0099ff') @@ -1417,29 +1308,11 @@ export class RemoveFundingRoundCommitteeAction extends PaginationComponent { } private async handleShowFundingRounds(interaction: TrackedInteraction): Promise { - const fundingRounds = await FundingRoundLogic.getPresentAndFutureFundingRounds(); - - const selectMenu = new StringSelectMenuBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, RemoveFundingRoundCommitteeAction.OPERATIONS.SELECT_FUNDING_ROUND)) - .setPlaceholder('Select a Funding Round') - .addOptions(fundingRounds.map(fr => ({ - label: fr.name, - value: fr.id.toString(), - description: `Status: ${fr.status}` - }))); - - const row = new ActionRowBuilder().addComponents(selectMenu); - - await interaction.update({ components: [row] }); + await (this.screen as ManageFundingRoundsScreen).committeeDeleteFRPaginator.handlePagination(interaction); } - + private async handleSelectFundingRound(interaction: TrackedInteraction): Promise { - const interactionWithValues = InteractionProperties.toInteractionWithValuesOrUndefined(interaction.interaction); - if (!interactionWithValues) { - throw new EndUserError('Invalid interaction type.'); - } - - const fundingRoundId = interactionWithValues.values[0]; + const fundingRoundId: string = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, 0); await this.showMemberRemovalSelection(interaction, parseInt(fundingRoundId)); } @@ -1583,6 +1456,7 @@ export class SelectFundingRoundToEditAction extends PaginationComponent { public async handleOperation(interaction: TrackedInteraction, operationId: string): Promise { switch (operationId) { + case PaginationComponent.PAGINATION_ARG: case SelectFundingRoundToEditAction.OPERATIONS.SHOW_FUNDING_ROUNDS: await this.handleShowFundingRounds(interaction); break; @@ -1608,8 +1482,10 @@ export class SelectFundingRoundToEditAction extends PaginationComponent { .setTitle('Select a Funding Round to Edit') .setDescription(`Please select a funding round to edit. Page ${currentPage + 1} of ${totalPages}`); + const customId: string = CustomIDOracle.addArgumentsToAction((this.screen as ManageFundingRoundsScreen).createFundingRoundAction, CreateOrEditFundingRoundAction.OPERATIONS.SHOW_PROGRESS); + const selectMenu = new StringSelectMenuBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, SelectFundingRoundToEditAction.OPERATIONS.SELECT_FUNDING_ROUND)) + .setCustomId(customId) .setPlaceholder('Select a Funding Round') .addOptions(fundingRounds.map(fr => ({ label: fr.name, @@ -1625,7 +1501,7 @@ export class SelectFundingRoundToEditAction extends PaginationComponent { components.push(paginationRow); } - await interaction.respond({ embeds: [embed], components, ephemeral: true }); + await interaction.update({ embeds: [embed], components, ephemeral: true }); } private async handleSelectFundingRound(interaction: TrackedInteraction): Promise { @@ -1637,11 +1513,15 @@ export class SelectFundingRoundToEditAction extends PaginationComponent { ); } + + public allSubActions(): Action[] { return []; } getComponent(): ButtonBuilder { + + console.log("Hellooooooo"); return new ButtonBuilder() .setCustomId(CustomIDOracle.addArgumentsToAction(this, SelectFundingRoundToEditAction.OPERATIONS.SHOW_FUNDING_ROUNDS)) .setLabel('Edit Funding Round') @@ -1719,7 +1599,7 @@ export class EditFundingRoundTypeSelectionAction extends Action { const row = new ActionRowBuilder() .addComponents(editInfoButton, editPhasesButton, editTopicButton); - + await interaction.update({ embeds: [embed], components: [row], ephemeral: true }); } @@ -1833,12 +1713,12 @@ export class EditFundingRoundInformationAction extends Action { .setStyle(TextInputStyle.Short) .setValue(fundingRound.stakingLedgerEpoch.toString()) .setRequired(true); - + const forumChannelId = new TextInputBuilder() .setCustomId(EditFundingRoundInformationAction.INPUT_IDS.FORUM_CHANNEL_ID) .setLabel('Forum Channel ID') .setStyle(TextInputStyle.Short) - .setValue(fundingRound.forumChannelId ? fundingRound.forumChannelId.toString(): '') + .setValue(fundingRound.forumChannelId ? fundingRound.forumChannelId.toString() : '') .setRequired(true); modal.addComponents( @@ -1855,7 +1735,7 @@ export class EditFundingRoundInformationAction extends Action { private async handleSubmitEdit(interaction: TrackedInteraction): Promise { const modalInteraction = InteractionProperties.toModalSubmitInteractionOrError(interaction.interaction); - const fundingRoundId: number = parseInt(ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID)); + const fundingRoundId: number = parseInt(ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID)); const name = modalInteraction.fields.getTextInputValue(EditFundingRoundInformationAction.INPUT_IDS.NAME); const description = modalInteraction.fields.getTextInputValue(EditFundingRoundInformationAction.INPUT_IDS.DESCRIPTION); @@ -1992,6 +1872,8 @@ export class EditFundingRoundPhasesAction extends Action { const phase: string = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.PHASE); const fundingRoundId: number = parseInt(ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID)); + const parsedPhase = FundingRoundMI.toFundingRoundPhaseFromString(phase); + const fundingRound = await FundingRoundLogic.getFundingRoundById(fundingRoundId); if (!fundingRound) { throw new EndUserError('Funding round not found.'); @@ -2002,7 +1884,7 @@ export class EditFundingRoundPhasesAction extends Action { startDate = fundingRound.startAt; endDate = fundingRound.endAt; } else { - const phaseData = await FundingRoundLogic.getFundingRoundPhase(fundingRoundId, phase); + const phaseData = await FundingRoundLogic.getFundingRoundPhase(fundingRoundId, parsedPhase); startDate = phaseData?.startAt; endDate = phaseData?.endAt; } @@ -2029,7 +1911,7 @@ export class EditFundingRoundPhasesAction extends Action { .setCustomId(EditFundingRoundPhasesAction.INPUT_IDS.STAKING_LEDGER_EPOCH) .setLabel('Staking Ledger Epoch For Vote Counting') .setStyle(TextInputStyle.Short) - .setValue(fundingRound.stakingLedgerEpoch ? fundingRound.stakingLedgerEpoch.toString(): '') + .setValue(fundingRound.stakingLedgerEpoch ? fundingRound.stakingLedgerEpoch.toString() : '') .setRequired(true); modal.addComponents( @@ -2050,14 +1932,18 @@ export class EditFundingRoundPhasesAction extends Action { const fundingRoundId: number = parseInt(ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID)); const phase: string = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.PHASE); - const startDate = new Date(modalInteraction.fields.getTextInputValue(EditFundingRoundPhasesAction.INPUT_IDS.START_DATE)); - const endDate = new Date(modalInteraction.fields.getTextInputValue(EditFundingRoundPhasesAction.INPUT_IDS.END_DATE)); + const startDate = InputDate.toDate(modalInteraction.fields.getTextInputValue(EditFundingRoundPhasesAction.INPUT_IDS.START_DATE)); + const endDate = InputDate.toDate(modalInteraction.fields.getTextInputValue(EditFundingRoundPhasesAction.INPUT_IDS.END_DATE)); const stakingLedgerEpochNum: number = parseInt(modalInteraction.fields.getTextInputValue(EditFundingRoundPhasesAction.INPUT_IDS.STAKING_LEDGER_EPOCH)); if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { throw new EndUserError('Invalid date format. Please use YYYY-MM-DD HH:MM.'); } + if (isNaN(stakingLedgerEpochNum)) { + throw new EndUserError('Invalid staking ledger epoch number.'); + } + if (startDate >= endDate) { throw new EndUserError('Start date must be before end date.'); } @@ -2066,7 +1952,7 @@ export class EditFundingRoundPhasesAction extends Action { try { if (phase === EditFundingRoundPhasesAction.PHASE_NAMES.ROUND) { - await FundingRoundLogic.updateFundingRound(fundingRoundId,{startAt: startDate, endAt: endDate, stakingLedgerEpoch: stakingLedgerEpochNum }); + await FundingRoundLogic.updateFundingRound(fundingRoundId, { startAt: startDate, endAt: endDate, stakingLedgerEpoch: stakingLedgerEpochNum }); } else { await FundingRoundLogic.setFundingRoundPhase(fundingRoundId, stringPhase, stakingLedgerEpochNum, startDate, endDate); } @@ -2079,7 +1965,7 @@ export class EditFundingRoundPhasesAction extends Action { .setTitle('Funding Round Phase Updated') .setDescription(`The ${phase} phase for "${updatedFundingRound?.name}" has been successfully updated.`) .addFields( - { name: 'Funding Round Duration', value: `Start: ${updatedFundingRound?.startAt?.toISOString() || 'Not set'}\nEnd: ${updatedFundingRound?.endAt?.toISOString() || 'Not set'}`, inline: false }, + { name: 'Funding Round Duration', value: `Start: ${updatedFundingRound?.startAt?.toISOString() || 'Not set'}\nEnd: ${updatedFundingRound?.endAt?.toISOString() || 'Not set'}`, inline: false }, { name: 'Staking Ledger Epoch', value: updatedFundingRound?.stakingLedgerEpoch.toString(), inline: false } ); From 512b650c3e9882a7faceb35338f15b5784d40ff6 Mon Sep 17 00:00:00 2001 From: Illya Gerasymchuk Date: Thu, 15 Aug 2024 21:01:20 +0100 Subject: [PATCH 04/15] refactor: funding round logic --- .../admin/screens/FundingRoundLogic.ts | 211 ++++++++++++++++-- 1 file changed, 189 insertions(+), 22 deletions(-) diff --git a/src/channels/admin/screens/FundingRoundLogic.ts b/src/channels/admin/screens/FundingRoundLogic.ts index 6e228b5..c94c419 100644 --- a/src/channels/admin/screens/FundingRoundLogic.ts +++ b/src/channels/admin/screens/FundingRoundLogic.ts @@ -24,6 +24,17 @@ export class FundingRoundLogic { }); } + static async newFundingRoundFromCoreInfo(name: string, description: string, topicId: number, budget: number, stakingLedgerEpoch: number): Promise { + return await FundingRound.create({ + name, + description, + topicId, + budget, + stakingLedgerEpoch, + status: FundingRoundStatus.VOTING, + }); + } + static async getFundingRoundById(id: number): Promise { return await FundingRound.findByPk(id); } @@ -37,19 +48,20 @@ export class FundingRoundLogic { return fundingRound; } - static async getFundingRoundPhase(fundingRoundId: number, phase: string) { - switch (phase.toLowerCase()) { - case 'consideration': + static async getFundingRoundPhase(fundingRoundId: number, phase: FundingRoundMIPhaseValue) { + switch (phase) { + case FundingRoundMI.PHASES.CONSIDERATION: return await ConsiderationPhase.findOne({ where: { fundingRoundId } }); - case 'deliberation': + case FundingRoundMI.PHASES.DELIBERATION: return await DeliberationPhase.findOne({ where: { fundingRoundId } }); - case 'voting': + case FundingRoundMI.PHASES.VOTING: return await FundingVotingPhase.findOne({ where: { fundingRoundId } }); default: throw new EndUserError(`Invalid phase: ${phase}. Funding Round Id ${fundingRoundId}`); } } + static async getFundingRoundPhases(fundingRoundId: number): Promise { const considerationPhase = await ConsiderationPhase.findOne({ where: { fundingRoundId } }); const deliberationPhase = await DeliberationPhase.findOne({ where: { fundingRoundId } }); @@ -100,6 +112,8 @@ export class FundingRoundLogic { static async setFundingRoundPhase(fundingRoundId: number, phase: FundingRoundMIPhaseValue, stakingLedgerEpoch: number, startDate: Date, endDate: Date): Promise { const fundingRound = await this.getFundingRoundByIdOrError(fundingRoundId); + await this.validateFundingRoundPhaseDatesOrError(fundingRoundId, phase, startDate, endDate); + switch (phase.toString().toLocaleLowerCase()) { case FundingRoundMI.PHASES.CONSIDERATION.toString().toLocaleLowerCase(): await ConsiderationPhase.upsert({ @@ -149,11 +163,8 @@ export class FundingRoundLogic { }); } - static async updateFundingRound(id: number, updates: Partial): Promise { - const fundingRound = await this.getFundingRoundById(id); - if (!fundingRound) { - return null; - } + static async updateFundingRound(id: number, updates: Partial): Promise { + const fundingRound = await this.getFundingRoundByIdOrError(id); if (updates.topicId) { const topic = await Topic.findByPk(updates.topicId); @@ -676,18 +687,174 @@ export class FundingRoundLogic { static async getActiveFundingRounds(): Promise { const now = new Date(); return await FundingRound.findAll({ - where: { - [Op.or]: [ - { status: FundingRoundStatus.VOTING }, - { - status: FundingRoundStatus.APPROVED, - startAt: { [Op.lte]: now }, - endAt: { [Op.gte]: now } - } - ] - }, - order: [['createdAt', 'DESC']] + where: { + [Op.or]: [ + { status: FundingRoundStatus.VOTING }, + { + status: FundingRoundStatus.APPROVED, + startAt: { [Op.lte]: now }, + endAt: { [Op.gte]: now } + } + ] + }, + order: [['createdAt', 'DESC']] }); - } + } + + static async validateFundingRoundDatesOrError(fundingRoundId: number, newStartDate: Date, newEndDate: Date, newVotingOpenUntil?: Date): Promise { + const phases = await this.getFundingRoundPhases(fundingRoundId); + + if (newStartDate >= newEndDate) { + throw new EndUserError('Start date must be before end date.'); + } + + if (newVotingOpenUntil && newVotingOpenUntil >= newStartDate) { + throw new EndUserError(`Voting open until date must be before start date, and ${newVotingOpenUntil.toUTCString()} >= ${newStartDate.toUTCString()}`); + } + + for (let i = 0; i < phases.length; i++) { + if (phases[i].startDate < newStartDate || phases[i].endDate > newEndDate) { + throw new EndUserError(`${phases[i].phase} phase must be within funding round dates, and ${phases[i].startDate.toUTCString()} < ${newStartDate.toUTCString()} or ${phases[i].endDate.toUTCString()} > ${newEndDate.toUTCString()}`); + } + + if (i > 0 && phases[i].startDate <= phases[i - 1].endDate) { + throw new EndUserError(`${phases[i].phase} phase must start after ${phases[i - 1].phase} phase ends, and ${phases[i].startDate.toUTCString()} <= ${phases[i - 1].endDate.toUTCString()}`); + } + } + } + + static async validateFundingRoundPhaseDatesOrError(fundingRoundId: number, phase: FundingRoundMIPhaseValue, newStartDate: Date, newEndDate: Date): Promise { + const fundingRound: FundingRound = await this.getFundingRoundByIdOrError(fundingRoundId); + if (newStartDate >= newEndDate) { + throw new EndUserError(`Start date must be before end date, and ${newStartDate} >= ${newEndDate}`); + } + + if (fundingRound.startAt && newStartDate <= fundingRound.startAt) { + throw new EndUserError(`Start date must be after funding round start date, and ${newStartDate.toUTCString()} <= ${fundingRound.startAt.toUTCString()}`); + } + + if (fundingRound.endAt && newEndDate >= fundingRound.endAt) { + throw new EndUserError(`End date must be before funding round end date, and ${newEndDate.toUTCString()} >= ${fundingRound.endAt.toUTCString()}`); + } + + + if (phase === FundingRoundMI.PHASES.CONSIDERATION) { + logger.debug(`Validating consideration phase dates for funding round ${fundingRoundId}...`); + + // Ensure that the consideration phase ends before the Debliberation phase starts + const deliberationPhase = await this.getFundingRoundPhase(fundingRoundId, FundingRoundMI.PHASES.DELIBERATION); + + if (deliberationPhase) { + logger.debug(`\tChecking deliberation phase...`); + if (newEndDate >= deliberationPhase.startAt) { + throw new EndUserError('Consideration phase must end before deliberation phase starts.'); + } + } + + // Ensure that the consideration ends (and starts) before the Voting phase starts + const votingPhase = await this.getFundingRoundPhase(fundingRoundId, FundingRoundMI.PHASES.VOTING); + + if (votingPhase) { + logger.debug(`\tChecking voting phase...`); + if (newEndDate >= votingPhase.startAt) { + throw new EndUserError(`Consideration phase must end before voting phase starts, and ${newEndDate.toUTCString()} >= ${votingPhase.startAt.toUTCString()}`); + } + + + } + } else if (phase === FundingRoundMI.PHASES.DELIBERATION) { + logger.debug(`Validating deliberation phase dates for funding round ${fundingRoundId}...`); + // Esnure that the deliberation phase starts and ends after the Consideration phase ends + const considerationPhase = await this.getFundingRoundPhase(fundingRoundId, FundingRoundMI.PHASES.CONSIDERATION); + + if (considerationPhase) { + logger.debug(`\tChecking consideration phase...`); + if (newStartDate <= considerationPhase.endAt) { + throw new EndUserError('Deliberation phase must start after consideration phase ends.'); + } + } + + // Ensure that the deliberation phase starts and ends before the Voting phase starts + const votingPhase = await this.getFundingRoundPhase(fundingRoundId, FundingRoundMI.PHASES.VOTING); + + if (votingPhase) { + logger.debug(`\tChecking voting phase...`); + if (newEndDate >= votingPhase.startAt) { + throw new EndUserError('Deliberation phase must end before voting phase starts.'); + } + + } + } else if (phase === FundingRoundMI.PHASES.VOTING) { + logger.debug(`Validating voting phase dates for funding round ${fundingRoundId}...`); + // 1. Ensure that the voting phase starts and ends after the Consideration phase ends + const considerationPhase = await this.getFundingRoundPhase(fundingRoundId, FundingRoundMI.PHASES.CONSIDERATION); + + if (considerationPhase) { + logger.debug(`\tChecking consideration phase...`); + if (newStartDate <= considerationPhase.endAt) { + throw new EndUserError('Voting phase must start after consideration phase ends.'); + } + } + + // 2. Ensure that the voting phase starts and ends after the Deliberation phase ends + const deliberationPhase = await this.getFundingRoundPhase(fundingRoundId, FundingRoundMI.PHASES.DELIBERATION); + + if (deliberationPhase) { + logger.debug(`\tChecking deliberation phase...`); + if (newStartDate <= deliberationPhase.endAt) { + throw new EndUserError('Voting phase must start after deliberation phase ends.'); + } + + } + + } + } + + + static async updateFundingRoundDates( + fundingRoundId: number, + startAt: Date, + endAt: Date, + votingOpenUntil?: Date + ): Promise { + const fundingRound = await this.getFundingRoundByIdOrError(fundingRoundId); + + await this.validateFundingRoundDatesOrError(fundingRoundId, startAt, endAt, votingOpenUntil); + + await fundingRound.update({ startAt, endAt, votingOpenUntil }); + return fundingRound; + } + + static async updateFundingRoundVoteData(fundingRoundId: number, startAt: Date, endAt: Date, votingOpenUntil: Date, stakingLedgerEpoch: number): Promise { + const fundingRound: FundingRound = await this.updateFundingRoundDates(fundingRoundId, startAt, endAt, votingOpenUntil); + await fundingRound.update({ stakingLedgerEpoch }); + return fundingRound; + } + + static async updateFundingRoundPhase( + fundingRoundId: number, + phase: 'consideration' | 'deliberation' | 'voting' | 'round', + stakingLedgerEpoch: number, + startDate: Date, + endDate: Date + ): Promise { + const fundingRound = await this.getFundingRoundByIdOrError(fundingRoundId); + + if (phase === FundingRoundMI.PHASES.ROUND) { + return await this.updateFundingRoundDates(fundingRoundId, startDate, endDate, fundingRound.votingOpenUntil); + } + + await this.setFundingRoundPhase(fundingRoundId, phase, stakingLedgerEpoch, startDate, endDate); + return fundingRound; + } + + static async setTopic(fundingRoundId: number, topicId: number): Promise { + const { TopicLogic } = await import('../../admin/screens/ManageTopicLogicScreen'); + + const fundingRound = await this.getFundingRoundByIdOrError(fundingRoundId); + const topic = await TopicLogic.getByIdOrError(topicId); + + return await fundingRound.update({ topicId: topic.id }); + } } \ No newline at end of file From 6bfc50f18ce0d6706cb21e903b371bb614e7d81c Mon Sep 17 00:00:00 2001 From: Illya Gerasymchuk Date: Thu, 15 Aug 2024 21:02:11 +0100 Subject: [PATCH 05/15] feat: link #admin FundingRound actions to #funding-round-init --- .../screens/FundingRoundInitScreen.ts | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/src/channels/funding-round-init/screens/FundingRoundInitScreen.ts b/src/channels/funding-round-init/screens/FundingRoundInitScreen.ts index ee8522f..cb53e7c 100644 --- a/src/channels/funding-round-init/screens/FundingRoundInitScreen.ts +++ b/src/channels/funding-round-init/screens/FundingRoundInitScreen.ts @@ -8,6 +8,8 @@ import { FundingRound, Topic } from '../../../models'; import { InteractionProperties } from '../../../core/Interaction'; import { IHomeScreen } from '../../../types/common'; import { EndUserError } from '../../../Errors'; +import { CreateOrEditFundingRoundAction, ManageFundingRoundsScreen, ModifyFundingRoundAction, SelectTopicAction } from '../../admin/screens/ManageFundingRoundsScreen'; +import { FundingRoundPaginator } from '../../../components/FundingRoundPaginator'; const FUNDING_ROUND_ID_ARG: string = "fid"; @@ -18,13 +20,13 @@ export class FundingRoundInitScreen extends Screen implements IHomeScreen { protected permissions: Permission[] = [new ZkIgniteFacilitatorPermission()]; - public readonly createDraftFundingRoundAction: CreateDraftFundingRoundAction; - public readonly voteFundingRoundAction: VoteFundingRoundAction; + public readonly manageFundingRoundScreen: ManageFundingRoundsScreen; + + public readonly selectTopicAction: SelectTopicAction = new SelectTopicAction(this, SelectTopicAction.ID); constructor(dashboard: Dashboard, screenId: string) { super(dashboard, screenId); - this.createDraftFundingRoundAction = new CreateDraftFundingRoundAction(this, CreateDraftFundingRoundAction.ID); - this.voteFundingRoundAction = new VoteFundingRoundAction(this, VoteFundingRoundAction.ID); + this.manageFundingRoundScreen = new ManageFundingRoundsScreen(this.dashboard, ManageFundingRoundsScreen.ID); } public async renderToTextChannel(channel: TextChannel): Promise { const content: MessageCreateOptions = await this.getResponse(); @@ -33,13 +35,13 @@ export class FundingRoundInitScreen extends Screen implements IHomeScreen { } protected allSubScreens(): Screen[] { - return []; + return [ + this.manageFundingRoundScreen, + ]; } protected allActions(): Action[] { return [ - this.createDraftFundingRoundAction, - this.voteFundingRoundAction, ]; } @@ -49,11 +51,20 @@ export class FundingRoundInitScreen extends Screen implements IHomeScreen { .setTitle('💰 Funding Round Initiation') .setDescription('Here, you can ✨create new funding rounds and 🗳️vote on existing ones.'); - const createButton = this.createDraftFundingRoundAction.getComponent(); - const voteButton = this.voteFundingRoundAction.getComponent(); + + const createButton = new ButtonBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction(this.manageFundingRoundScreen.createFundingRoundAction, CreateOrEditFundingRoundAction.OPERATIONS.START, CreateOrEditFundingRoundAction.BOOLEANS.ARGUMENTS.FORCE_REPLY, CreateOrEditFundingRoundAction.BOOLEANS.TRUE_VALUE)) + .setLabel('✨ Create New Funding Round') + .setStyle(ButtonStyle.Primary); + + const editFundingRoundButton = new ButtonBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction(this.manageFundingRoundScreen.modifyFundingRoundAction, ModifyFundingRoundAction.OPERATIONS.SHOW_FUNDING_ROUNDS, FundingRoundPaginator.BOOLEAN.ARGUMENTS.FORCE_REPLY, FundingRoundPaginator.BOOLEAN.TRUE)) + .setLabel('Edit Funding Round') + .setStyle(ButtonStyle.Primary); + const row = new ActionRowBuilder() - .addComponents(createButton, voteButton); + .addComponents(createButton, editFundingRoundButton); const components = [row]; @@ -88,7 +99,7 @@ export class FundingRoundInitScreen extends Screen implements IHomeScreen { export class CreateDraftFundingRoundAction extends Action { public static readonly ID = 'createDraftFundingRound'; - private static readonly OPERATIONS = { + public static readonly OPERATIONS = { SUBMIT_CREATE_FORM: 'submitCreateForm', SHOW_SET_PHASE_DATES: 'sSpD', SUBMIT_PHASE_DATES: 'submitPhaseDates', From 381b82ca89843943def9044b075c1800511f0dfc Mon Sep 17 00:00:00 2001 From: Illya Gerasymchuk Date: Thu, 15 Aug 2024 21:02:51 +0100 Subject: [PATCH 06/15] feat: introduce reusable, linkable FundingRound Paginator component --- src/components/FundingRoundPaginator.ts | 126 ++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 src/components/FundingRoundPaginator.ts diff --git a/src/components/FundingRoundPaginator.ts b/src/components/FundingRoundPaginator.ts new file mode 100644 index 0000000..f36b6b0 --- /dev/null +++ b/src/components/FundingRoundPaginator.ts @@ -0,0 +1,126 @@ +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, Embed, EmbedBuilder, MessageActionRowComponentBuilder, StringSelectMenuBuilder } from "discord.js"; +import { FundingRoundLogic } from "../channels/admin/screens/FundingRoundLogic"; +import { Action, TrackedInteraction } from "../core/BaseClasses"; +import { EndUserError } from "../Errors"; +import { FundingRound } from "../models"; +import { PaginationComponent } from "./PaginationComponent"; +import { ArgumentOracle, CustomIDOracle } from "../CustomIDOracle"; +import { Screen } from "../core/BaseClasses"; +import logger from "../logging"; + +/** + * Reusable pagniator component for the selection of Funding Rounds. Invoke .showFundingRoundSelect() with + * the action & operation to which the fundingRoundId selection shold be passed in the customId. + */ +export class FundingRoundPaginator extends PaginationComponent { + public static readonly ID = 'fundingRoundPagination'; + public readonly action: Action; + public readonly operation: string; + public readonly args: string[]; + public readonly title: string; + public readonly customId: string; + + + public static BOOLEAN = { + TRUE: 'yes', + ARGUMENTS: { + FORCE_REPLY: 'fRp' + } + } + + constructor(screen: Screen, dstAction: Action, dstOperation: string, args: string[], title: string) { + super(screen, FundingRoundPaginator.ID); + this.action = dstAction; + this.operation = dstOperation; + this.args = args; + this.title = title; + this.customId = CustomIDOracle.addArgumentsToAction(this.action, this.operation, ...this.args); + } + + protected async getTotalPages(interaction: TrackedInteraction): Promise { + const fundingRounds = await FundingRoundLogic.getPresentAndFutureFundingRounds(); + return Math.ceil(fundingRounds.length / 25); + } + + protected async getItemsForPage(interaction: TrackedInteraction, page: number): Promise { + const fundingRounds = await FundingRoundLogic.getPresentAndFutureFundingRounds(); + return fundingRounds.slice(page * 25, (page + 1) * 25); + } + + + public async handlePagination(interaction: TrackedInteraction): Promise { + + const currentPage = this.getCurrentPage(interaction); + const totalPages = await this.getTotalPages(interaction); + const fundingRounds = await this.getItemsForPage(interaction, currentPage); + + + const selectMenu = new StringSelectMenuBuilder() + .setCustomId(this.customId) + .setPlaceholder(`Select a Funding Round`) + .addOptions(fundingRounds.map((fr: FundingRound) => ({ + label: fr.name, + value: fr.id.toString(), + description: `Budget: ${fr.budget}, Status: ${fr.status}` + }))); + + const embed = new EmbedBuilder() + .setTitle(this.title) + .setDescription(`To continue, select a Funding Round from the list below`); + + const components: ActionRowBuilder[] = [ + new ActionRowBuilder().addComponents(selectMenu) + ]; + + if (totalPages > 1) { + const paginationRow = this.getPaginationRow(interaction, currentPage, totalPages); + components.push(paginationRow); + } + + const isReply: boolean = ArgumentOracle.isArgumentEquals(interaction, FundingRoundPaginator.BOOLEAN.ARGUMENTS.FORCE_REPLY, FundingRoundPaginator.BOOLEAN.TRUE); + const data = { embeds: [embed], components }; + + if (isReply) { + await interaction.respond(data); + } else { + await interaction.update(data); + } + } + + + public allSubActions(): Action[] { + return []; + } + + public getPaginationRow(interaction: TrackedInteraction, currentPage: number, totalPages: number, ...args: string[]): ActionRowBuilder { + const row = new ActionRowBuilder(); + + if (currentPage > 0) { + const thisActionCustomId = CustomIDOracle.addArgumentsToAction(this, PaginationComponent.PAGINATION_ARG, PaginationComponent.PAGE_ARG, (currentPage - 1).toString(), ...args); + row.addComponents( + new ButtonBuilder() + .setCustomId(thisActionCustomId) + .setLabel('Previous') + .setStyle(ButtonStyle.Secondary) + ); + } + + if (currentPage < totalPages - 1) { + const thisActionCustomId = CustomIDOracle.addArgumentsToAction(this, PaginationComponent.PAGINATION_ARG, PaginationComponent.PAGE_ARG, (currentPage + 1).toString(), ...args); + row.addComponents( + new ButtonBuilder() + .setCustomId(thisActionCustomId) + .setLabel('Next') + .setStyle(ButtonStyle.Secondary) + ); + } + + return row; + } + + getComponent(...args: any[]): StringSelectMenuBuilder { + return new StringSelectMenuBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction(this, 'paginate')) + .setPlaceholder('Select a Funding Round'); + } +} \ No newline at end of file From cdc5238d1887f3bbdb31bd11102eb01dbd7fa9d3 Mon Sep 17 00:00:00 2001 From: Illya Gerasymchuk Date: Thu, 15 Aug 2024 21:08:42 +0100 Subject: [PATCH 07/15] refactor: misc small fixes --- .../CommitteeDeliberationHomeScreen.ts | 9 ++++++++- src/channels/proposals/ProposalsForumManager.ts | 6 ++++-- src/core/BaseClasses.ts | 16 +++++++++------- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/channels/deliberate/CommitteeDeliberationHomeScreen.ts b/src/channels/deliberate/CommitteeDeliberationHomeScreen.ts index fe91eae..dd6b211 100644 --- a/src/channels/deliberate/CommitteeDeliberationHomeScreen.ts +++ b/src/channels/deliberate/CommitteeDeliberationHomeScreen.ts @@ -605,8 +605,15 @@ class CommitteeDeliberationVoteAction extends Action { } const fundingRoundId: number = parseInt(fundingRoundIdRaw); + let reason: string=''; + + try { + reason = modalInteraction.fields.getField(INPUT_IDS.REASON)?.value; + } catch (error) { + reason = ''; + } + const voteRaw = CustomIDOracle.getNamedArgument(interaction.customId, 'vote'); - const reason = modalInteraction.fields.getTextInputValue(INPUT_IDS.REASON); const uri: string | undefined = modalInteraction.fields.getTextInputValue(INPUT_IDS.URI); if (!uri) { diff --git a/src/channels/proposals/ProposalsForumManager.ts b/src/channels/proposals/ProposalsForumManager.ts index 6ed4cf0..4d21558 100644 --- a/src/channels/proposals/ProposalsForumManager.ts +++ b/src/channels/proposals/ProposalsForumManager.ts @@ -145,12 +145,14 @@ export class ProposalsForumManager { } const allChannels = await guild.channels.fetch(); - logger.debug(`All channels: ${allChannels.map(channel => channel?.name).join(', ')}`); + logger.debug(`All channels: ${allChannels.map(channel => channel?.id).join(', ')}`); - const proposalChannelId: string | null = fundingRound.forumChannelId; + const proposalChannelId: string | null = fundingRound.forumChannelId.toString(); if (!proposalChannelId) { return null; } + //FIXME: ensure proposal is being fetched correcly + logger.debug(`Fetching proposal channel ${proposalChannelId}...`); const channel = await guild.channels.fetch(proposalChannelId); return channel && channel.type === ChannelType.GuildForum ? channel as ForumChannel : null; diff --git a/src/core/BaseClasses.ts b/src/core/BaseClasses.ts index a057b82..1905c88 100644 --- a/src/core/BaseClasses.ts +++ b/src/core/BaseClasses.ts @@ -18,6 +18,7 @@ export class TrackedInteraction { public readonly interaction: AnyInteraction; public interactionReplies: Message[] = []; public Context: Map = new Map(); + protected _isUpdated: boolean = false; constructor(interaction: AnyInteraction) { this.interaction = interaction; @@ -63,15 +64,15 @@ export class TrackedInteraction { } try { - - if (this.interactionReplies.length === 0) { - - const response: Message = await this.interaction.reply(args); + const followUp: boolean = this.interactionReplies.length > 0 || this._isUpdated; + if (followUp) { + const lastResponse = this.interactionReplies[this.interactionReplies.length - 1]; + const response: Message = await this.interaction.followUp(args); this.interactionReplies.push(response); return response; + } else { - const lastResponse = this.interactionReplies[this.interactionReplies.length - 1]; - const response: Message = await this.interaction.followUp(args); + const response: Message = await this.interaction.reply(args); this.interactionReplies.push(response); return response; } @@ -85,6 +86,7 @@ export class TrackedInteraction { public async update(args: any) { const parsedInteraction = InteractionProperties.toUpdateableOrUndefined(this.interaction); if (parsedInteraction) { + this._isUpdated = true; return await parsedInteraction.update(args); } else { throw new EndUserError('Interaction is not updatable, so unable to update'); @@ -414,7 +416,7 @@ export abstract class Dashboard { } } - protected getScreen(screenId: string): Screen | undefined { + public getScreen(screenId: string): Screen | undefined { return this.screens.get(screenId); } From 265558af7397da0e8ff7254d8e793e62bd40e670 Mon Sep 17 00:00:00 2001 From: Illya Gerasymchuk Date: Thu, 15 Aug 2024 21:23:02 +0100 Subject: [PATCH 08/15] feat: handle error reporting in Forum Manager --- .../proposals/ProposalsForumManager.ts | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/src/channels/proposals/ProposalsForumManager.ts b/src/channels/proposals/ProposalsForumManager.ts index 4d21558..1bed187 100644 --- a/src/channels/proposals/ProposalsForumManager.ts +++ b/src/channels/proposals/ProposalsForumManager.ts @@ -4,7 +4,7 @@ import { CustomIDOracle } from '../../CustomIDOracle'; import logger from '../../logging'; import { ProjectVotingScreen, SelectProjectAction } from '../vote/screens/ProjectVotingScreen'; import { VoteDashboard } from '../vote/VoteDashboard'; -import { EndUserError } from '../../Errors'; +import { EndUserError, NotFoundEndUserError } from '../../Errors'; import { Screen } from '../../core/BaseClasses'; export class ProposalsForumManager { @@ -12,7 +12,7 @@ export class ProposalsForumManager { public static async createThread(proposal: Proposal, fundingRound: FundingRound, screen: Screen): Promise { try { - const forumChannel = await this.getForumChannel(fundingRound); + const forumChannel = await this.getForumChannelOrError(fundingRound); if (!forumChannel) { throw new EndUserError(`Proposal forum channel not found for funding round ${fundingRound.id}`); } @@ -65,7 +65,7 @@ export class ProposalsForumManager { const fundingRound = await FundingRound.findByPk(proposal.fundingRoundId); if (!fundingRound) throw new EndUserError('Funding round not found'); - const forumChannel = await this.getForumChannel(fundingRound); + const forumChannel = await this.getForumChannelOrError(fundingRound); if (!forumChannel) { throw new EndUserError(`Proposal forum channel not found for funding round ${fundingRound.id}.`); @@ -83,7 +83,7 @@ export class ProposalsForumManager { } public static async refreshThread(proposal: Proposal, screen: Screen): Promise { - if (!proposal.forumThreadId){ + if (!proposal.forumThreadId) { logger.warn(`Proposal ${proposal.id} does not have a forum thread`); return; } @@ -97,7 +97,7 @@ export class ProposalsForumManager { const fundingRound = await FundingRound.findByPk(proposal.fundingRoundId); if (!fundingRound) throw new EndUserError('Funding round not found'); - const forumChannel = await this.getForumChannel(fundingRound); + const forumChannel = await this.getForumChannelOrError(fundingRound); if (!forumChannel) { throw new EndUserError(`Proposal forum channel not found for funding round ${fundingRound.id}`); } @@ -133,9 +133,7 @@ export class ProposalsForumManager { return new ActionRowBuilder().addComponents(button); } - private static async getForumChannel(fundingRound: FundingRound): Promise { - //if (!fundingRound.forumChannelName) return null; - + private static async getForumChannelOrError(fundingRound: FundingRound): Promise { const { client } = await import("../../bot"); const guild = client.guilds.cache.first(); @@ -146,15 +144,26 @@ export class ProposalsForumManager { const allChannels = await guild.channels.fetch(); logger.debug(`All channels: ${allChannels.map(channel => channel?.id).join(', ')}`); - + const proposalChannelId: string | null = fundingRound.forumChannelId.toString(); + if (!proposalChannelId) { - return null; + throw new NotFoundEndUserError(`Funding round ${fundingRound.id} does not have a forum channel`); } + //FIXME: ensure proposal is being fetched correcly logger.debug(`Fetching proposal channel ${proposalChannelId}...`); const channel = await guild.channels.fetch(proposalChannelId); - return channel && channel.type === ChannelType.GuildForum ? channel as ForumChannel : null; + if (!channel) { + throw new NotFoundEndUserError(`Proposal channel ${proposalChannelId} not found in server.`); + } + + if (!(channel.type === ChannelType.GuildForum)) { + throw new NotFoundEndUserError(`Proposal channel ${proposalChannelId} exists, but is not a forum channel.`); + } + + return channel as ForumChannel; + } } \ No newline at end of file From 30dc45fb1bb70232c4a6996f142e3840b8f36009 Mon Sep 17 00:00:00 2001 From: Illya Gerasymchuk Date: Thu, 15 Aug 2024 22:07:14 +0100 Subject: [PATCH 09/15] feat: add in-voting funding round paginator --- src/components/FundingRoundPaginator.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/components/FundingRoundPaginator.ts b/src/components/FundingRoundPaginator.ts index f36b6b0..99eb321 100644 --- a/src/components/FundingRoundPaginator.ts +++ b/src/components/FundingRoundPaginator.ts @@ -28,6 +28,13 @@ export class FundingRoundPaginator extends PaginationComponent { } } + /** + * Override this method to customize the list of Funding Rounds which should be presented/paginated. + */ + protected async getFundingRounds(interaction: TrackedInteraction): Promise { + return await FundingRoundLogic.getPresentAndFutureFundingRounds(); + } + constructor(screen: Screen, dstAction: Action, dstOperation: string, args: string[], title: string) { super(screen, FundingRoundPaginator.ID); this.action = dstAction; @@ -38,12 +45,12 @@ export class FundingRoundPaginator extends PaginationComponent { } protected async getTotalPages(interaction: TrackedInteraction): Promise { - const fundingRounds = await FundingRoundLogic.getPresentAndFutureFundingRounds(); + const fundingRounds = await this.getFundingRounds(interaction); return Math.ceil(fundingRounds.length / 25); } protected async getItemsForPage(interaction: TrackedInteraction, page: number): Promise { - const fundingRounds = await FundingRoundLogic.getPresentAndFutureFundingRounds(); + const fundingRounds = await this.getFundingRounds(interaction); return fundingRounds.slice(page * 25, (page + 1) * 25); } @@ -123,4 +130,10 @@ export class FundingRoundPaginator extends PaginationComponent { .setCustomId(CustomIDOracle.addArgumentsToAction(this, 'paginate')) .setPlaceholder('Select a Funding Round'); } +} + +export class InVotingFundingRoundPaginator extends FundingRoundPaginator { + protected async getFundingRounds(interaction: TrackedInteraction): Promise { + return await FundingRoundLogic.getEligibleVotingRounds(); + } } \ No newline at end of file From 2f20726eb10110c420f296176e3c585e0523b785 Mon Sep 17 00:00:00 2001 From: Illya Gerasymchuk Date: Thu, 15 Aug 2024 22:08:26 +0100 Subject: [PATCH 10/15] feat: integrate reusable FR selector and restore voting in #funding-round-init --- .../screens/FundingRoundInitScreen.ts | 65 +++++++------------ 1 file changed, 23 insertions(+), 42 deletions(-) diff --git a/src/channels/funding-round-init/screens/FundingRoundInitScreen.ts b/src/channels/funding-round-init/screens/FundingRoundInitScreen.ts index cb53e7c..4583ab1 100644 --- a/src/channels/funding-round-init/screens/FundingRoundInitScreen.ts +++ b/src/channels/funding-round-init/screens/FundingRoundInitScreen.ts @@ -9,7 +9,7 @@ import { InteractionProperties } from '../../../core/Interaction'; import { IHomeScreen } from '../../../types/common'; import { EndUserError } from '../../../Errors'; import { CreateOrEditFundingRoundAction, ManageFundingRoundsScreen, ModifyFundingRoundAction, SelectTopicAction } from '../../admin/screens/ManageFundingRoundsScreen'; -import { FundingRoundPaginator } from '../../../components/FundingRoundPaginator'; +import { FundingRoundPaginator, InVotingFundingRoundPaginator } from '../../../components/FundingRoundPaginator'; const FUNDING_ROUND_ID_ARG: string = "fid"; @@ -18,15 +18,17 @@ const PHASE_ARG: string = "ph"; export class FundingRoundInitScreen extends Screen implements IHomeScreen { public static readonly ID = 'fundingRoundInit'; + public readonly voteFundingRoundAction: VoteFundingRoundAction; + protected permissions: Permission[] = [new ZkIgniteFacilitatorPermission()]; public readonly manageFundingRoundScreen: ManageFundingRoundsScreen; - public readonly selectTopicAction: SelectTopicAction = new SelectTopicAction(this, SelectTopicAction.ID); constructor(dashboard: Dashboard, screenId: string) { super(dashboard, screenId); this.manageFundingRoundScreen = new ManageFundingRoundsScreen(this.dashboard, ManageFundingRoundsScreen.ID); + this.voteFundingRoundAction = new VoteFundingRoundAction(this, VoteFundingRoundAction.ID); } public async renderToTextChannel(channel: TextChannel): Promise { const content: MessageCreateOptions = await this.getResponse(); @@ -42,6 +44,7 @@ export class FundingRoundInitScreen extends Screen implements IHomeScreen { protected allActions(): Action[] { return [ + this.voteFundingRoundAction, ]; } @@ -62,9 +65,13 @@ export class FundingRoundInitScreen extends Screen implements IHomeScreen { .setLabel('Edit Funding Round') .setStyle(ButtonStyle.Primary); + const voteFundingRoundButton = new ButtonBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction(this.voteFundingRoundAction, VoteFundingRoundAction.OPERATIONS.SHOW_ELIGIBLE_ROUNDS, InVotingFundingRoundPaginator.BOOLEAN.ARGUMENTS.FORCE_REPLY, InVotingFundingRoundPaginator.BOOLEAN.TRUE)) + .setLabel('🗳️ Vote On Funding Round') + .setStyle(ButtonStyle.Primary); const row = new ActionRowBuilder() - .addComponents(createButton, editFundingRoundButton); + .addComponents(createButton, editFundingRoundButton, voteFundingRoundButton); const components = [row]; @@ -293,9 +300,9 @@ export class CreateDraftFundingRoundAction extends Action { { name: 'Description', value: fundingRound.description }, { name: 'Budget', value: fundingRound.budget.toString() }, { name: 'Staking Ledger Epoch Number (For Voting)', value: fundingRound.stakingLedgerEpoch.toString() }, - { name: 'Voting Open Until', value: fundingRound.votingOpenUntil.toISOString() }, - { name: 'Start Date', value: fundingRound.startAt ? fundingRound.startAt.toISOString() : '❌ Not Set' }, - { name: 'End Date', value: fundingRound.startAt ? fundingRound.endAt.toISOString() : '❌ Not Set' }, + { name: 'Voting Open Until', value: fundingRound.votingOpenUntil.toUTCString() }, + { name: 'Start Date', value: fundingRound.startAt ? fundingRound.startAt.toUTCString() : '❌ Not Set' }, + { name: 'End Date', value: fundingRound.startAt ? fundingRound.endAt.toUTCString() : '❌ Not Set' }, ); const phases = await FundingRoundLogic.getFundingRoundPhases(fundingRoundId); @@ -309,7 +316,7 @@ export class CreateDraftFundingRoundAction extends Action { buttons.push(setRoundDurationButton); } else { - embed.addFields({ name: 'Round Duration', value: `Start: ${fundingRound.startAt.toISOString()}\nEnd: ${fundingRound.endAt.toISOString()}` }); + embed.addFields({ name: 'Round Duration', value: `Start: ${fundingRound.startAt.toUTCString()}\nEnd: ${fundingRound.endAt.toUTCString()}` }); } for (let phaseName of Object.values(CreateDraftFundingRoundAction.NON_ROUND_PHASE_NAMES)) { @@ -322,7 +329,7 @@ export class CreateDraftFundingRoundAction extends Action { .setStyle(ButtonStyle.Primary); buttons.push(button); } else { - embed.addFields({ name: `${phaseName.charAt(0).toUpperCase() + phaseName.slice(1)} Phase`, value: `Start: ${phase.startDate.toISOString()}\nEnd: ${phase.endDate.toISOString()}` }); + embed.addFields({ name: `${phaseName.charAt(0).toUpperCase() + phaseName.slice(1)} Phase`, value: `Start: ${phase.startDate.toUTCString()}\nEnd: ${phase.endDate.toUTCString()}` }); } } @@ -436,6 +443,8 @@ 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.BOOLEAN.ARGUMENTS.FORCE_REPLY, InVotingFundingRoundPaginator.BOOLEAN.TRUE], "Select A Funding Rount To Voet On") public static readonly OPERATIONS = { SHOW_ELIGIBLE_ROUNDS: 'showEligibleRounds', @@ -486,32 +495,7 @@ export class VoteFundingRoundAction extends PaginationComponent { } private async handleShowEligibleRounds(interaction: TrackedInteraction): Promise { - const currentPage = this.getCurrentPage(interaction); - const totalPages = await this.getTotalPages(interaction); - const eligibleRounds = await this.getItemsForPage(interaction, currentPage); - - if (eligibleRounds.length === 0) { - throw new EndUserError('There are no eligible funding rounds for voting at this time.'); - } - - const selectMenu = new StringSelectMenuBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, VoteFundingRoundAction.OPERATIONS.SELECT_ROUND)) - .setPlaceholder('🗳️ Select a Funding Round to Vote On') - .addOptions(eligibleRounds.map(round => ({ - label: round.name, - value: round.id.toString(), - description: `Budget: ${round.budget}, Voting Until: ${round.votingOpenUntil.toLocaleDateString()}` - }))); - - 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.respond({ components, ephemeral: true }); + await this.editFundingRoundPaginator.handlePagination(interaction); } private async handleSelectRound(interaction: TrackedInteraction, successMessage: string | undefined = undefined, errorMesasge: string | undefined = undefined): Promise { @@ -556,15 +540,15 @@ export class VoteFundingRoundAction extends PaginationComponent { .addFields( { name: 'Budget', value: fundingRound.budget.toString(), inline: true }, { name: 'Staking Ledger Epoch Number (For Voting)', value: fundingRound.stakingLedgerEpoch.toString(), inline: true }, - { name: 'Voting Open Until', value: fundingRound.votingOpenUntil.toISOString(), inline: true }, + { name: 'Voting Open Until', value: fundingRound.votingOpenUntil.toUTCString(), inline: true }, { name: 'Status', value: fundingRound.status, inline: true }, - { name: 'Start Date', value: fundingRound.startAt ? fundingRound.startAt.toISOString() : '❌ Not Set', inline: true }, - { name: 'End Date', value: fundingRound.endAt ? fundingRound.endAt.toISOString() : '❌ Not Set', inline: true }, + { name: 'Start Date', value: fundingRound.startAt ? fundingRound.startAt.toUTCString() : '❌ Not Set', inline: true }, + { name: 'End Date', value: fundingRound.endAt ? fundingRound.endAt.toUTCString() : '❌ Not Set', inline: true }, ); const phases = await FundingRoundLogic.getFundingRoundPhases(fundingRoundId); phases.forEach(phase => { - embed.addFields({ name: `${phase.phase.charAt(0).toUpperCase() + phase.phase.slice(1)} Phase`, value: `Start: ${phase.startDate.toISOString()}\nEnd: ${phase.endDate.toISOString()}`, inline: true }); + embed.addFields({ name: `${phase.phase.charAt(0).toUpperCase() + phase.phase.slice(1)} Phase`, value: `Start: ${phase.startDate.toUTCString()}\nEnd: ${phase.endDate.toUTCString()}`, inline: true }); }); const latestVote = await FundingRoundLogic.getLatestVote(interaction.interaction.user.id, fundingRoundId); @@ -695,10 +679,7 @@ export class VoteFundingRoundAction extends PaginationComponent { } getComponent(): ButtonBuilder { - return new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, VoteFundingRoundAction.OPERATIONS.SHOW_ELIGIBLE_ROUNDS)) - .setLabel('🗳️ Vote on Existing Funding Round') - .setStyle(ButtonStyle.Primary); + throw new EndUserError('VoteFundingRoundAction has no components'); } public async handlePagination(interaction: TrackedInteraction): Promise { From 4418c72fe0330b5250f51dd564b8e27cf5c3f78a Mon Sep 17 00:00:00 2001 From: Illya Gerasymchuk Date: Fri, 16 Aug 2024 17:07:05 +0100 Subject: [PATCH 11/15] feat: improvements & bug fixes in various components --- .../admin/screens/FundingRoundLogic.ts | 13 ++- .../screens/ManageFundingRoundsScreen.ts | 20 ++--- .../screens/ManageProposalStatusesScreen.ts | 75 +++++++++-------- .../CommitteeDeliberationHomeScreen.ts | 45 ++++++----- .../screens/FundingRoundInitScreen.ts | 2 +- .../proposals/ProposalsForumManager.ts | 25 +++++- .../propose/screens/ProposalHomeScreen.ts | 52 +++--------- .../vote/screens/ProjectVotingScreen.ts | 80 +++++++++---------- 8 files changed, 149 insertions(+), 163 deletions(-) diff --git a/src/channels/admin/screens/FundingRoundLogic.ts b/src/channels/admin/screens/FundingRoundLogic.ts index c94c419..fc38385 100644 --- a/src/channels/admin/screens/FundingRoundLogic.ts +++ b/src/channels/admin/screens/FundingRoundLogic.ts @@ -353,20 +353,17 @@ export class FundingRoundLogic { static async getEligibleVotingRounds(): Promise { const now = new Date(); - return await FundingRound.findAll({ + const allFindingRoundsInVoting = await FundingRound.findAll({ where: { status: FundingRoundStatus.VOTING, votingOpenUntil: { [Op.gte]: now, }, }, - include: [ - { model: ConsiderationPhase, required: true, as: 'considerationPhase' }, - { model: DeliberationPhase, required: true, as: 'deliberationPhase' }, - { model: FundingVotingPhase, required: true, as: 'fundingVotingPhase' }, - ], - }); - } + }); + const onlyReadyFundingRounds = allFindingRoundsInVoting.filter( value => value.isReady()) + return onlyReadyFundingRounds; +} static async hasUserVotedOnFundingRound(userId: string, fundingRoundId: number): Promise { const vote = await FundingRoundApprovalVote.findOne({ diff --git a/src/channels/admin/screens/ManageFundingRoundsScreen.ts b/src/channels/admin/screens/ManageFundingRoundsScreen.ts index a4cfc12..8864705 100644 --- a/src/channels/admin/screens/ManageFundingRoundsScreen.ts +++ b/src/channels/admin/screens/ManageFundingRoundsScreen.ts @@ -13,7 +13,7 @@ import { DiscordStatus } from '../../DiscordStatus'; import { FundingRoundMI, FundingRoundMIPhaseValue } from '../../../models/Interface'; import { InputDate } from '../../../dates/Input'; import { ExclusionConstraintError } from 'sequelize'; -import { FundingRoundPaginator } from '../../../components/FundingRoundPaginator'; +import { ApproveRejectFundingRoundPaginator, EditFundingRoundPaginator, FundingRoundPaginator, RemoveCommiteeFundingRoundPaginator, SetCommitteeFundingRoundPaginator } from '../../../components/FundingRoundPaginator'; @@ -37,10 +37,10 @@ export class ManageFundingRoundsScreen extends Screen { public readonly setPhaseAction: SetPhaseAction; public readonly selectForumChannelAction: SelectForumChannelAction; - public readonly crudFRPaginatorAction: FundingRoundPaginator; - public readonly committeeFRPaginator: FundingRoundPaginator; - public readonly committeeDeleteFRPaginator: FundingRoundPaginator; - public readonly approveRejectFRPaginator: FundingRoundPaginator; + public readonly crudFRPaginatorAction: EditFundingRoundPaginator; + public readonly committeeFRPaginator: SetCommitteeFundingRoundPaginator; + public readonly committeeDeleteFRPaginator: RemoveCommiteeFundingRoundPaginator; + public readonly approveRejectFRPaginator: ApproveRejectFundingRoundPaginator; constructor(dashboard: Dashboard, screenId: string) { super(dashboard, screenId); @@ -60,10 +60,10 @@ export class ManageFundingRoundsScreen extends Screen { this.setPhaseAction = new SetPhaseAction(this, SetPhaseAction.ID); this.selectForumChannelAction = new SelectForumChannelAction(this, SelectForumChannelAction.ID); - this.crudFRPaginatorAction = new FundingRoundPaginator(this, this.createFundingRoundAction, CreateOrEditFundingRoundAction.OPERATIONS.SHOW_PROGRESS, [], 'Select a Funding Round To Edit'); - this.committeeFRPaginator = new FundingRoundPaginator(this, this.setFundingRoundCommitteeAction , SetFundingRoundCommitteeAction.OPERATIONS.SELECT_FUNDING_ROUND, [], "Select a Funding Round To Manage Committee") - this.committeeDeleteFRPaginator = new FundingRoundPaginator(this, this.removeFundingRoundCommitteeAction, RemoveFundingRoundCommitteeAction.OPERATIONS.SELECT_FUNDING_ROUND, [], "Select a Funding Round To Remove Committee") - this.approveRejectFRPaginator = new FundingRoundPaginator(this, this.approveFundingRoundAction, ApproveFundingRoundAction.OPERATIONS.SELECT_FUNDING_ROUND, [], "Select a Funding Round To Approve/Reject") + this.crudFRPaginatorAction = new EditFundingRoundPaginator(this, this.createFundingRoundAction, CreateOrEditFundingRoundAction.OPERATIONS.SHOW_PROGRESS, EditFundingRoundPaginator.ID); + this.committeeFRPaginator = new SetCommitteeFundingRoundPaginator(this, this.setFundingRoundCommitteeAction , SetFundingRoundCommitteeAction.OPERATIONS.SELECT_FUNDING_ROUND, SetCommitteeFundingRoundPaginator.ID); + this.committeeDeleteFRPaginator = new RemoveCommiteeFundingRoundPaginator(this, this.removeFundingRoundCommitteeAction, RemoveFundingRoundCommitteeAction.OPERATIONS.SELECT_FUNDING_ROUND, RemoveCommiteeFundingRoundPaginator.ID); + this.approveRejectFRPaginator = new ApproveRejectFundingRoundPaginator(this, this.approveFundingRoundAction, ApproveFundingRoundAction.OPERATIONS.SELECT_FUNDING_ROUND, ApproveRejectFundingRoundPaginator.ID); } protected allSubScreens(): Screen[] { @@ -855,7 +855,7 @@ export class ModifyFundingRoundAction extends Action { getComponent(): ButtonBuilder { return new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, ModifyFundingRoundAction.OPERATIONS.SHOW_FUNDING_ROUNDS)) + .setCustomId(CustomIDOracle.addArgumentsToAction(this, ModifyFundingRoundAction.OPERATIONS.SHOW_FUNDING_ROUNDS, FundingRoundPaginator.BOOLEAN.ARGUMENTS.FORCE_REPLY, FundingRoundPaginator.BOOLEAN.TRUE)) .setLabel('Edit Funding Round') .setStyle(ButtonStyle.Primary); } diff --git a/src/channels/admin/screens/ManageProposalStatusesScreen.ts b/src/channels/admin/screens/ManageProposalStatusesScreen.ts index f84ad36..2978d2c 100644 --- a/src/channels/admin/screens/ManageProposalStatusesScreen.ts +++ b/src/channels/admin/screens/ManageProposalStatusesScreen.ts @@ -150,9 +150,9 @@ export class SelectProposalAction extends PaginationComponent { selectProposal: 'selectProposal' } - protected async getTotalPages(interaction: TrackedInteraction, frId?:string): Promise { + protected async getTotalPages(interaction: TrackedInteraction, frId?: string): Promise { let fundingRoundId = CustomIDOracle.getNamedArgument(interaction.customId, 'fundingRoundId'); - + if (frId) { fundingRoundId = frId.toString(); } @@ -165,7 +165,7 @@ export class SelectProposalAction extends PaginationComponent { return Math.ceil(proposals.length / 25); } - protected async getItemsForPage(interaction: TrackedInteraction, page: number, frId?:string): Promise { + protected async getItemsForPage(interaction: TrackedInteraction, page: number, frId?: string): Promise { let fundingRoundId = CustomIDOracle.getNamedArgument(interaction.customId, 'fundingRoundId'); @@ -281,12 +281,12 @@ export class UpdateProposalStatusAction extends Action { } } - public async renderShowStatusOptions(interaction: TrackedInteraction, pId?:string): Promise { - + 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; + + const proposalId: string | undefined = pId || proposalIdFromCntx || proposalIdFromCustomId; if (!proposalId) { await DiscordStatus.Error.error(interaction, 'Proposal ID not found in customId, context or arg.'); @@ -324,7 +324,7 @@ export class UpdateProposalStatusAction extends Action { const row = new ActionRowBuilder().addComponents(selectMenu); await interaction.update({ embeds: [embed], components: [row] }); - + } private async handleShowStatusOptions(interaction: TrackedInteraction): Promise { @@ -350,38 +350,35 @@ export class UpdateProposalStatusAction extends Action { const newStatus = parsedInteraction.values[0] as ProposalStatus; - try { - 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 selectPropAction: SelectProposalAction = (this.screen as ManageProposalStatusesScreen).selectProposalAction; - - 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 backButton = new ButtonBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(selectPropAction, SelectProposalAction.OPERATIONS.showProposals, 'fundingRoundId', updatedProposal.fundingRoundId.toString())) - .setLabel('Update Status Again') - .setStyle(ButtonStyle.Primary); - - const row = new ActionRowBuilder().addComponents(backButton); - - await interaction.update({ embeds: [embed], components: [row] }); - } catch (error) { - await DiscordStatus.Error.error(interaction, `Failed to update proposal status: ${error instanceof Error ? error.message : 'Unknown error'}`); + 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 selectPropAction: SelectProposalAction = (this.screen as ManageProposalStatusesScreen).selectProposalAction; + + 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 backButton = new ButtonBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction(selectPropAction, SelectProposalAction.OPERATIONS.showProposals, 'fundingRoundId', 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[] { diff --git a/src/channels/deliberate/CommitteeDeliberationHomeScreen.ts b/src/channels/deliberate/CommitteeDeliberationHomeScreen.ts index dd6b211..6dcdd98 100644 --- a/src/channels/deliberate/CommitteeDeliberationHomeScreen.ts +++ b/src/channels/deliberate/CommitteeDeliberationHomeScreen.ts @@ -605,12 +605,12 @@ class CommitteeDeliberationVoteAction extends Action { } const fundingRoundId: number = parseInt(fundingRoundIdRaw); - let reason: string=''; + let reason: string = ''; try { - reason = modalInteraction.fields.getField(INPUT_IDS.REASON)?.value; + reason = modalInteraction.fields.getField(INPUT_IDS.REASON).value; } catch (error) { - reason = ''; + reason = ''; } const voteRaw = CustomIDOracle.getNamedArgument(interaction.customId, 'vote'); @@ -632,28 +632,29 @@ class CommitteeDeliberationVoteAction extends Action { description = 'Your vote to approve the project with modifications has been recorded. You can change it until the end of the deliberation phase.'; vote = CommitteeDeliberationVoteChoice.APPROVED_MODIFIED; } else { - await interaction.respond({ content: `Invalid vote option: ${voteRaw}`, ephemeral: true }); - return; + throw new EndUserError(`Invalid vote option: ${voteRaw}`); } - try { - await CommitteeDeliberationLogic.submitVote(interaction.interaction.user.id, projectId, fundingRoundId, vote, reason, uri); - - const embed = new EmbedBuilder() - .setColor('#28a745') - .setTitle('Vote Submitted Successfully') - .setDescription(description) - .addFields( - { name: 'Project ID', value: projectId.toString() }, - { name: 'Funding Round ID', value: fundingRoundId.toString() }, - { name: 'Decision', value: vote }, - { name: 'Reason', value: reason }, - { name: 'URI', value: uri }, - ); - await interaction.update({ embeds: [embed], ephemeral: true }); - } catch (error) { - throw new EndUserError(`Error submitting vote`, error); + await CommitteeDeliberationLogic.submitVote(interaction.interaction.user.id, projectId, fundingRoundId, vote, reason, uri); + + const embed = new EmbedBuilder() + .setColor('#28a745') + .setTitle('Vote Submitted Successfully') + .setDescription(description) + .addFields( + { name: 'Project ID', value: projectId.toString() }, + { name: 'Funding Round ID', value: fundingRoundId.toString() }, + { name: 'Decision', value: vote }, + { name: 'URI', value: uri }, + ); + + if (reason) { + embed.addFields( + {name: 'Reason', value: reason} + ) } + await interaction.update({ embeds: [embed], ephemeral: true }); + } public allSubActions(): Action[] { diff --git a/src/channels/funding-round-init/screens/FundingRoundInitScreen.ts b/src/channels/funding-round-init/screens/FundingRoundInitScreen.ts index 4583ab1..1bbf3b2 100644 --- a/src/channels/funding-round-init/screens/FundingRoundInitScreen.ts +++ b/src/channels/funding-round-init/screens/FundingRoundInitScreen.ts @@ -444,7 +444,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.BOOLEAN.ARGUMENTS.FORCE_REPLY, InVotingFundingRoundPaginator.BOOLEAN.TRUE], "Select A Funding Rount To Voet On") + public readonly editFundingRoundPaginator: InVotingFundingRoundPaginator = new InVotingFundingRoundPaginator(this.screen, this, VoteFundingRoundAction.OPERATIONS.SELECT_ROUND, InVotingFundingRoundPaginator.ID); public static readonly OPERATIONS = { SHOW_ELIGIBLE_ROUNDS: 'showEligibleRounds', diff --git a/src/channels/proposals/ProposalsForumManager.ts b/src/channels/proposals/ProposalsForumManager.ts index 1bed187..5e4e859 100644 --- a/src/channels/proposals/ProposalsForumManager.ts +++ b/src/channels/proposals/ProposalsForumManager.ts @@ -1,11 +1,26 @@ import { ForumChannel, ThreadChannel, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, ChannelType } from 'discord.js'; import { FundingRound, Proposal } from '../../models'; -import { CustomIDOracle } from '../../CustomIDOracle'; +import { ArgumentOracle, CustomIDOracle } from '../../CustomIDOracle'; import logger from '../../logging'; import { ProjectVotingScreen, SelectProjectAction } from '../vote/screens/ProjectVotingScreen'; import { VoteDashboard } from '../vote/VoteDashboard'; import { EndUserError, NotFoundEndUserError } from '../../Errors'; import { Screen } from '../../core/BaseClasses'; +import { ProposalLogic } from '../../logic/ProposalLogic'; +import { ProposalStatus } from '../../types'; + +export function proposalStatusToPhase(status: ProposalStatus): string { + switch (status) { + case (ProposalStatus.CONSIDERATION_PHASE): + return "consideration"; + case (ProposalStatus.DELIBERATION_PHASE): + return "deliberation"; + case (ProposalStatus.FUNDING_VOTING_PHASE): + return "funding"; + default: + throw new EndUserError(`Invalid proposal voting phase: ${status.toString()}`) + } +} export class ProposalsForumManager { private static readonly VOTE_BUTTON_ID = 'vote_button'; @@ -32,7 +47,7 @@ export class ProposalsForumManager { public static async updateThreadContent(thread: ThreadChannel, proposal: Proposal, fundingRound: FundingRound, screen: Screen): Promise { try { const embed = this.createProposalEmbed(proposal); - const voteButton = this.createVoteButton(proposal.id, fundingRound.id, screen); + const voteButton = await this.createVoteButton(proposal.id, fundingRound.id, screen); const messages = await thread.messages.fetch({ limit: 1 }); const firstMessage = messages.first(); @@ -123,8 +138,10 @@ export class ProposalsForumManager { .setColor('#0099ff'); } - private static createVoteButton(proposalId: number, fundingRoundId: number, screen: any): ActionRowBuilder { - const customId: string = CustomIDOracle.customIdFromRawParts(VoteDashboard.ID, ProjectVotingScreen.ID, SelectProjectAction.ID, SelectProjectAction.OPERATIONS.selectProject, "projectId", proposalId.toString(), "fundingRoundId", fundingRoundId.toString(), "phase", "deliberation"); + public static async createVoteButton(proposalId: number, fundingRoundId: number, screen: any): Promise> { + 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 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 8b60a05..968b09c 100644 --- a/src/channels/propose/screens/ProposalHomeScreen.ts +++ b/src/channels/propose/screens/ProposalHomeScreen.ts @@ -1,6 +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 { CustomIDOracle } from '../../../CustomIDOracle'; +import { ArgumentOracle, CustomIDOracle } from '../../../CustomIDOracle'; import { AnyInteractionWithShowModal, AnyInteractionWithValues, IHomeScreen } from '../../../types/common'; import { ProposalLogic } from '../../../logic/ProposalLogic'; import { InteractionProperties } from '../../../core/Interaction'; @@ -10,6 +10,8 @@ import { FundingRound, Proposal } from '../../../models'; import { ProposalStatus } from '../../../types'; import { EndUserError, EndUserInfo } from '../../../Errors'; import { DiscordStatus } from '../../DiscordStatus'; +import logger from '../../../logging'; +import { EditMySubmittedProposalsPaginator } from '../../../components/ProposalsPaginator'; export class ProposalHomeScreen extends Screen implements IHomeScreen { @@ -96,7 +98,9 @@ export class ProposalHomeScreen extends Screen implements IHomeScreen { export class ManageSubmittedProposalsAction extends PaginationComponent { public static readonly ID: string = 'manageSubmittedProposals'; - private static readonly OPERATIONS = { + + 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', @@ -105,6 +109,8 @@ export class ManageSubmittedProposalsAction extends PaginationComponent { 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); @@ -177,45 +183,12 @@ export class ManageSubmittedProposalsAction extends PaginationComponent { } const fundingRoundId: number = parseInt(interactionWithValues.values[0]); - await this.handleShowProposals(interaction, fundingRoundId); + interaction.Context.set(ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, fundingRoundId.toString()); + await this.handleShowProposals(interaction); } - private async handleShowProposals(interaction: TrackedInteraction, fundingRoundId?: number): Promise { - if (!fundingRoundId) { - fundingRoundId = parseInt(CustomIDOracle.getNamedArgument(interaction.customId, 'fundingRoundId') || ''); - } - - if (!fundingRoundId) { - throw new EndUserError('Invalid funding round ID.'); - } - - const fundingRound: FundingRound | null = await FundingRoundLogic.getFundingRoundById(fundingRoundId); - if (!fundingRound) { - throw new EndUserError('Funding round not found.'); - } - - const proposals: Proposal[] = await ProposalLogic.getUserProposalsForFundingRound(interaction.interaction.user.id, fundingRoundId); - const eligibleProposals: Proposal[] = proposals.filter((p: Proposal) => - FundingRoundLogic.isProposalActiveForFundingRound(p, fundingRound) - ); - - const embed: EmbedBuilder = new EmbedBuilder() - .setColor('#0099ff') - .setTitle(`Select ${fundingRound.name} Proposals`) - .setDescription('To proceed please select a proposal from the list below.'); - - const selectMenu: StringSelectMenuBuilder = new StringSelectMenuBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, ManageSubmittedProposalsAction.OPERATIONS.SHOW_PROPOSAL_DETAILS, 'fundingRoundId', fundingRoundId.toString())) - .setPlaceholder('Select a Proposal') - .addOptions(eligibleProposals.map((p: Proposal) => ({ - label: p.name, - value: p.id.toString(), - description: `ID: ${p.id} Budget: ${p.budget}, Status: ${p.status}` - }))); - - const row: ActionRowBuilder = new ActionRowBuilder().addComponents(selectMenu); - - await interaction.respond({ embeds: [embed], components: [row] }); + private async handleShowProposals(interaction: TrackedInteraction): Promise { + await this.editSubmittedProposalsPaginator.handlePagination(interaction) } private async handleShowProposalDetails(interaction: TrackedInteraction): Promise { @@ -986,6 +959,7 @@ export class SubmitProposalToFundingRoundAction extends Action { await interaction.update({ embeds: [embed], components: [row] }); } catch (error) { + logger.error(error); throw new EndUserError('Failed to submit proposal', error); } } diff --git a/src/channels/vote/screens/ProjectVotingScreen.ts b/src/channels/vote/screens/ProjectVotingScreen.ts index a60f0c6..82bcbc2 100644 --- a/src/channels/vote/screens/ProjectVotingScreen.ts +++ b/src/channels/vote/screens/ProjectVotingScreen.ts @@ -13,6 +13,9 @@ 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'; @@ -164,7 +167,7 @@ export class SelectProjectAction extends PaginationComponent { let fundingRoundId: number = parseInt(fundingRoundIdRaw); - let phase: string = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.PHASE); + let phase: string = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.PHASE); phase = phase.toLowerCase(); const projects = await FundingRoundLogic.getActiveProposalsForPhase(fundingRoundId, phase); @@ -176,9 +179,9 @@ export class SelectProjectAction extends PaginationComponent { let fundingRoundId: number = parseInt(fundingRoundIdRaw); - - const phase: string = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.PHASE); + + 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); @@ -209,7 +212,7 @@ export class SelectProjectAction extends PaginationComponent { .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 => ({ @@ -223,15 +226,15 @@ export class SelectProjectAction extends PaginationComponent { .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); - } + 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 } + return { embeds, components, ephemeral: true } } private async handleShowProjects(interaction: TrackedInteraction): Promise { @@ -270,24 +273,14 @@ export class SelectProjectAction extends PaginationComponent { } private async handleSelectProject(interaction: TrackedInteraction): Promise { - const projectId = interaction.getFromValuesCustomIdOrContext(0, "projectId"); - if (!projectId) { - await DiscordStatus.Error.error(interaction, 'projectId argument not provided'); - return; - } + const projectId = ArgumentOracle.getNamedArgument(interaction, 'projectId') - const fundingRoundIdFromCI: string | undefined = CustomIDOracle.getNamedArgument(interaction.customId, 'fundingRoundId'); + const fundingRoundIdFromCI: string = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID); - if (!fundingRoundIdFromCI) { - throw new EndUserError('fundingRoundId not passed in customId'); - } const fundingRoundId: number = parseInt(fundingRoundIdFromCI); - const phase = CustomIDOracle.getNamedArgument(interaction.customId, 'phase'); - - if (!phase) { - throw new EndUserError('Phase not passed in cutomId'); - } + const phase = ArgumentOracle.getNamedArgument(interaction, 'phase'); + interaction.Context.set('projectId', projectId.toString()); interaction.Context.set('fundingRoundId', fundingRoundId.toString()); @@ -342,15 +335,22 @@ class VoteProjectAction extends Action { } private async handleShowVoteOptions(interaction: TrackedInteraction, args: { projectId: number, fundingRoundId: number, phase: string }): Promise { - const { projectId, fundingRoundId, phase } = args; - const project = await ProposalLogic.getProposalById(projectId); + const { projectId, fundingRoundId} = args; + const project = await ProposalLogic.getProposalByIdOrError(projectId); logger.info(`Funding Round ID: ${fundingRoundId}`); - const fundingRound = await FundingRoundLogic.getFundingRoundById(fundingRoundId); - if (!project || !fundingRound) { - throw new EndUserError('Project or Funding Round not found.'); + 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"); } + 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'; @@ -359,7 +359,7 @@ class VoteProjectAction extends Action { // assuming consideration phase let description: string = ` Voting Stage: 1️⃣/3️⃣ - Current Phase: ${phase} Next Phase: deliberation + Current Phase: ${proposalPhase} Next Phase: deliberation Here, you vote on the project's approval or rejection for the deliberation phase. Votes can be changed until the end of the voting period. @@ -371,11 +371,11 @@ class VoteProjectAction extends Action { The voting is done on-chain. Click the button below to vote. ` - - if (phase === 'deliberation') { + + if (proposalPhase === 'deliberation') { description = ` Voting Stage: 2️⃣/3️⃣ - Current Phase: ${phase} Previous Phase: consideration Next Phase: funding + 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. @@ -383,10 +383,10 @@ class VoteProjectAction extends Action { ` } - if (phase === 'funding') { + if (proposalPhase === 'funding') { description = ` Voting Stage: 3️⃣/3️⃣ - Curernt Phase: ${phase} Previous Phase: deliberation + Curernt Phase: ${proposalPhase} Previous Phase: deliberation ⚠️ This is the final voting stage. The votes in this stage decide which projects will be funded. @@ -414,11 +414,11 @@ class VoteProjectAction extends Action { { name: 'Proposer Discord ID', value: project.proposerDuid, inline: true } ); - let components: ActionRowBuilder[] = []; - switch (phase.toLowerCase()) { + let components: ActionRowBuilder[] = []; + switch (proposalPhase.toLowerCase()) { case 'consideration': case 'funding': - const voteLink = OCVLinkGenerator.generateProjectVoteLink(projectId, phase); + const voteLink = OCVLinkGenerator.generateProjectVoteLink(projectId, proposalPhase); const voteButton = new ButtonBuilder() .setLabel('🗳️ Vote On-Chain') .setStyle(ButtonStyle.Link) From 7d3e44373576c54e57d11df162ba426e03cc94ce Mon Sep 17 00:00:00 2001 From: Illya Gerasymchuk Date: Fri, 16 Aug 2024 17:07:31 +0100 Subject: [PATCH 12/15] feat: introduce automated pagination compoennts via actions --- src/components/FundingRoundPaginator.ts | 143 +++++++----------------- src/components/PaginationComponent.ts | 128 ++++++++++++++++++++- src/components/ProposalsPaginator.ts | 43 +++++++ 3 files changed, 211 insertions(+), 103 deletions(-) create mode 100644 src/components/ProposalsPaginator.ts diff --git a/src/components/FundingRoundPaginator.ts b/src/components/FundingRoundPaginator.ts index 99eb321..14ca6cf 100644 --- a/src/components/FundingRoundPaginator.ts +++ b/src/components/FundingRoundPaginator.ts @@ -1,24 +1,16 @@ -import { ActionRowBuilder, ButtonBuilder, ButtonStyle, Embed, EmbedBuilder, MessageActionRowComponentBuilder, StringSelectMenuBuilder } from "discord.js"; import { FundingRoundLogic } from "../channels/admin/screens/FundingRoundLogic"; -import { Action, TrackedInteraction } from "../core/BaseClasses"; -import { EndUserError } from "../Errors"; +import { TrackedInteraction } from "../core/BaseClasses"; import { FundingRound } from "../models"; -import { PaginationComponent } from "./PaginationComponent"; -import { ArgumentOracle, CustomIDOracle } from "../CustomIDOracle"; -import { Screen } from "../core/BaseClasses"; -import logger from "../logging"; +import { ORMModelPaginator, PaginationComponent } from "./PaginationComponent"; /** * Reusable pagniator component for the selection of Funding Rounds. Invoke .showFundingRoundSelect() with * the action & operation to which the fundingRoundId selection shold be passed in the customId. */ -export class FundingRoundPaginator extends PaginationComponent { - public static readonly ID = 'fundingRoundPagination'; - public readonly action: Action; - public readonly operation: string; - public readonly args: string[]; - public readonly title: string; - public readonly customId: string; + +export abstract class FundingRoundPaginator extends ORMModelPaginator { + public readonly description: string = 'To continue, select a Funding Round from the list below'; + public readonly placeholder: string = 'Select a Funding Round'; public static BOOLEAN = { @@ -31,109 +23,58 @@ export class FundingRoundPaginator extends PaginationComponent { /** * Override this method to customize the list of Funding Rounds which should be presented/paginated. */ - protected async getFundingRounds(interaction: TrackedInteraction): Promise { + public async getItems(interaction: TrackedInteraction): Promise { return await FundingRoundLogic.getPresentAndFutureFundingRounds(); + } + + protected async getOptions(interaction: TrackedInteraction, items: FundingRound[]): Promise { + return items.map((fr: FundingRound) => ({ + label: fr.name, + value: fr.id.toString(), + description: `Budget: ${fr.budget}, Status: ${fr.status}` + })); } - constructor(screen: Screen, dstAction: Action, dstOperation: string, args: string[], title: string) { - super(screen, FundingRoundPaginator.ID); - this.action = dstAction; - this.operation = dstOperation; - this.args = args; - this.title = title; - this.customId = CustomIDOracle.addArgumentsToAction(this.action, this.operation, ...this.args); - } - - protected async getTotalPages(interaction: TrackedInteraction): Promise { - const fundingRounds = await this.getFundingRounds(interaction); - return Math.ceil(fundingRounds.length / 25); - } - - protected async getItemsForPage(interaction: TrackedInteraction, page: number): Promise { - const fundingRounds = await this.getFundingRounds(interaction); - return fundingRounds.slice(page * 25, (page + 1) * 25); - } - - - public async handlePagination(interaction: TrackedInteraction): Promise { - - const currentPage = this.getCurrentPage(interaction); - const totalPages = await this.getTotalPages(interaction); - const fundingRounds = await this.getItemsForPage(interaction, currentPage); +} +export class EditFundingRoundPaginator extends FundingRoundPaginator { + public static readonly ID = 'editFRPag'; - const selectMenu = new StringSelectMenuBuilder() - .setCustomId(this.customId) - .setPlaceholder(`Select a Funding Round`) - .addOptions(fundingRounds.map((fr: FundingRound) => ({ - label: fr.name, - value: fr.id.toString(), - description: `Budget: ${fr.budget}, Status: ${fr.status}` - }))); + public args: string[] = [] + public title: string = "Select A Funding Round To Edit"; +} - const embed = new EmbedBuilder() - .setTitle(this.title) - .setDescription(`To continue, select a Funding Round from the list below`); +export class SetCommitteeFundingRoundPaginator extends FundingRoundPaginator { + public args: string[] = []; + public title: string = "Select A Funding Round To Set Committee For"; + public static readonly ID = 'setComFRPag'; - const components: ActionRowBuilder[] = [ - new ActionRowBuilder().addComponents(selectMenu) - ]; + +} - if (totalPages > 1) { - const paginationRow = this.getPaginationRow(interaction, currentPage, totalPages); - components.push(paginationRow); - } +export class RemoveCommiteeFundingRoundPaginator extends FundingRoundPaginator { + public static readonly ID = 'remComFRPag'; - const isReply: boolean = ArgumentOracle.isArgumentEquals(interaction, FundingRoundPaginator.BOOLEAN.ARGUMENTS.FORCE_REPLY, FundingRoundPaginator.BOOLEAN.TRUE); - const data = { embeds: [embed], components }; - - if (isReply) { - await interaction.respond(data); - } else { - await interaction.update(data); - } - } + public args: string[] = []; + public title: string = "Select A Funding Round To Remove Committee From"; +} +export class ApproveRejectFundingRoundPaginator extends FundingRoundPaginator { + public static readonly ID = 'appRejFRPag'; - public allSubActions(): Action[] { - return []; - } + public args: string[] = []; + public title: string = "Select A Funding Round To Approve Or Reject"; - public getPaginationRow(interaction: TrackedInteraction, currentPage: number, totalPages: number, ...args: string[]): ActionRowBuilder { - const row = new ActionRowBuilder(); - - if (currentPage > 0) { - const thisActionCustomId = CustomIDOracle.addArgumentsToAction(this, PaginationComponent.PAGINATION_ARG, PaginationComponent.PAGE_ARG, (currentPage - 1).toString(), ...args); - row.addComponents( - new ButtonBuilder() - .setCustomId(thisActionCustomId) - .setLabel('Previous') - .setStyle(ButtonStyle.Secondary) - ); - } - if (currentPage < totalPages - 1) { - const thisActionCustomId = CustomIDOracle.addArgumentsToAction(this, PaginationComponent.PAGINATION_ARG, PaginationComponent.PAGE_ARG, (currentPage + 1).toString(), ...args); - row.addComponents( - new ButtonBuilder() - .setCustomId(thisActionCustomId) - .setLabel('Next') - .setStyle(ButtonStyle.Secondary) - ); - } - - return row; - } - - getComponent(...args: any[]): StringSelectMenuBuilder { - return new StringSelectMenuBuilder() - .setCustomId(CustomIDOracle.addArgumentsToAction(this, 'paginate')) - .setPlaceholder('Select a Funding Round'); - } } export class InVotingFundingRoundPaginator extends FundingRoundPaginator { - protected async getFundingRounds(interaction: TrackedInteraction): Promise { + public static readonly ID = 'inVotFRPag'; + public args: string[] = [ORMModelPaginator.BOOLEAN.ARGUMENTS.FORCE_REPLY, ORMModelPaginator.BOOLEAN.TRUE] + public title: string = "Select A Funding Round To Vote For"; + + + public async getItems(interaction: TrackedInteraction): Promise { return await FundingRoundLogic.getEligibleVotingRounds(); } } \ No newline at end of file diff --git a/src/components/PaginationComponent.ts b/src/components/PaginationComponent.ts index 52a811d..38343b8 100644 --- a/src/components/PaginationComponent.ts +++ b/src/components/PaginationComponent.ts @@ -1,8 +1,11 @@ // src/components/PaginationComponent.ts import { Action, TrackedInteraction } from '../core/BaseClasses'; -import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; -import { CustomIDOracle } from '../CustomIDOracle'; +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 logger from '../logging'; export abstract class PaginationComponent extends Action { public static readonly PAGE_ARG = 'page'; @@ -57,4 +60,125 @@ export abstract class PaginationComponent extends Action { await this.handleInvalidOperation(interaction, operationId); } } +} + +/** + * Use this in the code, in the operation handler that shouls show the paginated list of items. Simply await on the handlePagination() + * and let it do the rest. + */ +export abstract class ORMModelPaginator extends PaginationComponent { + + public static BOOLEAN = { + TRUE: 'yes', + ARGUMENTS: { + FORCE_REPLY: 'fRp' + } + }; + + public readonly REQUIRED_ARGUMENTS: string[] = []; + + public readonly action: Action; + public readonly operation: string; + + public abstract readonly args: string[]; + + public abstract readonly title: string; + public abstract readonly description: string; + public abstract readonly placeholder: string; + + constructor(screen: Screen, dstAction: Action, dstOperation: string, id: string) { + super(screen, id); + this.action = dstAction; + this.operation = dstOperation; + } + + protected parseRequiredArguments(interaction: TrackedInteraction): { [key: string]: string } { + let parsedArguments: { [key: string]: string } = {}; + for (const argument of this.REQUIRED_ARGUMENTS) { + logger.debug(`Checking for ${argument} in ${this.REQUIRED_ARGUMENTS}`); + const value: string = ArgumentOracle.getNamedArgument(interaction, argument); + parsedArguments[argument] = value; + + } + return parsedArguments; + } + + protected getRequiredArgumentsAsList(interaction: TrackedInteraction) :string[] { + const requiredArgs: {[key: string]: string} = this.parseRequiredArguments(interaction); + const requiredArgsAsList: string[] = requiredArgs ? Object.entries(requiredArgs).flat() : []; + return requiredArgsAsList; + } + + protected abstract getOptions(interaction: TrackedInteraction, items: ORMModel[]): Promise; + abstract getItems(interaction: TrackedInteraction): Promise; + + protected async getTotalPages(interaction: TrackedInteraction): Promise { + const items = await this.getItems(interaction); + return Math.ceil(items.length / 25); + } + + protected async getItemsForPage(interaction: TrackedInteraction, page: number): Promise { + const items = await this.getItems(interaction); + return items.slice(page * 25, (page + 1) * 25); + } + + public async handlePagination(interaction: TrackedInteraction): Promise { + const currentPage = this.getCurrentPage(interaction); + const totalPages = await this.getTotalPages(interaction); + const allItems = await this.getItemsForPage(interaction, currentPage); + + const paginatorStr: string = `Page ${currentPage + 1} of ${totalPages}`; + const fullDescription: string = `${this.description}\n\n${paginatorStr}`; + + const requiredArgsAsList = this.getRequiredArgumentsAsList(interaction); + const allArgs: string[] = [...this.args, ...requiredArgsAsList] + const customId: string = CustomIDOracle.addArgumentsToAction(this.action, this.operation, ...allArgs); + + const selectMenuOptions = await this.getOptions(interaction, allItems); + const selectMenu = new StringSelectMenuBuilder() + .setCustomId(customId) + .setPlaceholder(this.placeholder) + .addOptions(selectMenuOptions); + + const embed = new EmbedBuilder() + .setTitle(this.title) + .setDescription(fullDescription); + + const components: ActionRowBuilder[] = [ + new ActionRowBuilder().addComponents(selectMenu) + ]; + + + + console.log(requiredArgsAsList); + + if (totalPages > 1) { + const paginationRow = this.getPaginationRow(interaction, currentPage, totalPages, ...requiredArgsAsList); + components.push(paginationRow); + } + + await this.replyOrUpdate(interaction, { embeds: [embed], components: components }); + } + + protected async replyOrUpdate(interaction: TrackedInteraction, data: any): Promise { + const isReply: boolean = ArgumentOracle.isArgumentEquals(interaction, ORMModelPaginator.BOOLEAN.ARGUMENTS.FORCE_REPLY, ORMModelPaginator.BOOLEAN.TRUE); + + if (isReply) { + await interaction.respond(data); + } else { + await interaction.update(data); + } + } + + + + public allSubActions(): Action[] { + return []; + } + + + getComponent(...args: any[]): StringSelectMenuBuilder { + throw new EndUserError('A paginator does not have a component'); + + } } \ No newline at end of file diff --git a/src/components/ProposalsPaginator.ts b/src/components/ProposalsPaginator.ts new file mode 100644 index 0000000..3cb0691 --- /dev/null +++ b/src/components/ProposalsPaginator.ts @@ -0,0 +1,43 @@ +import { FundingRoundLogic } from "../channels/admin/screens/FundingRoundLogic"; +import { TrackedInteraction } from "../core/BaseClasses"; +import { ArgumentOracle } from "../CustomIDOracle"; +import { ProposalLogic } from "../logic/ProposalLogic"; +import { FundingRound, Proposal } from "../models"; +import { ORMModelPaginator } from "./PaginationComponent"; + +export abstract class ProposalsPaginator extends ORMModelPaginator { + public readonly description: string = 'To continue, select a Proposal from the list below'; + public readonly placeholder: string = 'Select a proposal'; + + + protected async getOptions(interaction: TrackedInteraction, items: Proposal[]): Promise { + return items.map((fr: Proposal) => ({ + label: fr.name, + value: fr.id.toString(), + description: `Budget: ${fr.budget}, Status: ${fr.status}` + })); + } + +} + +export class EditMySubmittedProposalsPaginator extends ProposalsPaginator { + public static readonly ID = "EdMySuPP" + public args: string[] = []; + public title: string = "Edit A Submitted Proposal"; + + 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) + const fundingRound: FundingRound = await FundingRoundLogic.getFundingRoundByIdOrError(fundingRoundIdNum); + const proposals: Proposal[] = await ProposalLogic.getUserProposalsForFundingRound(interaction.interaction.user.id, fundingRoundIdNum); + const eligibleProposals: Proposal[] = proposals.filter((p: Proposal) => + FundingRoundLogic.isProposalActiveForFundingRound(p, fundingRound) + ); + return eligibleProposals; + } + + + +} \ No newline at end of file From d24ea09f9dcd6a9da617eb938ead51e69f73b653 Mon Sep 17 00:00:00 2001 From: Illya Gerasymchuk Date: Fri, 16 Aug 2024 17:07:47 +0100 Subject: [PATCH 13/15] feat: updates to entrypoint --- src/bot.ts | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/src/bot.ts b/src/bot.ts index 9fc1acd..1a854ff 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -124,19 +124,32 @@ client.once('ready', async () => { }); client.on('interactionCreate', async (interaction: Interaction) => { + logger.info("Start handling interaction"); try { - if (!interaction.isButton() && !interaction.isStringSelectMenu() && !interaction.isModalSubmit() && !interaction.isMessageComponent()){ - logger.info(`Interaction type not supported: ${interaction.type}`); - return; - } + if (!interaction.isButton() && !interaction.isStringSelectMenu() && !interaction.isModalSubmit() && !interaction.isMessageComponent()) { + logger.info(`Interaction type not supported: ${interaction.type}`); + return; + } + + logger.debug(`Before handling interaction...`); + await dashboardManager.handleInteraction(interaction); + logger.debug(`After handling interaction...`); + } catch (error) { + logger.debug(`Start handling error in interaction...`); + + try { + logger.error(error); + const trackedInteratction = new TrackedInteraction(interaction as AnyInteraction); + await DiscordStatus.handleException(trackedInteratction, error); - await dashboardManager.handleInteraction(interaction); -} catch (error) { - logger.error(error); - const trackedInteratction = new TrackedInteraction(interaction as AnyInteraction); - await DiscordStatus.handleException(trackedInteratction, error); + } catch (error) { + logger.error(`Unrecoverable error: ${error}`); + } } + + logger.info("Finished handling interaction"); + }); client.login(process.env.DISCORD_TOKEN); \ No newline at end of file From ba5d45e36b36abc6b5c32b08632da8055b977268 Mon Sep 17 00:00:00 2001 From: Illya Gerasymchuk Date: Fri, 16 Aug 2024 17:08:09 +0100 Subject: [PATCH 14/15] feat: updates to core and logic --- src/core/BaseClasses.ts | 8 ++++---- src/logic/ProposalLogic.ts | 10 ++++++++++ src/logic/TopicLogic.ts | 0 3 files changed, 14 insertions(+), 4 deletions(-) create mode 100644 src/logic/TopicLogic.ts diff --git a/src/core/BaseClasses.ts b/src/core/BaseClasses.ts index 1905c88..f5893c2 100644 --- a/src/core/BaseClasses.ts +++ b/src/core/BaseClasses.ts @@ -30,22 +30,22 @@ export class TrackedInteraction { public getFromValuesCustomIdOrContext(index: number, name: string) { const interactionWithValues: AnyInteractionWithValues | undefined = InteractionProperties.toInteractionWithValuesOrUndefined(this.interaction); - logger.trace(`Looking for value at index ${index} for name ${name}, in ${this.interaction.customId}`); + logger.debug(`Looking for value at index ${index} for name ${name}, in ${this.interaction.customId}`); if (interactionWithValues) { const value = interactionWithValues.values[index]; - logger.trace(`Interaction values: ${interactionWithValues.values}, returning value at index ${index}: ${value}`); + logger.debug(`Interaction values: ${interactionWithValues.values}, returning value at index ${index}: ${value}`); return value; } const valueFromCustomId = this.getFromCustomId(name); if (valueFromCustomId) { - logger.trace(`Returning value from custom_id: ${valueFromCustomId}`); + logger.debug(`Returning value from custom_id: ${valueFromCustomId}`); return valueFromCustomId; } const valueFromContext = this.Context.get(name); if (valueFromContext) { - logger.trace(`Returning value from context: ${valueFromContext}`); + logger.debug(`Returning value from context: ${valueFromContext}`); } return valueFromContext; } diff --git a/src/logic/ProposalLogic.ts b/src/logic/ProposalLogic.ts index be47851..7e0623e 100644 --- a/src/logic/ProposalLogic.ts +++ b/src/logic/ProposalLogic.ts @@ -19,6 +19,16 @@ export class ProposalLogic { return await Proposal.findByPk(id); } + static async getProposalByIdOrError(id: number): Promise { + const proposal: Proposal | null = await this.getProposalById(id); + + if (!proposal) { + throw new EndUserError(`Proposal with id ${id} not found`); + } + + return proposal; + } + static async createProposal(data: ProposalCreationAttributes): Promise { return await Proposal.create({ ...data, diff --git a/src/logic/TopicLogic.ts b/src/logic/TopicLogic.ts new file mode 100644 index 0000000..e69de29 From 95d98c501199b27bdac0b6d4f2ebeb7220e37e40 Mon Sep 17 00:00:00 2001 From: Illya Gerasymchuk Date: Fri, 16 Aug 2024 17:16:52 +0100 Subject: [PATCH 15/15] chore: bump version to 0.0.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0df8328..3b4d359 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mina-govbot", - "version": "0.0.5", + "version": "0.0.6", "description": "Discord bot for collective decision making for Mina Protocol", "main": "index.js", "directories": {