diff --git a/package.json b/package.json index 91ee495..874f6aa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mina-govbot", - "version": "0.0.11", + "version": "0.0.12", "description": "Discord bot for collective decision making for Mina Protocol", "main": "index.js", "directories": { diff --git a/src/channels/admin/actions/CountVotesAction.ts b/src/channels/admin/actions/CountVotesAction.ts new file mode 100644 index 0000000..9a64437 --- /dev/null +++ b/src/channels/admin/actions/CountVotesAction.ts @@ -0,0 +1,93 @@ +import { Action, TrackedInteraction, Screen } from '../../../core/BaseClasses'; +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, StringSelectMenuBuilder } from 'discord.js'; +import { ArgumentOracle, CustomIDOracle } from '../../../CustomIDOracle'; +import { AllFundingRoundsPaginator, FundingRoundPaginator } from '../../../components/FundingRoundPaginator'; +import { VoteCountingLogic } from '../../../logic/VoteCountingLogic'; +import { EndUserError } from '../../../Errors'; +import { AnyModalMessageComponent } from '../../../types/common'; +import { DiscordStatus } from '../../DiscordStatus'; + +export class CountVotesAction extends Action { + public allSubActions(): Action[] { + return []; + } + getComponent(...args: any[]): AnyModalMessageComponent { + throw new Error('Method not implemented.'); + } + public static readonly ID = 'countVotes'; + + private fundingRoundPaginator: FundingRoundPaginator; + + constructor(screen: Screen, id: string) { + super(screen, id); + this.fundingRoundPaginator = new AllFundingRoundsPaginator(screen, this, 'selectPhase', 'fundingRoundPaginator'); + } + + protected async handleOperation(interaction: TrackedInteraction, operationId: string): Promise { + switch (operationId) { + case 'selectFundingRound': + await this.handleSelectFundingRound(interaction); + break; + case 'selectPhase': + await this.handleSelectPhase(interaction); + break; + case 'countVotes': + await this.handleCountVotes(interaction); + break; + default: + throw new EndUserError('Invalid operation'); + } + } + + private async handleSelectFundingRound(interaction: TrackedInteraction): Promise { + await this.fundingRoundPaginator.handlePagination(interaction); + } + + private async handleSelectPhase(interaction: TrackedInteraction): Promise { + const fundingRoundId = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, 0); + + const embed = new EmbedBuilder() + .setColor('#0099ff') + .setTitle('Select Funding Round Phase') + .setDescription('Choose the phase for which you want to count votes:'); + + const row = new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction(this, 'countVotes', ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID, fundingRoundId.toString())) + .setPlaceholder('Select a phase') + .addOptions([ + { label: 'Consideration Phase', value: 'consideration' }, + { label: 'Deliberation Phase', value: 'deliberation' }, + { label: 'Voting Phase', value: 'voting' }, + ]), + ); + + await interaction.update({ embeds: [embed], components: [row] }); + } + + private async handleCountVotes(interaction: TrackedInteraction): Promise { + const fundingRoundId = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID); + const phase = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.PHASE, 0); + + const voteResults = await VoteCountingLogic.countVotes(parseInt(fundingRoundId), phase); + + const embed = new EmbedBuilder() + .setColor('#0099ff') + .setTitle(`Vote Count Results - ${phase.charAt(0).toUpperCase() + phase.slice(1)} Phase`) + .setDescription('Here are the vote counts for each project:'); + + voteResults.forEach((result, index) => { + let voteInfo = `Yes Votes: ${result.yesVotes}\nNo Votes: ${result.noVotes}`; + if (phase === 'deliberation' && result.approvedModifiedVotes !== undefined) { + voteInfo += `\nApproved Modified Votes: ${result.approvedModifiedVotes}`; + } + + embed.addFields({ + name: `${index + 1}. ${result.projectName} (ID: ${result.projectId})`, + value: `Proposer: ${result.proposerDuid}\n${voteInfo}`, + }); + }); + + await interaction.update({ embeds: [embed], components: [] }); + } +} diff --git a/src/channels/admin/screens/AdminHomeScreen.ts b/src/channels/admin/screens/AdminHomeScreen.ts index 5662d8c..ed7ba9a 100644 --- a/src/channels/admin/screens/AdminHomeScreen.ts +++ b/src/channels/admin/screens/AdminHomeScreen.ts @@ -7,17 +7,20 @@ import { ManageSMEGroupsScreen } from './ManageSMEGroupsScreen'; import { ManageTopicsScreen } from './ManageTopicLogicScreen'; import { ManageFundingRoundsScreen } from './ManageFundingRoundsScreen'; import { ManageProposalStatusesScreen } from './ManageProposalStatusesScreen'; - +import { CountVotesScreen } from './CountVotesScreen'; export class AdminHomeScreen extends Screen implements IHomeScreen { public static readonly ID = 'home'; protected permissions: Permission[] = []; // access allowed for all - protected manageSMEGroupsScreen: ManageSMEGroupsScreen = new ManageSMEGroupsScreen(this.dashboard, ManageSMEGroupsScreen.ID); + protected manageSMEGroupsScreen: ManageSMEGroupsScreen = new ManageSMEGroupsScreen(this.dashboard, ManageSMEGroupsScreen.ID); protected manageTopicsScreen: ManageTopicsScreen = new ManageTopicsScreen(this.dashboard, ManageTopicsScreen.ID); protected manageFundingRoundsScreen: ManageFundingRoundsScreen = new ManageFundingRoundsScreen(this.dashboard, ManageFundingRoundsScreen.ID); - protected manageProposalStatusesScreen: ManageProposalStatusesScreen = new ManageProposalStatusesScreen(this.dashboard, ManageProposalStatusesScreen.ID); - + protected manageProposalStatusesScreen: ManageProposalStatusesScreen = new ManageProposalStatusesScreen( + this.dashboard, + ManageProposalStatusesScreen.ID, + ); + protected countVotesScreen: CountVotesScreen = new CountVotesScreen(this.dashboard, CountVotesScreen.ID); async renderToTextChannel(channel: TextChannel): Promise { const embed = this.createEmbed(); @@ -38,16 +41,12 @@ export class AdminHomeScreen extends Screen implements IHomeScreen { return { embeds: [embed], components: [row], - ephemeral: true + ephemeral: true, }; } protected allSubScreens(): Screen[] { - return [ - this.manageSMEGroupsScreen, - this.manageTopicsScreen, - this.manageFundingRoundsScreen, - ] + return [this.manageSMEGroupsScreen, this.manageTopicsScreen, this.manageFundingRoundsScreen, this.countVotesScreen]; } protected allActions(): Action[] { return []; @@ -61,33 +60,29 @@ export class AdminHomeScreen extends Screen implements IHomeScreen { .addFields( { name: '👥 SME Management', value: 'Manage SME Groups and Users' }, { name: '📋 Topic Management', value: 'Manage Topics and Committees' }, - { name: '💰 Funding Round Management', value: 'Manage Funding Rounds and Phases' } + { name: '💰 Funding Round Management', value: 'Manage Funding Rounds and Phases' }, ); } private createActionRow(): ActionRowBuilder { - return new ActionRowBuilder() - .addComponents( - new ButtonBuilder() - .setCustomId(this.manageSMEGroupsScreen.fullCustomId) - .setLabel('SME Management') - .setStyle(ButtonStyle.Primary) - .setEmoji('👥'), - new ButtonBuilder() - .setCustomId(this.manageTopicsScreen.fullCustomId) - .setLabel('Topic Management') - .setStyle(ButtonStyle.Primary) - .setEmoji('📋'), - new ButtonBuilder() - .setCustomId(this.manageFundingRoundsScreen.fullCustomId) - .setLabel('Funding Round Management') - .setStyle(ButtonStyle.Primary) - .setEmoji('💰'), - new ButtonBuilder() - .setCustomId(this.manageProposalStatusesScreen.fullCustomId) - .setLabel('Proposal Status Management') - .setStyle(ButtonStyle.Primary) - .setEmoji('📊') - ); + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(this.manageSMEGroupsScreen.fullCustomId) + .setLabel('SME Management') + .setStyle(ButtonStyle.Primary) + .setEmoji('👥'), + new ButtonBuilder().setCustomId(this.manageTopicsScreen.fullCustomId).setLabel('Topic Management').setStyle(ButtonStyle.Primary).setEmoji('📋'), + new ButtonBuilder() + .setCustomId(this.manageFundingRoundsScreen.fullCustomId) + .setLabel('Funding Round Management') + .setStyle(ButtonStyle.Primary) + .setEmoji('💰'), + new ButtonBuilder() + .setCustomId(this.manageProposalStatusesScreen.fullCustomId) + .setLabel('Proposal Status Management') + .setStyle(ButtonStyle.Primary) + .setEmoji('📊'), + new ButtonBuilder().setCustomId(this.countVotesScreen.fullCustomId).setLabel('Count Votes').setStyle(ButtonStyle.Primary).setEmoji('🗳️'), + ); } } diff --git a/src/channels/admin/screens/CountVotesScreen.ts b/src/channels/admin/screens/CountVotesScreen.ts new file mode 100644 index 0000000..a2254c9 --- /dev/null +++ b/src/channels/admin/screens/CountVotesScreen.ts @@ -0,0 +1,40 @@ +import { Screen, Dashboard, Permission, Action, TrackedInteraction } from '../../../core/BaseClasses'; +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js'; +import { CountVotesAction } from '../actions/CountVotesAction'; +import { CustomIDOracle } from '../../../CustomIDOracle'; + +export class CountVotesScreen extends Screen { + public static readonly ID = 'countVotes'; + protected permissions: Permission[] = []; + private countVotesAction: CountVotesAction; + + constructor(dashboard: Dashboard, id: string) { + super(dashboard, id); + this.countVotesAction = new CountVotesAction(this, CountVotesAction.ID); + } + + async getResponse(interaction: TrackedInteraction): Promise { + const embed = new EmbedBuilder().setColor('#0099ff').setTitle('Count Votes').setDescription('Select a funding round to count votes for:'); + + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(CustomIDOracle.addArgumentsToAction(this.countVotesAction, 'selectFundingRound')) + .setLabel('Select Funding Round') + .setStyle(ButtonStyle.Primary), + ); + + return { + embeds: [embed], + components: [row], + ephemeral: true, + }; + } + + protected allSubScreens(): Screen[] { + return []; + } + + protected allActions(): Action[] { + return [this.countVotesAction]; + } +} diff --git a/src/channels/admin/screens/FundingRoundLogic.ts b/src/channels/admin/screens/FundingRoundLogic.ts index 59ff720..39e6cf9 100644 --- a/src/channels/admin/screens/FundingRoundLogic.ts +++ b/src/channels/admin/screens/FundingRoundLogic.ts @@ -2,904 +2,969 @@ import sequelize from '../../../config/database'; import { TrackedInteraction } from '../../../core/BaseClasses'; import { EndUserError } from '../../../Errors'; import logger from '../../../logging'; -import { FundingRound, Topic, ConsiderationPhase, DeliberationPhase, FundingVotingPhase, SMEGroup, SMEGroupMembership, FundingRoundDeliberationCommitteeSelection, FundingRoundApprovalVote, TopicSMEGroupProposalCreationLimiter, Proposal, TopicCommittee } from '../../../models'; +import { + FundingRound, + Topic, + ConsiderationPhase, + DeliberationPhase, + FundingVotingPhase, + SMEGroup, + SMEGroupMembership, + FundingRoundDeliberationCommitteeSelection, + FundingRoundApprovalVote, + TopicSMEGroupProposalCreationLimiter, + Proposal, + TopicCommittee, +} from '../../../models'; import { FundingRoundMI, FundingRoundMIPhase, FundingRoundMIPhaseValue } from '../../../models/Interface'; import { FundingRoundAttributes, FundingRoundStatus, FundingRoundPhase, ProposalStatus } from '../../../types'; import { Op, Transaction } from 'sequelize'; export class FundingRoundLogic { - static async createFundingRound(name: string, description: string, topicName: string, budget: number, stakingLedgerEpoch: number): Promise { - const topic = await Topic.findOne({ where: { name: topicName } }); - if (!topic) { - throw new EndUserError('Topic not found'); - } - - return await FundingRound.create({ - name, - description, - topicId: topic.id, - budget, - votingAddress: null, - stakingLedgerEpoch, - status: FundingRoundStatus.VOTING, + static async createFundingRound( + name: string, + description: string, + topicName: string, + budget: number, + stakingLedgerEpoch: number, + ): Promise { + const topic = await Topic.findOne({ where: { name: topicName } }); + if (!topic) { + throw new EndUserError('Topic not found'); + } + + return await FundingRound.create({ + name, + description, + topicId: topic.id, + budget, + votingAddress: null, + stakingLedgerEpoch, + status: FundingRoundStatus.VOTING, + }); + } + + 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); + } + + static async getFundingRoundByIdOrError(fundingRoundId: number): Promise { + const fundingRound = await this.getFundingRoundById(fundingRoundId); + if (!fundingRound) { + throw new EndUserError(`Funding round with id ${fundingRoundId} not found`); + } + + return fundingRound; + } + + static async getFundingRoundPhase(fundingRoundId: number, phase: FundingRoundMIPhaseValue) { + switch (phase) { + case FundingRoundMI.PHASES.CONSIDERATION: + return await ConsiderationPhase.findOne({ where: { fundingRoundId } }); + case FundingRoundMI.PHASES.DELIBERATION: + return await DeliberationPhase.findOne({ where: { fundingRoundId } }); + 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 } }); + const votingPhase = await FundingVotingPhase.findOne({ where: { fundingRoundId } }); + + const phases: FundingRoundPhase[] = []; + + if (considerationPhase) { + phases.push({ + phase: 'consideration', + startDate: considerationPhase.startAt, + endDate: considerationPhase.endAt, + }); + } + + if (deliberationPhase) { + phases.push({ + phase: 'deliberation', + startDate: deliberationPhase.startAt, + endDate: deliberationPhase.endAt, + }); + } + + if (votingPhase) { + phases.push({ + phase: 'voting', + startDate: votingPhase.startAt, + endDate: votingPhase.endAt, + }); + } + + return phases; + } + + static async getCurrentPhase(fundingRoundId: number): Promise { + const currentPhases: FundingRoundPhase[] = await this.getFundingRoundPhases(fundingRoundId); + if (currentPhases.length === 0) { + return null; + } + + if (currentPhases.length > 1) { + logger.warn( + `Funding round ${fundingRoundId} has multiple active phases: ${currentPhases + .map((phase) => phase.phase) + .join(', ')}. Defaulting to the first one.`, + ); + } + + return currentPhases[0]; + } + + 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({ + fundingRoundId, + stakingLedgerEpoch, + startAt: startDate, + endAt: endDate, }); - } - - 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, + break; + case FundingRoundMI.PHASES.DELIBERATION.toString().toLocaleLowerCase(): + await DeliberationPhase.upsert({ + fundingRoundId, + stakingLedgerEpoch, + startAt: startDate, + endAt: endDate, }); + break; + case FundingRoundMI.PHASES.VOTING.toString().toLocaleLowerCase(): + await FundingVotingPhase.upsert({ + fundingRoundId, + stakingLedgerEpoch, + startAt: startDate, + endAt: endDate, + }); + break; + case FundingRoundMI.PHASES.ROUND.toString().toLocaleLowerCase(): + await fundingRound.update({ + startAt: startDate, + endAt: endDate, + }); + break; + default: + throw new EndUserError('Invalid phase: ' + phase); } + } - static async getFundingRoundById(id: number): Promise { - return await FundingRound.findByPk(id); - } - - static async getFundingRoundByIdOrError(fundingRoundId: number): Promise { - const fundingRound = await this.getFundingRoundById(fundingRoundId); - if (!fundingRound) { - throw new EndUserError(`Funding round with id ${fundingRoundId} not found`); - } + static async getPresentAndFutureFundingRounds(): Promise { + const currentDate = new Date(); + return await FundingRound.findAll({ + where: { + [Op.or]: [{ endAt: { [Op.gte]: currentDate } }, { endAt: null }], + }, + order: [['startAt', 'ASC']], + }); + } - return fundingRound; - } + static async updateFundingRound(id: number, updates: Partial): Promise { + const fundingRound = await this.getFundingRoundByIdOrError(id); - static async getFundingRoundPhase(fundingRoundId: number, phase: FundingRoundMIPhaseValue) { - switch (phase) { - case FundingRoundMI.PHASES.CONSIDERATION: - return await ConsiderationPhase.findOne({ where: { fundingRoundId } }); - case FundingRoundMI.PHASES.DELIBERATION: - return await DeliberationPhase.findOne({ where: { fundingRoundId } }); - case FundingRoundMI.PHASES.VOTING: - return await FundingVotingPhase.findOne({ where: { fundingRoundId } }); - default: - throw new EndUserError(`Invalid phase: ${phase}. Funding Round Id ${fundingRoundId}`); - } + if (updates.topicId) { + const topic = await Topic.findByPk(updates.topicId); + if (!topic) { + throw new EndUserError('Topic not found'); + } } + await fundingRound.update(updates); + return fundingRound; + } - static async getFundingRoundPhases(fundingRoundId: number): Promise { - const considerationPhase = await ConsiderationPhase.findOne({ where: { fundingRoundId } }); - const deliberationPhase = await DeliberationPhase.findOne({ where: { fundingRoundId } }); - const votingPhase = await FundingVotingPhase.findOne({ where: { fundingRoundId } }); - - const phases: FundingRoundPhase[] = []; - - if (considerationPhase) { - phases.push({ - phase: 'consideration', - startDate: considerationPhase.startAt, - endDate: considerationPhase.endAt, - }); - } - - if (deliberationPhase) { - phases.push({ - phase: 'deliberation', - startDate: deliberationPhase.startAt, - endDate: deliberationPhase.endAt, - }); - } - - if (votingPhase) { - phases.push({ - phase: 'voting', - startDate: votingPhase.startAt, - endDate: votingPhase.endAt, - }); - } - - return phases; + static async approveFundingRound(id: number): Promise { + const fundingRound = await this.getFundingRoundById(id); + if (!fundingRound) { + return null; } - static async getCurrentPhase(fundingRoundId: number): Promise { - const currentPhases: FundingRoundPhase[] = await this.getFundingRoundPhases(fundingRoundId); - if (currentPhases.length === 0) { - return null; - } - - if (currentPhases.length > 1) { - logger.warn(`Funding round ${fundingRoundId} has multiple active phases: ${currentPhases.map(phase => phase.phase).join(', ')}. Defaulting to the first one.`); - } + await fundingRound.update({ status: FundingRoundStatus.APPROVED }); + return fundingRound; + } - return currentPhases[0]; - } - - 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({ - fundingRoundId, - stakingLedgerEpoch, - startAt: startDate, - endAt: endDate, - }); - break; - case FundingRoundMI.PHASES.DELIBERATION.toString().toLocaleLowerCase(): - await DeliberationPhase.upsert({ - fundingRoundId, - stakingLedgerEpoch, - startAt: startDate, - endAt: endDate, - }); - break; - case FundingRoundMI.PHASES.VOTING.toString().toLocaleLowerCase(): - await FundingVotingPhase.upsert({ - fundingRoundId, - stakingLedgerEpoch, - startAt: startDate, - endAt: endDate, - }); - break; - case FundingRoundMI.PHASES.ROUND.toString().toLocaleLowerCase(): - await fundingRound.update({ - startAt: startDate, - endAt: endDate, - }); - break; - default: - throw new EndUserError('Invalid phase: ' + phase); - } - } - - static async getPresentAndFutureFundingRounds(): Promise { - const currentDate = new Date(); - return await FundingRound.findAll({ - where: { - [Op.or]: [ - { endAt: { [Op.gte]: currentDate } }, - { endAt: null }, - ], - }, - order: [['startAt', 'ASC']], - }); + static async rejectFundingRound(id: number): 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); - if (!topic) { - throw new EndUserError('Topic not found'); - } - } + await fundingRound.update({ status: FundingRoundStatus.REJECTED }); + return fundingRound; + } - await fundingRound.update(updates); - return fundingRound; - } + static async getSMEGroupMemberCount(smeGroupId: number): Promise { + return await SMEGroupMembership.count({ where: { smeGroupId } }); + } - static async approveFundingRound(id: number): Promise { - const fundingRound = await this.getFundingRoundById(id); - if (!fundingRound) { - return null; - } + static async getSMEGroupMembers(smeGroupId: number): Promise { + const memberships = await SMEGroupMembership.findAll({ where: { smeGroupId } }); + return memberships.map((membership) => membership.duid); + } - await fundingRound.update({ status: FundingRoundStatus.APPROVED }); - return fundingRound; + static async setFundingRoundCommittee(fundingRoundId: number, memberDuids: string[]): Promise { + const fundingRound = await this.getFundingRoundById(fundingRoundId); + if (!fundingRound) { + throw new EndUserError('Funding round not found'); } - static async rejectFundingRound(id: number): Promise { - const fundingRound = await this.getFundingRoundById(id); - if (!fundingRound) { - return null; - } + await FundingRoundDeliberationCommitteeSelection.destroy({ where: { fundingRoundId } }); - await fundingRound.update({ status: FundingRoundStatus.REJECTED }); - return fundingRound; + for (const duid of memberDuids) { + await FundingRoundDeliberationCommitteeSelection.create({ + fundingRoundId, + duid: duid, + }); } + } - static async getSMEGroupMemberCount(smeGroupId: number): Promise { - return await SMEGroupMembership.count({ where: { smeGroupId } }); + static async appendFundingRoundCommitteeMembers(fundingRoundId: number, memberDuids: string[]): Promise { + const fundingRound = await this.getFundingRoundById(fundingRoundId); + if (!fundingRound) { + throw new EndUserError('Funding round not found'); } - static async getSMEGroupMembers(smeGroupId: number): Promise { - const memberships = await SMEGroupMembership.findAll({ where: { smeGroupId } }); - return memberships.map(membership => membership.duid); - } + let insertedCount = 0; - static async setFundingRoundCommittee(fundingRoundId: number, memberDuids: string[]): Promise { - const fundingRound = await this.getFundingRoundById(fundingRoundId); - if (!fundingRound) { - throw new EndUserError('Funding round not found'); - } + await FundingRound.sequelize!.transaction(async (t: Transaction) => { + const existingMembers = await FundingRoundDeliberationCommitteeSelection.findAll({ + where: { fundingRoundId }, + transaction: t, + }); - await FundingRoundDeliberationCommitteeSelection.destroy({ where: { fundingRoundId } }); + const existingDuids = new Set(existingMembers.map((member) => member.duid)); - for (const duid of memberDuids) { - await FundingRoundDeliberationCommitteeSelection.create({ + for (const duid of memberDuids) { + try { + if (!existingDuids.has(duid)) { + await FundingRoundDeliberationCommitteeSelection.create( + { fundingRoundId, duid: duid, - }); - } - } - - static async appendFundingRoundCommitteeMembers(fundingRoundId: number, memberDuids: string[]): Promise { - const fundingRound = await this.getFundingRoundById(fundingRoundId); - if (!fundingRound) { - throw new EndUserError('Funding round not found'); + }, + { transaction: t }, + ); + insertedCount++; + } + } catch (error) { + logger.error('Error in appendFundingRoundCommitteeMembers:', error); + throw new EndUserError('Error in appendFundingRoundCommitteeMembers', error); } + } + }); - let insertedCount = 0; - - await FundingRound.sequelize!.transaction(async (t: Transaction) => { - const existingMembers = await FundingRoundDeliberationCommitteeSelection.findAll({ - where: { fundingRoundId }, - transaction: t - }); - - const existingDuids = new Set(existingMembers.map(member => member.duid)); - - for (const duid of memberDuids) { - try { - if (!existingDuids.has(duid)) { - await FundingRoundDeliberationCommitteeSelection.create( - { - fundingRoundId, - duid: duid, - }, - { transaction: t } - ); - insertedCount++; - } - } catch (error) { - logger.error('Error in appendFundingRoundCommitteeMembers:', error); - throw new EndUserError('Error in appendFundingRoundCommitteeMembers', error); - - } - } - }); + return insertedCount; + } - return insertedCount; - } - - static async countSMEMembersInDeliberationCommittee(fundingRoundId: number, smeGroupId: number): Promise { - try { - const fundingRound = await FundingRound.findByPk(fundingRoundId); - if (!fundingRound) { - throw new EndUserError('Funding round not found'); - } - - const count = await sequelize.transaction(async (t) => { - // First, get all committee members for the funding round - const committeeMembers = await FundingRoundDeliberationCommitteeSelection.findAll({ - where: { fundingRoundId }, - attributes: ['duid'], - transaction: t, - }); - - if (committeeMembers.length === 0) { - return 0; // No committee members selected for this funding round - } - - // Get the duids of all committee members - const committeeDuids: string[] = committeeMembers.map(member => member.duid); - - // Now, count how many of these duids are in the specified SME group - const smeMembersCount = await SMEGroupMembership.count({ - where: { - smeGroupId: smeGroupId, - duid: committeeDuids, - }, - transaction: t, - }); - - const allSMEGroupMembers = await SMEGroupMembership.findAll({ - where: { smeGroupId }, - attributes: ['duid'], - transaction: t, - }); - - return smeMembersCount; - }); - - return count; - } catch (error) { - logger.error('Error in countSMEMembersInDeliberationCommittee:', error); - throw new EndUserError('Error in countSMEMembersInDeliberationCommittee', error); - } - } + static async countSMEMembersInDeliberationCommittee(fundingRoundId: number, smeGroupId: number): Promise { + try { + const fundingRound = await FundingRound.findByPk(fundingRoundId); + if (!fundingRound) { + throw new EndUserError('Funding round not found'); + } - static async getFundingRoundCommitteeMembers(fundingRoundId: number): Promise { + const count = await sequelize.transaction(async (t) => { + // First, get all committee members for the funding round const committeeMembers = await FundingRoundDeliberationCommitteeSelection.findAll({ - where: { fundingRoundId }, - attributes: ['duid'] + where: { fundingRoundId }, + attributes: ['duid'], + transaction: t, }); - return committeeMembers.map(member => member.duid); - } - - static async removeFundingRoundCommitteeMembers(fundingRoundId: number, memberDuids: string[]): Promise { - const result = await FundingRoundDeliberationCommitteeSelection.destroy({ - where: { - fundingRoundId, - duid: memberDuids - } - }); + if (committeeMembers.length === 0) { + return 0; // No committee members selected for this funding round + } - return result; - } + // Get the duids of all committee members + const committeeDuids: string[] = committeeMembers.map((member) => member.duid); - static async removeAllFundingRoundCommitteeMembers(fundingRoundId: number): Promise { - const result = await FundingRoundDeliberationCommitteeSelection.destroy({ - where: { - fundingRoundId - } + // Now, count how many of these duids are in the specified SME group + const smeMembersCount = await SMEGroupMembership.count({ + where: { + smeGroupId: smeGroupId, + duid: committeeDuids, + }, + transaction: t, }); - return result; - } - - static async createDraftFundingRound(topicId: number, name: string, description: string, budget: number, stakingLedgerEpoch: number, votingOpenUntil: Date): Promise { - return await FundingRound.create({ - topicId, - name, - description, - budget, - votingAddress: null, - stakingLedgerEpoch, - votingOpenUntil, - status: FundingRoundStatus.VOTING, + const allSMEGroupMembers = await SMEGroupMembership.findAll({ + where: { smeGroupId }, + attributes: ['duid'], + transaction: t, }); - } - - static async getEligibleVotingRounds(interaction: TrackedInteraction): Promise { - const duid: string = interaction.discordUserId; - const now = new Date(); - const userFundingRounds = await FundingRoundLogic.getFundingRoundsForUser(duid); - - const eligibleFundingRounds = userFundingRounds.filter((fr) => - fr.status === FundingRoundStatus.VOTING && - fr.votingOpenUntil >= now - ); - - const readyFundingRounds = await Promise.all( - eligibleFundingRounds.map(async (fr) => ({ - fundingRound: fr, - isReady: await fr.isReady() - })) - ); + return smeMembersCount; + }); + + return count; + } catch (error) { + logger.error('Error in countSMEMembersInDeliberationCommittee:', error); + throw new EndUserError('Error in countSMEMembersInDeliberationCommittee', error); + } + } + + static async getFundingRoundCommitteeMembers(fundingRoundId: number): Promise { + const committeeMembers = await FundingRoundDeliberationCommitteeSelection.findAll({ + where: { fundingRoundId }, + attributes: ['duid'], + }); + + return committeeMembers.map((member) => member.duid); + } + + static async removeFundingRoundCommitteeMembers(fundingRoundId: number, memberDuids: string[]): Promise { + const result = await FundingRoundDeliberationCommitteeSelection.destroy({ + where: { + fundingRoundId, + duid: memberDuids, + }, + }); + + return result; + } + + static async removeAllFundingRoundCommitteeMembers(fundingRoundId: number): Promise { + const result = await FundingRoundDeliberationCommitteeSelection.destroy({ + where: { + fundingRoundId, + }, + }); + + return result; + } + + static async createDraftFundingRound( + topicId: number, + name: string, + description: string, + budget: number, + stakingLedgerEpoch: number, + votingOpenUntil: Date, + ): Promise { + return await FundingRound.create({ + topicId, + name, + description, + budget, + votingAddress: null, + stakingLedgerEpoch, + votingOpenUntil, + status: FundingRoundStatus.VOTING, + }); + } - return readyFundingRounds - .filter(({ isReady }) => isReady) - .map(({ fundingRound }) => fundingRound); - } + static async getEligibleVotingRounds(interaction: TrackedInteraction): Promise { + const duid: string = interaction.discordUserId; + const now = new Date(); - static async hasUserVotedOnFundingRound(userId: string, fundingRoundId: number): Promise { - const vote = await FundingRoundApprovalVote.findOne({ - where: { - duid: userId, - fundingRoundId, - }, - }); - return !!vote; - } + const userFundingRounds = await FundingRoundLogic.getFundingRoundsForUser(duid); - static async voteFundingRound(userId: string, fundingRoundId: number): Promise { - const fundingRound = await this.getFundingRoundById(fundingRoundId); - if (!fundingRound) { - throw new EndUserError('Funding round not found'); - } + const eligibleFundingRounds = userFundingRounds.filter((fr) => fr.status === FundingRoundStatus.VOTING && fr.votingOpenUntil >= now); - if (fundingRound.status !== FundingRoundStatus.VOTING) { - throw new EndUserError('This funding round is not open for voting'); - } + const readyFundingRounds = await Promise.all( + eligibleFundingRounds.map(async (fr) => ({ + fundingRound: fr, + isReady: await fr.isReady(), + })), + ); - if (fundingRound.votingOpenUntil < new Date()) { - throw new EndUserError('Voting period for this funding round has ended'); - } + return readyFundingRounds.filter(({ isReady }) => isReady).map(({ fundingRound }) => fundingRound); + } - await FundingRoundApprovalVote.upsert({ - duid: userId, - fundingRoundId, - isPass: true, - }); + static async hasUserVotedOnFundingRound(userId: string, fundingRoundId: number): Promise { + const vote = await FundingRoundApprovalVote.findOne({ + where: { + duid: userId, + fundingRoundId, + }, + }); + return !!vote; + } + + static async voteFundingRound(userId: string, fundingRoundId: number): Promise { + const fundingRound = await this.getFundingRoundById(fundingRoundId); + if (!fundingRound) { + throw new EndUserError('Funding round not found'); } - static async unvoteFundingRound(userId: string, fundingRoundId: number): Promise { - const fundingRound: FundingRound | null = await this.getFundingRoundById(fundingRoundId); + if (fundingRound.status !== FundingRoundStatus.VOTING) { + throw new EndUserError('This funding round is not open for voting'); + } - if (!fundingRound) { - throw new EndUserError('Funding round not found'); - } - - if (fundingRound.status !== FundingRoundStatus.VOTING) { - throw new EndUserError('This funding round is not open for voting'); - } - - if (fundingRound.votingOpenUntil < new Date()) { - throw new EndUserError('Voting period for this funding round has ended'); - } - - await FundingRoundApprovalVote.upsert({ - duid: userId, - fundingRoundId, - isPass: false, - }); + if (fundingRound.votingOpenUntil < new Date()) { + throw new EndUserError('Voting period for this funding round has ended'); + } + + await FundingRoundApprovalVote.upsert({ + duid: userId, + fundingRoundId, + isPass: true, + }); + } + + static async unvoteFundingRound(userId: string, fundingRoundId: number): Promise { + const fundingRound: FundingRound | null = await this.getFundingRoundById(fundingRoundId); + + if (!fundingRound) { + throw new EndUserError('Funding round not found'); + } + + if (fundingRound.status !== FundingRoundStatus.VOTING) { + throw new EndUserError('This funding round is not open for voting'); + } + + if (fundingRound.votingOpenUntil < new Date()) { + throw new EndUserError('Voting period for this funding round has ended'); + } + + await FundingRoundApprovalVote.upsert({ + duid: userId, + fundingRoundId, + isPass: false, + }); + } + + static async getLatestVote(userId: string, fundingRoundId: number): Promise { + return await FundingRoundApprovalVote.findOne({ + where: { + duid: userId, + fundingRoundId, + }, + order: [['createdAt', 'DESC']], + }); + } + + static async canChangeVote(userId: string, fundingRoundId: number): Promise { + const fundingRound = await this.getFundingRoundById(fundingRoundId); + if (!fundingRound) { + throw new EndUserError('Funding round not found'); + } + + if (fundingRound.status !== FundingRoundStatus.VOTING) { + return false; } - static async getLatestVote(userId: string, fundingRoundId: number): Promise { - return await FundingRoundApprovalVote.findOne({ - where: { - duid: userId, - fundingRoundId, - }, - order: [['createdAt', 'DESC']], - }); + if (fundingRound.votingOpenUntil < new Date()) { + return false; } - static async canChangeVote(userId: string, fundingRoundId: number): Promise { - const fundingRound = await this.getFundingRoundById(fundingRoundId); - if (!fundingRound) { - throw new EndUserError('Funding round not found'); - } + return true; + } - if (fundingRound.status !== FundingRoundStatus.VOTING) { - return false; - } - - if (fundingRound.votingOpenUntil < new Date()) { - return false; - } - - return true; + static async createApproveVote(userId: string, fundingRoundId: number, reason: string): Promise { + if (!(await this.canChangeVote(userId, fundingRoundId))) { + throw new EndUserError('Voting is not allowed at this time'); } - static async createApproveVote(userId: string, fundingRoundId: number, reason: string): Promise { - if (!(await this.canChangeVote(userId, fundingRoundId))) { - throw new EndUserError('Voting is not allowed at this time'); - } + await FundingRoundApprovalVote.create({ + duid: userId, + fundingRoundId, + isPass: true, + reason, + }); + } - await FundingRoundApprovalVote.create({ - duid: userId, - fundingRoundId, - isPass: true, - reason, - }); + static async createRejectVote(userId: string, fundingRoundId: number, reason: string): Promise { + if (!(await this.canChangeVote(userId, fundingRoundId))) { + throw new EndUserError('Voting is not allowed at this time'); } - static async createRejectVote(userId: string, fundingRoundId: number, reason: string): Promise { - if (!(await this.canChangeVote(userId, fundingRoundId))) { - throw new EndUserError('Voting is not allowed at this time'); - } + await FundingRoundApprovalVote.create({ + duid: userId, + fundingRoundId, + isPass: false, + reason, + }); + } - await FundingRoundApprovalVote.create({ - duid: userId, - fundingRoundId, - isPass: false, - reason, - }); + static async getEligibleFundingRoundsForProposal(proposalId: number, userId: string): Promise { + const proposal: Proposal | null = await Proposal.findByPk(proposalId); + if (!proposal) { + throw new EndUserError('Proposal not found'); } - static async getEligibleFundingRoundsForProposal(proposalId: number, userId: string): Promise { - const proposal: Proposal | null = await Proposal.findByPk(proposalId); - if (!proposal) { - throw new EndUserError('Proposal not found'); - } + const now: Date = new Date(); + const eligibleFundingRounds: FundingRound[] = await FundingRound.findAll({ + where: { + status: FundingRoundStatus.APPROVED, + startAt: { [Op.lte]: now }, + endAt: { [Op.gt]: now }, + }, - const now: Date = new Date(); - const eligibleFundingRounds: FundingRound[] = await FundingRound.findAll({ - where: { - status: FundingRoundStatus.APPROVED, - startAt: { [Op.lte]: now }, - endAt: { [Op.gt]: now } - }, + include: [ + { + model: ConsiderationPhase, + where: { + startAt: { [Op.lte]: now }, + endAt: { [Op.gt]: now }, + }, + as: 'considerationPhase', + }, + ], + }); - include: [ - { - model: ConsiderationPhase, - where: { - startAt: { [Op.lte]: now }, - endAt: { [Op.gt]: now } - }, - as: 'considerationPhase' - } - ] - }); + const userSMEGroups: SMEGroupMembership[] = await SMEGroupMembership.findAll({ + where: { duid: userId }, + }); - const userSMEGroups: SMEGroupMembership[] = await SMEGroupMembership.findAll({ - where: { duid: userId } - }); + const userSMEGroupIds: number[] = userSMEGroups.map((membership) => membership.smeGroupId); - const userSMEGroupIds: number[] = userSMEGroups.map(membership => membership.smeGroupId); + return eligibleFundingRounds.filter(async (fundingRound: FundingRound) => { + const limitations: TopicSMEGroupProposalCreationLimiter[] = await TopicSMEGroupProposalCreationLimiter.findAll({ + where: { topicId: fundingRound.topicId }, + }); + + if (limitations.length === 0) { + return true; + } - return eligibleFundingRounds.filter(async (fundingRound: FundingRound) => { - const limitations: TopicSMEGroupProposalCreationLimiter[] = await TopicSMEGroupProposalCreationLimiter.findAll({ - where: { topicId: fundingRound.topicId } - }); + const allowedSMEGroupIds: number[] = limitations.map((limitation) => limitation.smeGroupId); + return userSMEGroupIds.some((id) => allowedSMEGroupIds.includes(id)); + }); + } - if (limitations.length === 0) { - return true; - } + static async getFundingRoundsWithUserProposals(duid: string): Promise { + const userProposals: Proposal[] = await Proposal.findAll({ + where: { proposerDuid: duid }, + attributes: ['fundingRoundId'], + group: ['fundingRoundId'], + }); - const allowedSMEGroupIds: number[] = limitations.map(limitation => limitation.smeGroupId); - return userSMEGroupIds.some(id => allowedSMEGroupIds.includes(id)); - }); - } + const fundingRoundIds: (number | null)[] = userProposals.map((proposal) => proposal.fundingRoundId); - static async getFundingRoundsWithUserProposals(duid: string): Promise { - const userProposals: Proposal[] = await Proposal.findAll({ - where: { proposerDuid: duid }, - attributes: ['fundingRoundId'], - group: ['fundingRoundId'], - }); + return FundingRound.findAll({ + where: { + id: { + [Op.in]: fundingRoundIds.filter((id): id is number => id !== null), + }, + }, + order: [['endAt', 'DESC']], + }); + } - const fundingRoundIds: (number | null)[] = userProposals.map(proposal => proposal.fundingRoundId); + static async isProposalActiveForFundingRound(proposal: Proposal, fundingRound: FundingRound): Promise { + const now: Date = new Date(); - return FundingRound.findAll({ - where: { - id: { - [Op.in]: fundingRoundIds.filter((id): id is number => id !== null) - } - }, - order: [['endAt', 'DESC']] - }); + if (fundingRound.status !== FundingRoundStatus.APPROVED) { + return false; } - static async isProposalActiveForFundingRound(proposal: Proposal, fundingRound: FundingRound): Promise { - const now: Date = new Date(); - - if (fundingRound.status !== FundingRoundStatus.APPROVED) { - return false; - } - - if (proposal.status === ProposalStatus.CANCELLED || proposal.status === ProposalStatus.DRAFT) { - return false; - } - - const considerationPhase: ConsiderationPhase | null = await ConsiderationPhase.findOne({ - where: { fundingRoundId: fundingRound.id } - }); - - const deliberationPhase: DeliberationPhase | null = await DeliberationPhase.findOne({ - where: { fundingRoundId: fundingRound.id } - }); - - const fundingVotingPhase: FundingVotingPhase | null = await FundingVotingPhase.findOne({ - where: { fundingRoundId: fundingRound.id } - }); - - if (considerationPhase && now >= considerationPhase.startAt && now <= considerationPhase.endAt) { - return proposal.status === ProposalStatus.CONSIDERATION_PHASE; - } - - if (deliberationPhase && now >= deliberationPhase.startAt && now <= deliberationPhase.endAt) { - return proposal.status === ProposalStatus.DELIBERATION_PHASE; - } - - if (fundingVotingPhase && now >= fundingVotingPhase.startAt && now <= fundingVotingPhase.endAt) { - return proposal.status === ProposalStatus.FUNDING_VOTING_PHASE; - } - - return false; + if (proposal.status === ProposalStatus.CANCELLED || proposal.status === ProposalStatus.DRAFT) { + return false; } - static async getProposalsWithActivityStatus(fundingRoundId: number): Promise<{ proposal: Proposal; isActive: boolean }[]> { - const fundingRound: FundingRound | null = await FundingRound.findByPk(fundingRoundId); - if (!fundingRound) { - throw new EndUserError('Funding round not found'); - } - - const proposals: Proposal[] = await Proposal.findAll({ - where: { fundingRoundId } - }); + const considerationPhase: ConsiderationPhase | null = await ConsiderationPhase.findOne({ + where: { fundingRoundId: fundingRound.id }, + }); - const result: { proposal: Proposal; isActive: boolean }[] = []; + const deliberationPhase: DeliberationPhase | null = await DeliberationPhase.findOne({ + where: { fundingRoundId: fundingRound.id }, + }); - for (const proposal of proposals) { - const isActive: boolean = await this.isProposalActiveForFundingRound(proposal, fundingRound); - result.push({ proposal, isActive }); - } + const fundingVotingPhase: FundingVotingPhase | null = await FundingVotingPhase.findOne({ + where: { fundingRoundId: fundingRound.id }, + }); - return result; + if (considerationPhase && now >= considerationPhase.startAt && now <= considerationPhase.endAt) { + return proposal.status === ProposalStatus.CONSIDERATION_PHASE; } + if (deliberationPhase && now >= deliberationPhase.startAt && now <= deliberationPhase.endAt) { + return proposal.status === ProposalStatus.DELIBERATION_PHASE; + } - static async getActiveFundingRoundPhases(fundingRoundId: number): Promise { - const fundingRound = await this.getFundingRoundById(fundingRoundId); - if (!fundingRound) { - throw new EndUserError('Funding round not found'); - } + if (fundingVotingPhase && now >= fundingVotingPhase.startAt && now <= fundingVotingPhase.endAt) { + return proposal.status === ProposalStatus.FUNDING_VOTING_PHASE; + } - const now = new Date(); - const activePhases: string[] = []; + return false; + } - const considerationPhase = await ConsiderationPhase.findOne({ where: { fundingRoundId } }); - if (considerationPhase && now >= considerationPhase.startAt && now <= considerationPhase.endAt) { - activePhases.push('consideration'); - } + static async getProposalsWithActivityStatus(fundingRoundId: number): Promise<{ proposal: Proposal; isActive: boolean }[]> { + const fundingRound: FundingRound | null = await FundingRound.findByPk(fundingRoundId); + if (!fundingRound) { + throw new EndUserError('Funding round not found'); + } - const deliberationPhase = await DeliberationPhase.findOne({ where: { fundingRoundId } }); - if (deliberationPhase && now >= deliberationPhase.startAt && now <= deliberationPhase.endAt) { - activePhases.push('deliberation'); - } + const proposals: Proposal[] = await Proposal.findAll({ + where: { fundingRoundId }, + }); - const fundingVotingPhase = await FundingVotingPhase.findOne({ where: { fundingRoundId } }); - if (fundingVotingPhase && now >= fundingVotingPhase.startAt && now <= fundingVotingPhase.endAt) { - activePhases.push('funding'); - } + const result: { proposal: Proposal; isActive: boolean }[] = []; - return activePhases; + for (const proposal of proposals) { + const isActive: boolean = await this.isProposalActiveForFundingRound(proposal, fundingRound); + result.push({ proposal, isActive }); } - static async getActiveProposalsForPhase(fundingRoundId: number, phase: string): Promise { - const fundingRound = await this.getFundingRoundById(fundingRoundId); - if (!fundingRound) { - throw new EndUserError('Funding round not found'); - } + return result; + } - let status: ProposalStatus; - switch (phase.toLowerCase()) { - case 'consideration': - status = ProposalStatus.CONSIDERATION_PHASE; - break; - case 'deliberation': - status = ProposalStatus.DELIBERATION_PHASE; - break; - case 'funding': - status = ProposalStatus.FUNDING_VOTING_PHASE; - break; - default: - throw new EndUserError('Invalid phase'); - } - return await Proposal.findAll({ - where: { - fundingRoundId, - status, - }, - }); + static async getActiveFundingRoundPhases(fundingRoundId: number): Promise { + const fundingRound = await this.getFundingRoundById(fundingRoundId); + if (!fundingRound) { + throw new EndUserError('Funding round not found'); } + const now = new Date(); + const activePhases: string[] = []; - static async getVotingAndApprovedFundingRounds(): Promise { - const now = new Date(); - return await FundingRound.findAll({ - where: { - [Op.or]: [ - { - status: FundingRoundStatus.VOTING, - votingOpenUntil: { - [Op.gte]: now, - }, - }, - { - status: FundingRoundStatus.APPROVED, - endAt: { - [Op.gt]: now, - }, - startAt: { - [Op.lte]: now, - }, - }, - ], - }, - include: [ - { model: ConsiderationPhase, required: true, as: 'considerationPhase' }, - { model: DeliberationPhase, required: true, as: 'deliberationPhase' }, - { model: FundingVotingPhase, required: true, as: 'fundingVotingPhase' }, - ], - }); + const considerationPhase = await ConsiderationPhase.findOne({ where: { fundingRoundId } }); + if (considerationPhase && now >= considerationPhase.startAt && now <= considerationPhase.endAt) { + activePhases.push('consideration'); } - 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']] - }); + const deliberationPhase = await DeliberationPhase.findOne({ where: { fundingRoundId } }); + if (deliberationPhase && now >= deliberationPhase.startAt && now <= deliberationPhase.endAt) { + activePhases.push('deliberation'); } - 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()}`); - } - } + const fundingVotingPhase = await FundingVotingPhase.findOne({ where: { fundingRoundId } }); + if (fundingVotingPhase && now >= fundingVotingPhase.startAt && now <= fundingVotingPhase.endAt) { + activePhases.push('funding'); } - 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.'); - } + return activePhases; + } - } - - } + static async getActiveProposalsForPhase(fundingRoundId: number, phase: string): Promise { + const fundingRound = await this.getFundingRoundById(fundingRoundId); + if (!fundingRound) { + throw new EndUserError('Funding round not found'); } + let status: ProposalStatus; + switch (phase.toLowerCase()) { + case 'consideration': + status = ProposalStatus.CONSIDERATION_PHASE; + break; + case 'deliberation': + status = ProposalStatus.DELIBERATION_PHASE; + break; + case 'funding': + status = ProposalStatus.FUNDING_VOTING_PHASE; + break; + default: + throw new EndUserError('Invalid phase'); + } + return await Proposal.findAll({ + where: { + fundingRoundId, + status, + }, + }); + } - 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); + static async getVotingAndApprovedFundingRounds(): Promise { + const now = new Date(); + return await FundingRound.findAll({ + where: { + [Op.or]: [ + { + status: FundingRoundStatus.VOTING, + votingOpenUntil: { + [Op.gte]: now, + }, + }, + { + status: FundingRoundStatus.APPROVED, + endAt: { + [Op.gt]: now, + }, + startAt: { + [Op.lte]: now, + }, + }, + ], + }, + include: [ + { model: ConsiderationPhase, required: true, as: 'considerationPhase' }, + { model: DeliberationPhase, required: true, as: 'deliberationPhase' }, + { model: FundingVotingPhase, required: true, as: 'fundingVotingPhase' }, + ], + }); + } + + 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']], + }); + } + + 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()}`, + ); + } - await fundingRound.update({ startAt, endAt, votingOpenUntil }); - return fundingRound; + 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 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 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}`); } - 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; + if (fundingRound.startAt && newStartDate <= fundingRound.startAt) { + throw new EndUserError( + `Start date must be after funding round start date, and ${newStartDate.toUTCString()} <= ${fundingRound.startAt.toUTCString()}`, + ); } - static async setTopic(fundingRoundId: number, topicId: number): Promise { - const { TopicLogic } = await import("../../../logic/TopicLogic"); + if (fundingRound.endAt && newEndDate >= fundingRound.endAt) { + throw new EndUserError( + `End date must be before funding round end date, and ${newEndDate.toUTCString()} >= ${fundingRound.endAt.toUTCString()}`, + ); + } - const fundingRound = await this.getFundingRoundByIdOrError(fundingRoundId); - const topic = await TopicLogic.getByIdOrError(topicId); + if (phase === FundingRoundMI.PHASES.CONSIDERATION) { + logger.debug(`Validating consideration phase dates for funding round ${fundingRoundId}...`); - return await fundingRound.update({ topicId: topic.id }); - } + // Ensure that the consideration phase ends before the Debliberation phase starts + const deliberationPhase = await this.getFundingRoundPhase(fundingRoundId, FundingRoundMI.PHASES.DELIBERATION); - static async getFundingRoundsForUser(duid: string): Promise { - // Get all SMEGroups the user belongs to - const userSMEGroups = await SMEGroupMembership.findAll({ - where: { duid }, - attributes: ['smeGroupId'] - }); + if (deliberationPhase) { + logger.debug(`\tChecking deliberation phase...`); + if (newEndDate >= deliberationPhase.startAt) { + throw new EndUserError('Consideration phase must end before deliberation phase starts.'); + } + } - const userSMEGroupIds = userSMEGroups.map(group => group.smeGroupId); + // Ensure that the consideration ends (and starts) before the Voting phase starts + const votingPhase = await this.getFundingRoundPhase(fundingRoundId, FundingRoundMI.PHASES.VOTING); - // Find all TopicCommittees associated with the user's SMEGroups - const relevantTopicCommittees = await TopicCommittee.findAll({ - where: { - smeGroupId: { - [Op.in]: userSMEGroupIds - } - }, - attributes: ['topicId'] - }); + 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); - const relevantTopicIds = relevantTopicCommittees.map(committee => committee.topicId); + if (considerationPhase) { + logger.debug(`\tChecking consideration phase...`); + if (newStartDate <= considerationPhase.endAt) { + throw new EndUserError('Deliberation phase must start after consideration phase ends.'); + } + } - // Find all FundingRounds associated with these Topics - const fundingRounds = await FundingRound.findAll({ - where: { - topicId: { - [Op.in]: relevantTopicIds - } - }, - include: [ - { - model: Topic, - as: 'topic' - } - ] - }); + // Ensure that the deliberation phase starts and ends before the Voting phase starts + const votingPhase = await this.getFundingRoundPhase(fundingRoundId, FundingRoundMI.PHASES.VOTING); - return fundingRounds; - } -} \ No newline at end of file + 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('../../../logic/TopicLogic'); + + const fundingRound = await this.getFundingRoundByIdOrError(fundingRoundId); + const topic = await TopicLogic.getByIdOrError(topicId); + + return await fundingRound.update({ topicId: topic.id }); + } + + static async getFundingRoundsForUser(duid: string): Promise { + // Get all SMEGroups the user belongs to + const userSMEGroups = await SMEGroupMembership.findAll({ + where: { duid }, + attributes: ['smeGroupId'], + }); + + const userSMEGroupIds = userSMEGroups.map((group) => group.smeGroupId); + + // Find all TopicCommittees associated with the user's SMEGroups + const relevantTopicCommittees = await TopicCommittee.findAll({ + where: { + smeGroupId: { + [Op.in]: userSMEGroupIds, + }, + }, + attributes: ['topicId'], + }); + + const relevantTopicIds = relevantTopicCommittees.map((committee) => committee.topicId); + + // Find all FundingRounds associated with these Topics + const fundingRounds = await FundingRound.findAll({ + where: { + topicId: { + [Op.in]: relevantTopicIds, + }, + }, + include: [ + { + model: Topic, + as: 'topic', + }, + ], + }); + + return fundingRounds; + } + + public static async getAllFundingRounds(): Promise { + return await FundingRound.findAll({ + order: [['createdAt', 'DESC']], + }); + } + + static async getProposalsForFundingRound(fundingRoundId: number): Promise { + const fundingRound = await this.getFundingRoundByIdOrError(fundingRoundId); + + return await Proposal.findAll({ + where: { fundingRoundId: fundingRound.id }, + order: [['createdAt', 'ASC']], + }); + } +} diff --git a/src/components/FundingRoundPaginator.ts b/src/components/FundingRoundPaginator.ts index 48f99fe..e0ef227 100644 --- a/src/components/FundingRoundPaginator.ts +++ b/src/components/FundingRoundPaginator.ts @@ -100,3 +100,13 @@ export class ActiveFundingRoundPaginator extends FundingRoundPaginator { return await FundingRoundLogic.getActiveFundingRounds(); } } + +export class AllFundingRoundsPaginator extends FundingRoundPaginator { + public static readonly ID = 'allFRPag'; + public args: string[] = []; + public title: string = 'Select a Funding Round'; + + public async getItems(interaction: TrackedInteraction): Promise { + return await FundingRoundLogic.getAllFundingRounds(); + } +} diff --git a/src/logic/VoteCountingLogic.ts b/src/logic/VoteCountingLogic.ts new file mode 100644 index 0000000..6d35a0a --- /dev/null +++ b/src/logic/VoteCountingLogic.ts @@ -0,0 +1,95 @@ +import { FundingRound, Proposal, SMEConsiderationVoteLog, CommitteeDeliberationVoteLog } from '../models'; +import { EndUserError } from '../Errors'; +import { CommitteeDeliberationVoteChoice } from '../types'; + +interface VoteResult { + projectId: number; + projectName: string; + proposerDuid: string; + yesVotes: number; + noVotes: number; + approvedModifiedVotes?: number; // Only for deliberation phase +} + +export class VoteCountingLogic { + public static async countVotes(fundingRoundId: number, phase: string): Promise { + const fundingRound = await FundingRound.findByPk(fundingRoundId, { include: [Proposal] }); + if (!fundingRound) { + throw new EndUserError('Funding round not found'); + } + + const { FundingRoundLogic } = await import('../channels/admin/screens/FundingRoundLogic'); + const proposals = await FundingRoundLogic.getProposalsForFundingRound(fundingRoundId); + let voteResults: VoteResult[] = []; + + switch (phase) { + case 'consideration': + voteResults = await this.countConsiderationVotes(proposals); + break; + case 'deliberation': + voteResults = await this.countDeliberationVotes(proposals); + break; + case 'voting': + throw new EndUserError('Voting phase vote counting is not yet implemented'); + default: + throw new EndUserError(`Invalid phase selected: ${phase}`); + } + + return voteResults.sort((a, b) => { + if (a.yesVotes !== b.yesVotes) { + return b.yesVotes - a.yesVotes; // Sort by yes votes descending + } + if (a.noVotes !== b.noVotes) { + return b.noVotes - a.noVotes; // If yes votes are equal, sort by no votes descending + } + // If both yes and no votes are equal, sort by approved modified votes (if present) + return (b.approvedModifiedVotes || 0) - (a.approvedModifiedVotes || 0); + }); + } + + private static async countConsiderationVotes(proposals: Proposal[]): Promise { + return Promise.all( + proposals.map(async (proposal) => { + const yesVotes = await SMEConsiderationVoteLog.count({ + where: { proposalId: proposal.id, isPass: true }, + }); + const noVotes = await SMEConsiderationVoteLog.count({ + where: { proposalId: proposal.id, isPass: false }, + }); + + return { + projectId: proposal.id, + projectName: proposal.name, + proposerDuid: proposal.proposerDuid, + yesVotes, + noVotes, + }; + }), + ); + } + + private static async countDeliberationVotes(proposals: Proposal[]): Promise { + return Promise.all( + proposals.map(async (proposal) => { + const yesVotes = await CommitteeDeliberationVoteLog.count({ + where: { proposalId: proposal.id, vote: CommitteeDeliberationVoteChoice.APPROVED }, + }); + const noVotes = await CommitteeDeliberationVoteLog.count({ + where: { proposalId: proposal.id, vote: CommitteeDeliberationVoteChoice.REJECTED }, + }); + const approvedModifiedVotes = await CommitteeDeliberationVoteLog.count({ + where: { proposalId: proposal.id, vote: CommitteeDeliberationVoteChoice.APPROVED_MODIFIED }, + }); + + return { + projectId: proposal.id, + projectName: proposal.name, + proposerDuid: proposal.proposerDuid, + yesVotes, + noVotes, + approvedModifiedVotes, + }; + }), + ); + } +} diff --git a/src/logic/VoteLogic.ts b/src/logic/VoteLogic.ts index 9c3df7c..993ba4d 100644 --- a/src/logic/VoteLogic.ts +++ b/src/logic/VoteLogic.ts @@ -6,7 +6,6 @@ import { FundingRoundStatus, ProposalStatus } from '../types'; import { FundingRoundLogic } from '../channels/admin/screens/FundingRoundLogic'; import { EndUserError } from '../Errors'; - export class VoteLogic { static async voteFundingRound(userId: string, fundingRoundId: number): Promise { const fundingRound = await FundingRoundLogic.getFundingRoundById(fundingRoundId); @@ -50,7 +49,13 @@ export class VoteLogic { }); } - static async submitDeliberationReasoning(userId: string, projectId: number, fundingRoundId: number, reasoning: string, reason: string | null = null): Promise { + static async submitDeliberationReasoning( + userId: string, + projectId: number, + fundingRoundId: number, + reasoning: string, + reason: string | null = null, + ): Promise { const proposal = await ProposalLogic.getProposalById(projectId); if (!proposal) { throw new EndUserError('Project not found'); @@ -194,4 +199,4 @@ export class VoteLogic { return { hasVoted: true, isPass: voteLog.isPass }; } -} \ No newline at end of file +}