From 99894adbff4e6cdb06707c4cd3e498672f09d336 Mon Sep 17 00:00:00 2001 From: devbyte Date: Mon, 30 Mar 2026 06:53:16 -0400 Subject: [PATCH] feat(governance): implement governance analytics API #543 - Added GET /governance/analytics/participation for voter turnout stats - Added GET /governance/analytics/proposals for proposal success rates - Added GET /governance/analytics/top-voters for most active participants - Added GET /governance/analytics/trends for historical governance data - Added GET /governance/analytics/export for research data export - Implemented ParticipationStatsDto, ProposalAnalyticsDto, TopVoterDto, and GovernanceTrendDto - Added unit tests for GovernanceAnalyticsService Closes #543 --- .../governance/dto/governance-trend.dto.ts | 20 ++ .../governance/dto/participation-stats.dto.ts | 18 ++ .../governance/dto/proposal-analytics.dto.ts | 33 +++ .../modules/governance/dto/top-voter.dto.ts | 15 ++ .../governance-analytics.controller.ts | 52 +++++ .../governance-analytics.service.spec.ts | 114 +++++++++++ .../governance-analytics.service.ts | 188 ++++++++++++++++++ .../modules/governance/governance.module.ts | 14 +- 8 files changed, 452 insertions(+), 2 deletions(-) create mode 100644 backend/src/modules/governance/dto/governance-trend.dto.ts create mode 100644 backend/src/modules/governance/dto/participation-stats.dto.ts create mode 100644 backend/src/modules/governance/dto/proposal-analytics.dto.ts create mode 100644 backend/src/modules/governance/dto/top-voter.dto.ts create mode 100644 backend/src/modules/governance/governance-analytics.controller.ts create mode 100644 backend/src/modules/governance/governance-analytics.service.spec.ts create mode 100644 backend/src/modules/governance/governance-analytics.service.ts diff --git a/backend/src/modules/governance/dto/governance-trend.dto.ts b/backend/src/modules/governance/dto/governance-trend.dto.ts new file mode 100644 index 000000000..4aa1a3917 --- /dev/null +++ b/backend/src/modules/governance/dto/governance-trend.dto.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class TrendDataPoint { + @ApiProperty({ description: 'The time interval (e.g. 2026-03)' }) + interval: string; + + @ApiProperty({ description: 'Number of proposals created' }) + proposalsCount: number; + + @ApiProperty({ description: 'Number of votes cast' }) + votesCount: number; + + @ApiProperty({ description: 'Total voting power used' }) + totalWeight: string; +} + +export class GovernanceTrendDto { + @ApiProperty({ type: [TrendDataPoint] }) + trends: TrendDataPoint[]; +} diff --git a/backend/src/modules/governance/dto/participation-stats.dto.ts b/backend/src/modules/governance/dto/participation-stats.dto.ts new file mode 100644 index 000000000..411a66332 --- /dev/null +++ b/backend/src/modules/governance/dto/participation-stats.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class ParticipationStatsDto { + @ApiProperty({ description: 'Total number of unique voters' }) + totalUniqueVoters: number; + + @ApiProperty({ description: 'Average number of voters per proposal' }) + averageVotersPerProposal: number; + + @ApiProperty({ description: 'Percentage of proposals that reached quorum' }) + quorumAchievementRate: number; + + @ApiProperty({ description: 'Total votes cast across all proposals' }) + totalVotesCast: number; + + @ApiProperty({ description: 'Current active voters (voted in last 30 days)' }) + activeVoters: number; +} diff --git a/backend/src/modules/governance/dto/proposal-analytics.dto.ts b/backend/src/modules/governance/dto/proposal-analytics.dto.ts new file mode 100644 index 000000000..b948b569d --- /dev/null +++ b/backend/src/modules/governance/dto/proposal-analytics.dto.ts @@ -0,0 +1,33 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ProposalCategory } from '../entities/governance-proposal.entity'; + +export class CategorySuccessRate { + @ApiProperty({ enum: ProposalCategory }) + category: ProposalCategory; + + @ApiProperty({ description: 'Number of passed proposals' }) + passed: number; + + @ApiProperty({ description: 'Number of failed proposals' }) + failed: number; + + @ApiProperty({ description: 'Percentage of passed proposals' }) + successRate: number; +} + +export class ProposalAnalyticsDto { + @ApiProperty({ description: 'Total number of proposals' }) + totalProposals: number; + + @ApiProperty({ description: 'Total number of passed proposals' }) + passedProposals: number; + + @ApiProperty({ description: 'Percentage of passed proposals' }) + overallSuccessRate: number; + + @ApiProperty({ description: 'Average voting power per proposal' }) + averageVotingPower: string; + + @ApiProperty({ type: [CategorySuccessRate] }) + categoryBreakdown: CategorySuccessRate[]; +} diff --git a/backend/src/modules/governance/dto/top-voter.dto.ts b/backend/src/modules/governance/dto/top-voter.dto.ts new file mode 100644 index 000000000..355670958 --- /dev/null +++ b/backend/src/modules/governance/dto/top-voter.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class TopVoterDto { + @ApiProperty({ description: 'The wallet address of the voter' }) + walletAddress: string; + + @ApiProperty({ description: 'Number of unique proposals voted on' }) + voteCount: number; + + @ApiProperty({ description: 'Total voting power used across all proposals' }) + totalWeight: string; + + @ApiProperty({ description: 'Rank of the voter based on activity' }) + rank: number; +} diff --git a/backend/src/modules/governance/governance-analytics.controller.ts b/backend/src/modules/governance/governance-analytics.controller.ts new file mode 100644 index 000000000..349421255 --- /dev/null +++ b/backend/src/modules/governance/governance-analytics.controller.ts @@ -0,0 +1,52 @@ +import { Controller, Get, Res } from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Response } from 'express'; +import { GovernanceAnalyticsService } from './governance-analytics.service'; +import { ParticipationStatsDto } from './dto/participation-stats.dto'; +import { ProposalAnalyticsDto } from './dto/proposal-analytics.dto'; +import { TopVoterDto } from './dto/top-voter.dto'; +import { GovernanceTrendDto } from './dto/governance-trend.dto'; + +@ApiTags('governance-analytics') +@Controller('governance/analytics') +export class GovernanceAnalyticsController { + constructor(private readonly analyticsService: GovernanceAnalyticsService) {} + + @Get('participation') + @ApiOperation({ summary: 'Get voter turnout and participation stats' }) + @ApiResponse({ status: 200, type: ParticipationStatsDto }) + async getParticipationStats(): Promise { + return this.analyticsService.getParticipationStats(); + } + + @Get('proposals') + @ApiOperation({ summary: 'Get proposal success rates and category breakdown' }) + @ApiResponse({ status: 200, type: ProposalAnalyticsDto }) + async getProposalAnalytics(): Promise { + return this.analyticsService.getProposalAnalytics(); + } + + @Get('top-voters') + @ApiOperation({ summary: 'Get the most active governance participants' }) + @ApiResponse({ status: 200, type: [TopVoterDto] }) + async getTopVoters(): Promise { + return this.analyticsService.getTopVoters(); + } + + @Get('trends') + @ApiOperation({ summary: 'Get governance trends over time' }) + @ApiResponse({ status: 200, type: GovernanceTrendDto }) + async getTrends(): Promise { + return this.analyticsService.getTrends(); + } + + @Get('export') + @ApiOperation({ summary: 'Export governance data for research (JSON)' }) + @ApiResponse({ status: 200, description: 'JSON download of governance historical data' }) + async exportData(@Res() res: Response): Promise { + const data = await this.analyticsService.exportData(); + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Content-Disposition', 'attachment; filename=governance-export.json'); + res.send(JSON.stringify(data, null, 2)); + } +} diff --git a/backend/src/modules/governance/governance-analytics.service.spec.ts b/backend/src/modules/governance/governance-analytics.service.spec.ts new file mode 100644 index 000000000..d6eaa9abe --- /dev/null +++ b/backend/src/modules/governance/governance-analytics.service.spec.ts @@ -0,0 +1,114 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { GovernanceAnalyticsService } from './governance-analytics.service'; +import { GovernanceProposal, ProposalStatus } from './entities/governance-proposal.entity'; +import { Vote } from './entities/vote.entity'; + +describe('GovernanceAnalyticsService', () => { + let service: GovernanceAnalyticsService; + let proposalRepo: any; + let voteRepo: any; + + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + having: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + getRawOne: jest.fn(), + getRawMany: jest.fn(), + }; + + beforeEach(async () => { + proposalRepo = { + count: jest.fn(), + find: jest.fn(), + createQueryBuilder: jest.fn(() => mockQueryBuilder), + }; + voteRepo = { + count: jest.fn(), + createQueryBuilder: jest.fn(() => mockQueryBuilder), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GovernanceAnalyticsService, + { + provide: getRepositoryToken(GovernanceProposal), + useValue: proposalRepo, + }, + { + provide: getRepositoryToken(Vote), + useValue: voteRepo, + }, + ], + }).compile(); + + service = module.get(GovernanceAnalyticsService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getParticipationStats', () => { + it('should return participation statistics', async () => { + mockQueryBuilder.getRawOne + .mockResolvedValueOnce({ count: '100' }) // totalUniqueVoters + .mockResolvedValueOnce({ count: '50' }); // activeVoters (last 30 days) + + voteRepo.count.mockResolvedValue(500); + proposalRepo.count.mockResolvedValue(10); + mockQueryBuilder.getRawMany.mockResolvedValue([{ proposalId: '1', totalWeight: '60000' }]); + + const result = await service.getParticipationStats(); + + expect(result.totalUniqueVoters).toBe(100); + expect(result.totalVotesCast).toBe(500); + expect(result.averageVotersPerProposal).toBe(50); + expect(result.quorumAchievementRate).toBe(10); // 1 out of 10 + expect(result.activeVoters).toBe(50); + }); + }); + + describe('getProposalAnalytics', () => { + it('should return proposal analytics', async () => { + proposalRepo.count + .mockResolvedValueOnce(20) // total + .mockResolvedValueOnce(15); // passed + + mockQueryBuilder.getRawOne.mockResolvedValue({ totalWeight: '1000000' }); + mockQueryBuilder.getRawMany.mockResolvedValue([ + { category: 'Governance', total: '10', passed: '8', failed: '2' }, + { category: 'Treasury', total: '10', passed: '7', failed: '3' }, + ]); + + const result = await service.getProposalAnalytics(); + + expect(result.totalProposals).toBe(20); + expect(result.passedProposals).toBe(15); + expect(result.overallSuccessRate).toBe(75); + expect(result.averageVotingPower).toBe('50000.00'); + expect(result.categoryBreakdown).toHaveLength(2); + expect(result.categoryBreakdown[0].successRate).toBe(80); + }); + }); + + describe('getTopVoters', () => { + it('should return top 10 voters', async () => { + mockQueryBuilder.getRawMany.mockResolvedValue([ + { walletAddress: 'addr1', voteCount: '5', totalWeight: '50000' }, + { walletAddress: 'addr2', voteCount: '4', totalWeight: '40000' }, + ]); + + const result = await service.getTopVoters(); + + expect(result).toHaveLength(2); + expect(result[0].walletAddress).toBe('addr1'); + expect(result[0].rank).toBe(1); + }); + }); +}); diff --git a/backend/src/modules/governance/governance-analytics.service.ts b/backend/src/modules/governance/governance-analytics.service.ts new file mode 100644 index 000000000..21959d317 --- /dev/null +++ b/backend/src/modules/governance/governance-analytics.service.ts @@ -0,0 +1,188 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { GovernanceProposal, ProposalStatus } from './entities/governance-proposal.entity'; +import { Vote } from './entities/vote.entity'; +import { ParticipationStatsDto } from './dto/participation-stats.dto'; +import { ProposalAnalyticsDto, CategorySuccessRate } from './dto/proposal-analytics.dto'; +import { TopVoterDto } from './dto/top-voter.dto'; +import { GovernanceTrendDto, TrendDataPoint } from './dto/governance-trend.dto'; + +@Injectable() +export class GovernanceAnalyticsService { + private readonly QUORUM_THRESHOLD = 50000; // Placeholder value (50,000 voting power) + + constructor( + @InjectRepository(GovernanceProposal) + private readonly proposalRepo: Repository, + @InjectRepository(Vote) + private readonly voteRepo: Repository, + ) {} + + async getParticipationStats(): Promise { + const totalUniqueVotersResult = await this.voteRepo + .createQueryBuilder('vote') + .select('COUNT(DISTINCT vote.walletAddress)', 'count') + .getRawOne(); + + const totalVotesResult = await this.voteRepo.count(); + + const totalProposals = await this.proposalRepo.count(); + const averageVotersPerProposal = totalProposals > 0 ? totalVotesResult / totalProposals : 0; + + // Calculate quorum achievement rate + const quorumReachedResult = await this.voteRepo + .createQueryBuilder('vote') + .select('vote.proposalId', 'proposalId') + .addSelect('SUM(vote.weight)', 'totalWeight') + .groupBy('vote.proposalId') + .having('SUM(vote.weight) >= :threshold', { threshold: this.QUORUM_THRESHOLD }) + .getRawMany(); + + const quorumAchievementRate = totalProposals > 0 + ? (quorumReachedResult.length / totalProposals) * 100 + : 0; + + // Active voters (voted in last 30 days) + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + const activeVotersResult = await this.voteRepo + .createQueryBuilder('vote') + .select('COUNT(DISTINCT vote.walletAddress)', 'count') + .where('vote.createdAt >= :date', { date: thirtyDaysAgo }) + .getRawOne(); + + return { + totalUniqueVoters: parseInt(totalUniqueVotersResult.count || '0', 10), + averageVotersPerProposal: Math.round(averageVotersPerProposal * 10) / 10, + quorumAchievementRate: Math.round(quorumAchievementRate * 10) / 10, + totalVotesCast: totalVotesResult, + activeVoters: parseInt(activeVotersResult.count || '0', 10), + }; + } + + async getProposalAnalytics(): Promise { + const totalProposals = await this.proposalRepo.count(); + const passedProposals = await this.proposalRepo.count({ where: { status: ProposalStatus.PASSED } }); + const overallSuccessRate = totalProposals > 0 ? (passedProposals / totalProposals) * 100 : 0; + + // Average voting power per proposal + const totalWeightResult = await this.voteRepo + .createQueryBuilder('vote') + .select('SUM(vote.weight)', 'totalWeight') + .getRawOne(); + + const averageVotingPower = totalProposals > 0 + ? parseFloat(totalWeightResult.totalWeight || '0') / totalProposals + : 0; + + // Break down by category + const categoryStats = await this.proposalRepo + .createQueryBuilder('proposal') + .select('proposal.category', 'category') + .addSelect('COUNT(*)', 'total') + .addSelect(`SUM(CASE WHEN proposal.status = '${ProposalStatus.PASSED}' THEN 1 ELSE 0 END)`, 'passed') + .addSelect(`SUM(CASE WHEN proposal.status = '${ProposalStatus.FAILED}' THEN 1 ELSE 0 END)`, 'failed') + .groupBy('proposal.category') + .getRawMany(); + + const categoryBreakdown: CategorySuccessRate[] = categoryStats.map(stat => ({ + category: stat.category, + passed: parseInt(stat.passed || '0', 10), + failed: parseInt(stat.failed || '0', 10), + successRate: parseInt(stat.total || '0', 10) > 0 + ? Math.round((parseInt(stat.passed || '0', 10) / parseInt(stat.total || '0', 10)) * 1000) / 10 + : 0, + })); + + return { + totalProposals, + passedProposals, + overallSuccessRate: Math.round(overallSuccessRate * 10) / 10, + averageVotingPower: averageVotingPower.toFixed(2), + categoryBreakdown, + }; + } + + async getTopVoters(): Promise { + const topVoters = await this.voteRepo + .createQueryBuilder('vote') + .select('vote.walletAddress', 'walletAddress') + .addSelect('COUNT(DISTINCT vote.proposalId)', 'voteCount') + .addSelect('SUM(vote.weight)', 'totalWeight') + .groupBy('vote.walletAddress') + .orderBy('voteCount', 'DESC') + .addOrderBy('totalWeight', 'DESC') + .limit(10) + .getRawMany(); + + return topVoters.map((voter, index) => ({ + walletAddress: voter.walletAddress, + voteCount: parseInt(voter.voteCount || '0', 10), + totalWeight: parseFloat(voter.totalWeight || '0').toFixed(2), + rank: index + 1, + })); + } + + async getTrends(): Promise { + // Grouping by Month (compatible with PostgreSQL/SQLite) + // Note: strftime for SQLite, TO_CHAR for PostgreSQL + // We'll use a generic approach with manual grouping for broad compatibility if needed, + // but here we assume PostgreSQL based on provide prisma mcp server or standard TypeORM usage. + + const rawProposals = await this.proposalRepo + .createQueryBuilder('proposal') + .select("TO_CHAR(proposal.createdAt, 'YYYY-MM')", 'interval') + .addSelect('COUNT(*)', 'count') + .groupBy('interval') + .orderBy('interval', 'ASC') + .getRawMany(); + + const rawVotes = await this.voteRepo + .createQueryBuilder('vote') + .select("TO_CHAR(vote.createdAt, 'YYYY-MM')", 'interval') + .addSelect('COUNT(*)', 'count') + .addSelect('SUM(vote.weight)', 'totalWeight') + .groupBy('interval') + .getRawMany(); + + const voteMap = new Map(rawVotes.map(v => [v.interval, v])); + const intervals = Array.from(new Set([...rawProposals.map(p => p.interval), ...rawVotes.map(v => v.interval)])); + + intervals.sort(); + + const trends: TrendDataPoint[] = intervals.map(interval => { + const propStat = rawProposals.find(p => p.interval === interval); + const voteStat = voteMap.get(interval); + + return { + interval, + proposalsCount: parseInt(propStat?.count || '0', 10), + votesCount: parseInt(voteStat?.count || '0', 10), + totalWeight: parseFloat(voteStat?.totalWeight || '0').toFixed(2), + }; + }); + + return { trends }; + } + + async exportData(): Promise { + const proposals = await this.proposalRepo.find({ + relations: ['votes'], + order: { createdAt: 'DESC' }, + }); + + return proposals.map(p => ({ + id: p.id, + onChainId: p.onChainId, + title: p.title, + status: p.status, + category: p.category, + proposer: p.proposer, + createdAt: p.createdAt.toISOString(), + voteCount: p.votes.length, + totalWeight: p.votes.reduce((sum, v) => sum + Number(v.weight), 0), + })); + } +} diff --git a/backend/src/modules/governance/governance.module.ts b/backend/src/modules/governance/governance.module.ts index 983e7bcdc..90c8c793e 100644 --- a/backend/src/modules/governance/governance.module.ts +++ b/backend/src/modules/governance/governance.module.ts @@ -3,6 +3,8 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { GovernanceController } from './governance.controller'; import { GovernanceProposalsController } from './governance-proposals.controller'; import { GovernanceService } from './governance.service'; +import { GovernanceAnalyticsController } from './governance-analytics.controller'; +import { GovernanceAnalyticsService } from './governance-analytics.service'; import { GovernanceIndexerService } from './governance-indexer.service'; import { UserModule } from '../user/user.module'; import { BlockchainModule } from '../blockchain/blockchain.module'; @@ -15,7 +17,15 @@ import { Vote } from './entities/vote.entity'; BlockchainModule, TypeOrmModule.forFeature([GovernanceProposal, Vote]), ], - controllers: [GovernanceController, GovernanceProposalsController], - providers: [GovernanceService, GovernanceIndexerService], + controllers: [ + GovernanceController, + GovernanceProposalsController, + GovernanceAnalyticsController, + ], + providers: [ + GovernanceService, + GovernanceIndexerService, + GovernanceAnalyticsService, + ], }) export class GovernanceModule {}