From f97507d985ae89699b59d9ed0608c5a50a194e74 Mon Sep 17 00:00:00 2001 From: Illya Gerasymchuk Date: Fri, 6 Sep 2024 16:57:15 +0100 Subject: [PATCH 1/4] feat: expand logic of vote coutning with username display --- src/logic/VoteCountingLogic.ts | 78 +++++++++++++++++++++++++++------- 1 file changed, 62 insertions(+), 16 deletions(-) diff --git a/src/logic/VoteCountingLogic.ts b/src/logic/VoteCountingLogic.ts index 6d35a0a..30290aa 100644 --- a/src/logic/VoteCountingLogic.ts +++ b/src/logic/VoteCountingLogic.ts @@ -1,6 +1,8 @@ import { FundingRound, Proposal, SMEConsiderationVoteLog, CommitteeDeliberationVoteLog } from '../models'; import { EndUserError } from '../Errors'; import { CommitteeDeliberationVoteChoice } from '../types'; +import { TrackedInteraction } from '../core/BaseClasses'; +import logger from '../logging'; interface VoteResult { projectId: number; @@ -8,11 +10,14 @@ interface VoteResult { proposerDuid: string; yesVotes: number; noVotes: number; - approvedModifiedVotes?: number; // Only for deliberation phase + approvedModifiedVotes?: number; + yesVoters: string[]; + noVoters: string[]; + approvedModifiedVoters?: string[]; } export class VoteCountingLogic { - public static async countVotes(fundingRoundId: number, phase: string): Promise { + public static async countVotes(fundingRoundId: number, phase: string, trackedInteraction: TrackedInteraction): Promise { const fundingRound = await FundingRound.findByPk(fundingRoundId, { include: [Proposal] }); if (!fundingRound) { throw new EndUserError('Funding round not found'); @@ -24,10 +29,10 @@ export class VoteCountingLogic { switch (phase) { case 'consideration': - voteResults = await this.countConsiderationVotes(proposals); + voteResults = await this.countConsiderationVotes(proposals, trackedInteraction); break; case 'deliberation': - voteResults = await this.countDeliberationVotes(proposals); + voteResults = await this.countDeliberationVotes(proposals, trackedInteraction); break; case 'voting': throw new EndUserError('Voting phase vote counting is not yet implemented'); @@ -47,47 +52,88 @@ export class VoteCountingLogic { }); } - private static async countConsiderationVotes(proposals: Proposal[]): Promise { + private static async getVoterUsernames(trackedInteraction: TrackedInteraction, duids: string[]): Promise { + const usernames: string[] = []; + for (const duid of duids) { + try { + const user = await trackedInteraction.interaction.client.users.fetch(duid); + usernames.push(user.username); + } catch (error) { + logger.error(`Error fetching user ${duid}:`, error); + usernames.push(duid); // Fallback to using DUID if username can't be fetched + } + } + return usernames; + } + + private static async countConsiderationVotes(proposals: Proposal[], trackedInteraction: TrackedInteraction): Promise { return Promise.all( proposals.map(async (proposal) => { - const yesVotes = await SMEConsiderationVoteLog.count({ + const yesVotes = await SMEConsiderationVoteLog.findAll({ where: { proposalId: proposal.id, isPass: true }, }); - const noVotes = await SMEConsiderationVoteLog.count({ + const noVotes = await SMEConsiderationVoteLog.findAll({ where: { proposalId: proposal.id, isPass: false }, }); + const yesVoters = await this.getVoterUsernames( + trackedInteraction, + yesVotes.map((vote) => vote.duid), + ); + const noVoters = await this.getVoterUsernames( + trackedInteraction, + noVotes.map((vote) => vote.duid), + ); + return { projectId: proposal.id, projectName: proposal.name, proposerDuid: proposal.proposerDuid, - yesVotes, - noVotes, + yesVotes: yesVotes.length, + noVotes: noVotes.length, + yesVoters, + noVoters, }; }), ); } - private static async countDeliberationVotes(proposals: Proposal[]): Promise { + private static async countDeliberationVotes(proposals: Proposal[], trackedInteraction: TrackedInteraction): Promise { return Promise.all( proposals.map(async (proposal) => { - const yesVotes = await CommitteeDeliberationVoteLog.count({ + const yesVotes = await CommitteeDeliberationVoteLog.findAll({ where: { proposalId: proposal.id, vote: CommitteeDeliberationVoteChoice.APPROVED }, }); - const noVotes = await CommitteeDeliberationVoteLog.count({ + const noVotes = await CommitteeDeliberationVoteLog.findAll({ where: { proposalId: proposal.id, vote: CommitteeDeliberationVoteChoice.REJECTED }, }); - const approvedModifiedVotes = await CommitteeDeliberationVoteLog.count({ + const approvedModifiedVotes = await CommitteeDeliberationVoteLog.findAll({ where: { proposalId: proposal.id, vote: CommitteeDeliberationVoteChoice.APPROVED_MODIFIED }, }); + const yesVoters = await this.getVoterUsernames( + trackedInteraction, + yesVotes.map((vote) => vote.duid), + ); + const noVoters = await this.getVoterUsernames( + trackedInteraction, + noVotes.map((vote) => vote.duid), + ); + const approvedModifiedVoters = await this.getVoterUsernames( + trackedInteraction, + approvedModifiedVotes.map((vote) => vote.duid), + ); + return { projectId: proposal.id, projectName: proposal.name, proposerDuid: proposal.proposerDuid, - yesVotes, - noVotes, - approvedModifiedVotes, + yesVotes: yesVotes.length, + noVotes: noVotes.length, + approvedModifiedVotes: approvedModifiedVotes.length, + yesVoters, + noVoters, + approvedModifiedVoters, }; }), ); From 235dc8d6fa16379cda75085d0dbe07718540a1af Mon Sep 17 00:00:00 2001 From: Illya Gerasymchuk Date: Fri, 6 Sep 2024 16:58:21 +0100 Subject: [PATCH 2/4] feat: integrate username display into votes --- .../admin/actions/CountVotesAction.ts | 60 ++++++++++++++----- 1 file changed, 45 insertions(+), 15 deletions(-) diff --git a/src/channels/admin/actions/CountVotesAction.ts b/src/channels/admin/actions/CountVotesAction.ts index 9a64437..86dd2d9 100644 --- a/src/channels/admin/actions/CountVotesAction.ts +++ b/src/channels/admin/actions/CountVotesAction.ts @@ -6,6 +6,7 @@ import { VoteCountingLogic } from '../../../logic/VoteCountingLogic'; import { EndUserError } from '../../../Errors'; import { AnyModalMessageComponent } from '../../../types/common'; import { DiscordStatus } from '../../DiscordStatus'; +import { Client } from 'discord.js'; export class CountVotesAction extends Action { public allSubActions(): Action[] { @@ -66,28 +67,57 @@ export class CountVotesAction extends Action { } private async handleCountVotes(interaction: TrackedInteraction): Promise { + await interaction.interaction.deferReply(); + 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 progressEmbed = new EmbedBuilder().setColor('#0099ff').setTitle('Counting Votes').setDescription('Please wait while we count the votes...'); - 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:'); + await interaction.interaction.editReply({ embeds: [progressEmbed], components: [] }); - 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}`; - } + let updateCounter = 0; + const updateInterval = setInterval(async () => { + updateCounter++; + progressEmbed.setDescription(`Please wait while we count the votes...\nTime elapsed: ${updateCounter * 5} seconds`); + await interaction.interaction.editReply({ embeds: [progressEmbed] }); + }, 5000); + + try { + const voteResults = await VoteCountingLogic.countVotes(parseInt(fundingRoundId), phase, interaction); + + clearInterval(updateInterval); - embed.addFields({ - name: `${index + 1}. ${result.projectName} (ID: ${result.projectId})`, - value: `Proposer: ${result.proposerDuid}\n${voteInfo}`, + const resultEmbed = 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}`; + } + + let voterInfo = `Yes Voters: ${result.yesVoters.join(', ')}\nNo Voters: ${result.noVoters.join(', ')}`; + if (phase === 'deliberation' && result.approvedModifiedVoters) { + voterInfo += `\nApproved Modified Voters: ${result.approvedModifiedVoters.join(', ')}`; + } + + resultEmbed.addFields({ + name: `${index + 1}. ${result.projectName} (ID: ${result.projectId})`, + value: `Proposer: ${result.proposerDuid}\n${voteInfo}\n\n${voterInfo}`, + }); }); - }); - await interaction.update({ embeds: [embed], components: [] }); + await interaction.interaction.editReply({ embeds: [resultEmbed], components: [] }); + } catch (error) { + clearInterval(updateInterval); + if (error instanceof EndUserError) { + throw error; + } else { + throw new EndUserError('An unexpected error occurred while counting votes.'); + } + } } } From 4d97c79eb479e3edf4c06375f3995c56c0414947 Mon Sep 17 00:00:00 2001 From: Illya Gerasymchuk Date: Fri, 6 Sep 2024 17:23:34 +0100 Subject: [PATCH 3/4] #admin feat: show proposer's username in vote counting, and correct counting logic --- .../admin/actions/CountVotesAction.ts | 11 +- src/logic/VoteCountingLogic.ts | 129 +++++++++++------- 2 files changed, 91 insertions(+), 49 deletions(-) diff --git a/src/channels/admin/actions/CountVotesAction.ts b/src/channels/admin/actions/CountVotesAction.ts index 86dd2d9..e9e5362 100644 --- a/src/channels/admin/actions/CountVotesAction.ts +++ b/src/channels/admin/actions/CountVotesAction.ts @@ -72,14 +72,19 @@ export class CountVotesAction extends Action { const fundingRoundId = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.FUNDING_ROUND_ID); const phase = ArgumentOracle.getNamedArgument(interaction, ArgumentOracle.COMMON_ARGS.PHASE, 0); - const progressEmbed = new EmbedBuilder().setColor('#0099ff').setTitle('Counting Votes').setDescription('Please wait while we count the votes...'); + const progressEmbed = new EmbedBuilder() + .setColor('#0099ff') + .setTitle('Counting Votes') + .setDescription('Please wait while our quantum computers count all possible vote outcomes...'); await interaction.interaction.editReply({ embeds: [progressEmbed], components: [] }); let updateCounter = 0; const updateInterval = setInterval(async () => { updateCounter++; - progressEmbed.setDescription(`Please wait while we count the votes...\nTime elapsed: ${updateCounter * 5} seconds`); + progressEmbed.setDescription( + `Please wait while our quantum computers count all possible vote outcomes...\nTime elapsed: ${updateCounter * 5} seconds`, + ); await interaction.interaction.editReply({ embeds: [progressEmbed] }); }, 5000); @@ -106,7 +111,7 @@ export class CountVotesAction extends Action { resultEmbed.addFields({ name: `${index + 1}. ${result.projectName} (ID: ${result.projectId})`, - value: `Proposer: ${result.proposerDuid}\n${voteInfo}\n\n${voterInfo}`, + value: `Proposer: ${result.proposerUsername}\n${voteInfo}\n\n${voterInfo}`, }); }); diff --git a/src/logic/VoteCountingLogic.ts b/src/logic/VoteCountingLogic.ts index 30290aa..805ebca 100644 --- a/src/logic/VoteCountingLogic.ts +++ b/src/logic/VoteCountingLogic.ts @@ -3,11 +3,12 @@ import { EndUserError } from '../Errors'; import { CommitteeDeliberationVoteChoice } from '../types'; import { TrackedInteraction } from '../core/BaseClasses'; import logger from '../logging'; +import { Op } from 'sequelize'; interface VoteResult { projectId: number; projectName: string; - proposerDuid: string; + proposerUsername: string; yesVotes: number; noVotes: number; approvedModifiedVotes?: number; @@ -52,12 +53,22 @@ export class VoteCountingLogic { }); } + private static async getUsername(trackedInteraction: TrackedInteraction, duid: string): Promise { + try { + const user = await trackedInteraction.interaction.client.users.fetch(duid); + return user.username; + } catch (error) { + logger.error(`Error fetching user ${duid}:`, error); + return duid; // Fallback to using DUID if username can't be fetched + } + } + private static async getVoterUsernames(trackedInteraction: TrackedInteraction, duids: string[]): Promise { const usernames: string[] = []; for (const duid of duids) { try { - const user = await trackedInteraction.interaction.client.users.fetch(duid); - usernames.push(user.username); + const username: string = await this.getUsername(trackedInteraction, duid); + usernames.push(username); } catch (error) { logger.error(`Error fetching user ${duid}:`, error); usernames.push(duid); // Fallback to using DUID if username can't be fetched @@ -69,30 +80,42 @@ export class VoteCountingLogic { private static async countConsiderationVotes(proposals: Proposal[], trackedInteraction: TrackedInteraction): Promise { return Promise.all( proposals.map(async (proposal) => { - const yesVotes = await SMEConsiderationVoteLog.findAll({ - where: { proposalId: proposal.id, isPass: true }, + const allVotes = await SMEConsiderationVoteLog.findAll({ + where: { proposalId: proposal.id }, + order: [['createdAt', 'DESC']], }); - const noVotes = await SMEConsiderationVoteLog.findAll({ - where: { proposalId: proposal.id, isPass: false }, + + const latestVotes = new Map(); + const yesVoters: string[] = []; + const noVoters: string[] = []; + + allVotes.forEach((vote) => { + if (!latestVotes.has(vote.duid)) { + latestVotes.set(vote.duid, vote.isPass); + if (vote.isPass) { + yesVoters.push(vote.duid); + } else { + noVoters.push(vote.duid); + } + } }); - const yesVoters = await this.getVoterUsernames( - trackedInteraction, - yesVotes.map((vote) => vote.duid), - ); - const noVoters = await this.getVoterUsernames( - trackedInteraction, - noVotes.map((vote) => vote.duid), - ); + const yesVotes = yesVoters.length; + const noVotes = noVoters.length; + + const yesVoterUsernames = await this.getVoterUsernames(trackedInteraction, yesVoters); + const noVoterUsernames = await this.getVoterUsernames(trackedInteraction, noVoters); + + const proposerUsername = await this.getUsername(trackedInteraction, proposal.proposerDuid); return { projectId: proposal.id, projectName: proposal.name, - proposerDuid: proposal.proposerDuid, - yesVotes: yesVotes.length, - noVotes: noVotes.length, - yesVoters, - noVoters, + proposerUsername, + yesVotes, + noVotes, + yesVoters: yesVoterUsernames, + noVoters: noVoterUsernames, }; }), ); @@ -101,39 +124,53 @@ export class VoteCountingLogic { private static async countDeliberationVotes(proposals: Proposal[], trackedInteraction: TrackedInteraction): Promise { return Promise.all( proposals.map(async (proposal) => { - const yesVotes = await CommitteeDeliberationVoteLog.findAll({ - where: { proposalId: proposal.id, vote: CommitteeDeliberationVoteChoice.APPROVED }, - }); - const noVotes = await CommitteeDeliberationVoteLog.findAll({ - where: { proposalId: proposal.id, vote: CommitteeDeliberationVoteChoice.REJECTED }, + const allVotes = await CommitteeDeliberationVoteLog.findAll({ + where: { proposalId: proposal.id }, + order: [['createdAt', 'DESC']], }); - const approvedModifiedVotes = await CommitteeDeliberationVoteLog.findAll({ - where: { proposalId: proposal.id, vote: CommitteeDeliberationVoteChoice.APPROVED_MODIFIED }, + + const latestVotes = new Map(); + const yesVoters: string[] = []; + const noVoters: string[] = []; + const approvedModifiedVoters: string[] = []; + + allVotes.forEach((vote) => { + if (!latestVotes.has(vote.duid)) { + latestVotes.set(vote.duid, vote.vote); + switch (vote.vote) { + case CommitteeDeliberationVoteChoice.APPROVED: + yesVoters.push(vote.duid); + break; + case CommitteeDeliberationVoteChoice.REJECTED: + noVoters.push(vote.duid); + break; + case CommitteeDeliberationVoteChoice.APPROVED_MODIFIED: + approvedModifiedVoters.push(vote.duid); + break; + } + } }); - const yesVoters = await this.getVoterUsernames( - trackedInteraction, - yesVotes.map((vote) => vote.duid), - ); - const noVoters = await this.getVoterUsernames( - trackedInteraction, - noVotes.map((vote) => vote.duid), - ); - const approvedModifiedVoters = await this.getVoterUsernames( - trackedInteraction, - approvedModifiedVotes.map((vote) => vote.duid), - ); + const yesVotes = yesVoters.length; + const noVotes = noVoters.length; + const approvedModifiedVotes = approvedModifiedVoters.length; + + const yesVoterUsernames = await this.getVoterUsernames(trackedInteraction, yesVoters); + const noVoterUsernames = await this.getVoterUsernames(trackedInteraction, noVoters); + const approvedModifiedVoterUsernames = await this.getVoterUsernames(trackedInteraction, approvedModifiedVoters); + + const proposerUsername = await this.getUsername(trackedInteraction, proposal.proposerDuid); return { projectId: proposal.id, projectName: proposal.name, - proposerDuid: proposal.proposerDuid, - yesVotes: yesVotes.length, - noVotes: noVotes.length, - approvedModifiedVotes: approvedModifiedVotes.length, - yesVoters, - noVoters, - approvedModifiedVoters, + proposerUsername, + yesVotes, + noVotes, + approvedModifiedVotes, + yesVoters: yesVoterUsernames, + noVoters: noVoterUsernames, + approvedModifiedVoters: approvedModifiedVoterUsernames, }; }), ); From 847c1499ce0d2f0c4cf6b369d8c39563a0c24f6d Mon Sep 17 00:00:00 2001 From: Illya Gerasymchuk Date: Fri, 6 Sep 2024 17:24:17 +0100 Subject: [PATCH 4/4] chore: bump version to 0.0.13 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 874f6aa..089696d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mina-govbot", - "version": "0.0.12", + "version": "0.0.13", "description": "Discord bot for collective decision making for Mina Protocol", "main": "index.js", "directories": {