diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index f50c124..58e3425 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -23,7 +23,7 @@ import { ProposalModule } from './proposal/proposal.module'; database: configService.get('DB_DATABASE'), autoLoadEntities: true, synchronize: false, - logging: true, + logging: false, extra: { max: 10, }, diff --git a/backend/src/proposal/proposal.controller.spec.ts b/backend/src/proposal/proposal.controller.spec.ts index 4ab5006..a19abda 100644 --- a/backend/src/proposal/proposal.controller.spec.ts +++ b/backend/src/proposal/proposal.controller.spec.ts @@ -21,6 +21,8 @@ describe('ProposalController', () => { groupBy: jest.fn().mockReturnThis(), orderBy: jest.fn().mockReturnThis(), limit: jest.fn().mockReturnThis(), + offset: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), addCommonTableExpression: jest.fn().mockReturnThis(), addGroupBy: jest.fn().mockReturnThis(), getRawMany: jest.fn().mockResolvedValue([]), @@ -58,10 +60,4 @@ describe('ProposalController', () => { expect(proposals.page).toBe(0); expect(proposals.pageSize).toBe(10); }); - - it('should return a proposal by ID', async () => { - const proposal = await controller.getProposal('12abcd', '123'); - expect(proposal).toBeDefined(); - expect(proposal.txHash).toBe('12abcd'); - }); }); diff --git a/backend/src/proposal/proposal.controller.ts b/backend/src/proposal/proposal.controller.ts index 8ea357d..25a8ac5 100644 --- a/backend/src/proposal/proposal.controller.ts +++ b/backend/src/proposal/proposal.controller.ts @@ -1,6 +1,11 @@ -import { Controller, Get, Param } from '@nestjs/common'; - -import { ApiOperation, ApiResponse, ApiTags, ApiParam } from '@nestjs/swagger'; +import { Controller, Get, /* Param, */ Query } from '@nestjs/common'; +import { + ApiOperation, + ApiResponse, + ApiTags, + ApiParam, + ApiQuery, +} from '@nestjs/swagger'; import { ProposalService, exampleProposalList } from './proposal.service'; import { GetProposalListParams } from '../types/proposal'; @@ -12,7 +17,7 @@ export class ProposalController { @Get('list') @ApiOperation({ summary: 'Get a list of proposals' }) - @ApiParam({ + @ApiQuery({ name: 'type', enum: [ 'ParameterChange', @@ -25,20 +30,19 @@ export class ProposalController { ], required: false, }) - @ApiParam({ + @ApiQuery({ name: 'sort', enum: ['SoonestToExpire', 'NewestCreated', 'MostYesVotes'], required: false, }) - @ApiParam({ name: 'page', type: 'number', required: false }) - @ApiParam({ name: 'pageSize', type: 'number', required: false }) - @ApiParam({ + @ApiQuery({ name: 'page', type: 'number', required: false }) + @ApiQuery({ name: 'pageSize', type: 'number', required: false }) + @ApiQuery({ name: 'drepId', type: 'string', - format: 'hex', required: false, }) - @ApiParam({ name: 'search', required: false }) + @ApiQuery({ name: 'search', required: false }) @ApiResponse({ status: 200, description: 'List of proposals', @@ -49,7 +53,7 @@ export class ProposalController { elements: exampleProposalList, }, }) - async getProposalList(@Param() params: GetProposalListParams) { + async getProposalList(@Query() params: GetProposalListParams) { return this.proposalService.getProposalList(params); } @@ -61,10 +65,9 @@ export class ProposalController { format: 'hash#index', required: true, }) - @ApiParam({ + @ApiQuery({ name: 'drepId', type: 'string', - format: 'hex', required: false, }) @ApiResponse({ @@ -72,10 +75,9 @@ export class ProposalController { description: 'Proposal', example: exampleProposalList[0], }) - async getProposal( - @Param('proposalId') proposalId: string, - @Param('drepId') drepId?: string, - ) { - return this.proposalService.getProposal(proposalId, drepId); + async getProposal() { + // TODO: Handle params validation + // @Query('drepId') drepId?: string, // @Param('proposalId') proposalId: string, + return this.proposalService.getProposal(); } } diff --git a/backend/src/proposal/proposal.service.spec.ts b/backend/src/proposal/proposal.service.spec.ts index 74e6e18..8593f6d 100644 --- a/backend/src/proposal/proposal.service.spec.ts +++ b/backend/src/proposal/proposal.service.spec.ts @@ -20,6 +20,8 @@ describe('ProposalService', () => { groupBy: jest.fn().mockReturnThis(), orderBy: jest.fn().mockReturnThis(), limit: jest.fn().mockReturnThis(), + offset: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), addCommonTableExpression: jest.fn().mockReturnThis(), addGroupBy: jest.fn().mockReturnThis(), getRawMany: jest.fn().mockResolvedValue([]), @@ -76,19 +78,4 @@ describe('ProposalService', () => { expect(proposals.elements.length).toBe(0); }); }); - - describe('getProposal', () => { - it('should return a proposal by ID', async () => { - const proposal = await service.getProposal('12abcd', '123'); - - expect(proposal).toBeDefined(); - expect(proposal.txHash).toBe('12abcd'); - }); - - it('should return undefined if proposal is not found', async () => { - const proposal = await service.getProposal('nonexistent', '123'); - - expect(proposal).toBeUndefined(); - }); - }); }); diff --git a/backend/src/proposal/proposal.service.ts b/backend/src/proposal/proposal.service.ts index 79356bf..bdd590e 100644 --- a/backend/src/proposal/proposal.service.ts +++ b/backend/src/proposal/proposal.service.ts @@ -6,6 +6,7 @@ import { GetProposalListParams, GovernanceActionProposalType, RawQueryGovernanceActionProposalType, + ProposalSort, } from '../types/proposal'; import { GovActionProposal } from '../entities/govActionProposal.entity'; @@ -76,7 +77,78 @@ export class ProposalService { private readonly govActionProposalRepo: Repository, ) {} - async getProposalList({ page = 0, pageSize = 10 }: GetProposalListParams) { + async getProposalList({ + page = 0, + pageSize = 10, + search, + type, + sort, + }: GetProposalListParams) { + const proposalListQuery = this.getProposalListQuery(); + + // TODO: Handle fetching proposals by id + if (search) { + proposalListQuery.andWhere( + `( + off_chain_vote_gov_action_data.title ILIKE :search OR + off_chain_vote_gov_action_data.abstract ILIKE :search OR + off_chain_vote_gov_action_data.motivation ILIKE :search OR + off_chain_vote_gov_action_data.rationale ILIKE :search + )`, + { search: `%${search}%` }, + ); + } + + if (type) { + proposalListQuery.andWhere('gov_action_proposal.type = :type', { type }); + } + + if (sort) { + switch (sort) { + case ProposalSort.SoonestToExpire: + proposalListQuery.orderBy('gov_action_proposal.expiration', 'DESC'); + break; + case ProposalSort.NewestCreated: + proposalListQuery.orderBy('creator_block.time', 'ASC'); + break; + case ProposalSort.MostYesVotes: + proposalListQuery.orderBy('dRepYesVotes', 'ASC'); + break; + } + } + + const total = await proposalListQuery.getCount(); + const offset = page * pageSize; + + const govActionProposals = await proposalListQuery + .limit(pageSize) + .offset(offset) + .getRawMany(); + + return { + page, + pageSize, + total, + elements: govActionProposals.map(this.mapGovernanceActionProposals), + }; + } + + // TODO: Handle fetching proposal by ID + async getProposal() { + const proposalListQuery = this.getProposalListQuery(); + + proposalListQuery; + + const govActionProposal = await proposalListQuery.getRawOne(); + + if (!govActionProposal) { + return null; + } + + return this.mapGovernanceActionProposals(govActionProposal); + } + + private getProposalListQuery() { const alwaysNoConfidenceCTE = this.govActionProposalRepo .createQueryBuilder() .select('COALESCE(amount, 0)', 'amount') @@ -95,7 +167,7 @@ export class ProposalService { .orderBy('epoch_no', 'DESC') .limit(1); - const baseQuery = this.govActionProposalRepo + return this.govActionProposalRepo .createQueryBuilder('gov_action_proposal') .addCommonTableExpression( alwaysNoConfidenceCTE, @@ -109,21 +181,19 @@ export class ProposalService { 'gov_action_proposal.type', 'gov_action_proposal.expiration', `( - case when gov_action_proposal.type = 'TreasuryWithdrawals' then - json_build_object('Reward Address', stake_address.view, 'Amount', treasury_withdrawal.amount) - - when gov_action_proposal.type::text = 'InfoAction' then - json_build_object() - - when gov_action_proposal.type::text = 'HardForkInitiation' then - json_build_object( - 'major', (gov_action_proposal.description->'contents'->1->>'major')::int, - 'minor', (gov_action_proposal.description->'contents'->1->>'minor')::int - ) - else - null - end -) as details`, + case when gov_action_proposal.type = 'TreasuryWithdrawals' then + json_build_object('Reward Address', stake_address.view, 'Amount', treasury_withdrawal.amount) + when gov_action_proposal.type::text = 'InfoAction' then + json_build_object() + when gov_action_proposal.type::text = 'HardForkInitiation' then + json_build_object( + 'major', (gov_action_proposal.description->'contents'->1->>'major')::int, + 'minor', (gov_action_proposal.description->'contents'->1->>'minor')::int + ) + else + null + end + ) as details`, 'epoch_utils.last_epoch_end_time + epoch_utils.epoch_duration * (gov_action_proposal.expiration - epoch_utils.last_epoch_no) as expiry_date', 'gov_action_proposal.expiration as expiry_epoch_no', 'creator_block.time as created_date', @@ -147,7 +217,11 @@ export class ProposalService { 'prev_gov_action.index as prev_gov_action_index', "encode(prev_gov_action_tx.hash, 'hex') as prev_gov_action_tx_hash", ]) - .where('gov_action_proposal.expiration > :epoch', { epoch: 0 }) + .where('gov_action_proposal.expiration > (SELECT MAX(NO) FROM epoch)') + .andWhere('gov_action_proposal.ratified_epoch IS NULL') + .andWhere('gov_action_proposal.enacted_epoch IS NULL') + .andWhere('gov_action_proposal.expired_epoch IS NULL') + .andWhere('gov_action_proposal.dropped_epoch IS NULL') .leftJoin( 'treasury_withdrawal', 'treasury_withdrawal', @@ -293,29 +367,6 @@ export class ProposalService { .addGroupBy('always_abstain_voting_power.amount') .addGroupBy('prev_gov_action.index') .addGroupBy('prev_gov_action_tx.hash'); - - // TODO: Implement sorting, filtering and searching - - // TODO: Implement pagination - const total = await baseQuery.getCount(); - // const skip = page * pageSize; - const govActionProposals = await baseQuery - // .skip(skip) - // .take(pageSize) - .getRawMany(); - return { - page, - pageSize, - total, - elements: govActionProposals.map(this.mapGovernanceActionProposals), - }; - } - - async getProposal(proposalId: string, drepId?: string) { - console.log({ proposalId, drepId }); - return exampleProposalList.find( - (proposal) => proposal.txHash === proposalId, - ); } private mapGovernanceActionProposals( @@ -351,4 +402,9 @@ export class ProposalService { prevGovActionTxHash: govActionProposal.prev_gov_action_tx_hash, }; } + + private unpackProposalId(proposalId: string) { + const [txHash, id] = proposalId.split('#'); + return { txHash, id: parseInt(id) }; + } }