Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions backend/src/modules/governance/dto/governance-trend.dto.ts
Original file line number Diff line number Diff line change
@@ -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[];
}
18 changes: 18 additions & 0 deletions backend/src/modules/governance/dto/participation-stats.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
33 changes: 33 additions & 0 deletions backend/src/modules/governance/dto/proposal-analytics.dto.ts
Original file line number Diff line number Diff line change
@@ -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[];
}
15 changes: 15 additions & 0 deletions backend/src/modules/governance/dto/top-voter.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
52 changes: 52 additions & 0 deletions backend/src/modules/governance/governance-analytics.controller.ts
Original file line number Diff line number Diff line change
@@ -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<ParticipationStatsDto> {
return this.analyticsService.getParticipationStats();
}

@Get('proposals')
@ApiOperation({ summary: 'Get proposal success rates and category breakdown' })
@ApiResponse({ status: 200, type: ProposalAnalyticsDto })
async getProposalAnalytics(): Promise<ProposalAnalyticsDto> {
return this.analyticsService.getProposalAnalytics();
}

@Get('top-voters')
@ApiOperation({ summary: 'Get the most active governance participants' })
@ApiResponse({ status: 200, type: [TopVoterDto] })
async getTopVoters(): Promise<TopVoterDto[]> {
return this.analyticsService.getTopVoters();
}

@Get('trends')
@ApiOperation({ summary: 'Get governance trends over time' })
@ApiResponse({ status: 200, type: GovernanceTrendDto })
async getTrends(): Promise<GovernanceTrendDto> {
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<void> {
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));
}
}
114 changes: 114 additions & 0 deletions backend/src/modules/governance/governance-analytics.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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>(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);
});
});
});
Loading
Loading