Skip to content

Commit c38b3aa

Browse files
authored
Merge pull request #589 from barry01-hash/government-proposal
Add governance proposal create and edit endpoints
2 parents e26f2b8 + 4365a71 commit c38b3aa

11 files changed

+1174
-43
lines changed
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { MigrationInterface, QueryRunner } from 'typeorm';
2+
3+
export class ExtendGovernanceProposalsForStructuredCreation1797000000000 implements MigrationInterface {
4+
public async up(queryRunner: QueryRunner): Promise<void> {
5+
await queryRunner.query(`
6+
ALTER TABLE "governance_proposals"
7+
ADD COLUMN IF NOT EXISTS "createdByUserId" uuid
8+
`);
9+
await queryRunner.query(`
10+
ALTER TABLE "governance_proposals"
11+
ADD COLUMN IF NOT EXISTS "type" varchar(50)
12+
`);
13+
await queryRunner.query(`
14+
ALTER TABLE "governance_proposals"
15+
ADD COLUMN IF NOT EXISTS "action" jsonb
16+
`);
17+
await queryRunner.query(`
18+
ALTER TABLE "governance_proposals"
19+
ADD COLUMN IF NOT EXISTS "attachments" jsonb NOT NULL DEFAULT '[]'::jsonb
20+
`);
21+
await queryRunner.query(`
22+
ALTER TABLE "governance_proposals"
23+
ADD COLUMN IF NOT EXISTS "requiredQuorum" numeric(20,8) NOT NULL DEFAULT 0
24+
`);
25+
await queryRunner.query(`
26+
ALTER TABLE "governance_proposals"
27+
ADD COLUMN IF NOT EXISTS "quorumBps" integer NOT NULL DEFAULT 5000
28+
`);
29+
await queryRunner.query(`
30+
ALTER TABLE "governance_proposals"
31+
ADD COLUMN IF NOT EXISTS "proposalThreshold" numeric(20,8) NOT NULL DEFAULT 0
32+
`);
33+
await queryRunner.query(`
34+
CREATE INDEX IF NOT EXISTS "IDX_governance_proposals_createdByUserId"
35+
ON "governance_proposals" ("createdByUserId")
36+
`);
37+
await queryRunner.query(`
38+
CREATE INDEX IF NOT EXISTS "IDX_governance_proposals_type"
39+
ON "governance_proposals" ("type")
40+
`);
41+
}
42+
43+
public async down(queryRunner: QueryRunner): Promise<void> {
44+
await queryRunner.query(`
45+
DROP INDEX IF EXISTS "IDX_governance_proposals_type"
46+
`);
47+
await queryRunner.query(`
48+
DROP INDEX IF EXISTS "IDX_governance_proposals_createdByUserId"
49+
`);
50+
await queryRunner.query(`
51+
ALTER TABLE "governance_proposals"
52+
DROP COLUMN IF EXISTS "proposalThreshold"
53+
`);
54+
await queryRunner.query(`
55+
ALTER TABLE "governance_proposals"
56+
DROP COLUMN IF EXISTS "quorumBps"
57+
`);
58+
await queryRunner.query(`
59+
ALTER TABLE "governance_proposals"
60+
DROP COLUMN IF EXISTS "requiredQuorum"
61+
`);
62+
await queryRunner.query(`
63+
ALTER TABLE "governance_proposals"
64+
DROP COLUMN IF EXISTS "attachments"
65+
`);
66+
await queryRunner.query(`
67+
ALTER TABLE "governance_proposals"
68+
DROP COLUMN IF EXISTS "action"
69+
`);
70+
await queryRunner.query(`
71+
ALTER TABLE "governance_proposals"
72+
DROP COLUMN IF EXISTS "type"
73+
`);
74+
await queryRunner.query(`
75+
ALTER TABLE "governance_proposals"
76+
DROP COLUMN IF EXISTS "createdByUserId"
77+
`);
78+
}
79+
}
Lines changed: 80 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,114 @@
1+
import { Type } from 'class-transformer';
12
import {
2-
IsString,
3+
ArrayMaxSize,
4+
IsArray,
35
IsEnum,
46
IsInt,
7+
IsObject,
58
IsOptional,
9+
IsString,
10+
IsUrl,
611
MaxLength,
12+
ValidateNested,
713
} from 'class-validator';
8-
import { ApiProperty } from '@nestjs/swagger';
9-
import { ProposalCategory } from '../entities/governance-proposal.entity';
14+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
15+
import {
16+
ProposalAttachmentType,
17+
ProposalType,
18+
} from '../entities/governance-proposal.entity';
1019

11-
export class CreateProposalDto {
12-
@ApiProperty({ description: 'On-chain proposal ID', example: 1 })
13-
@IsInt()
14-
onChainId: number;
20+
export class ProposalAttachmentDto {
21+
@ApiPropertyOptional({
22+
description: 'Display name for the supporting document or link',
23+
example: 'Treasury model spreadsheet',
24+
})
25+
@IsOptional()
26+
@IsString()
27+
@MaxLength(120)
28+
name?: string;
29+
30+
@ApiProperty({
31+
description: 'Public URL for the supporting document or external reference',
32+
example: 'https://example.com/governance/treasury-model.pdf',
33+
})
34+
@IsUrl({
35+
require_protocol: true,
36+
})
37+
url: string;
1538

1639
@ApiProperty({
17-
description: 'Proposal title',
18-
example: 'Increase Treasury Allocation',
40+
enum: ProposalAttachmentType,
41+
example: ProposalAttachmentType.DOCUMENT,
42+
})
43+
@IsEnum(ProposalAttachmentType)
44+
type: ProposalAttachmentType;
45+
}
46+
47+
export class CreateProposalDto {
48+
@ApiPropertyOptional({
49+
description:
50+
'Optional human-readable title. Derived from description if omitted.',
51+
example: 'Increase Flexi Savings Rate',
1952
maxLength: 500,
2053
})
54+
@IsOptional()
2155
@IsString()
2256
@MaxLength(500)
23-
title: string;
57+
title?: string;
2458

2559
@ApiProperty({
2660
description: 'Detailed proposal description',
27-
example: 'This proposal aims to...',
61+
example:
62+
'Increase the flexi savings rate from 8% to 10% to improve user retention.',
2863
})
2964
@IsString()
65+
@MaxLength(5000)
3066
description: string;
3167

32-
@ApiProperty({ enum: ProposalCategory, example: ProposalCategory.TREASURY })
33-
@IsEnum(ProposalCategory)
34-
category: ProposalCategory;
35-
3668
@ApiProperty({
37-
description: 'Proposer wallet address',
38-
example: '0x1234...',
39-
required: false,
69+
enum: ProposalType,
70+
description:
71+
'Structured proposal type used for validation and categorization',
72+
example: ProposalType.RATE_CHANGE,
4073
})
41-
@IsOptional()
42-
@IsString()
43-
proposer?: string;
74+
@IsEnum(ProposalType)
75+
type: ProposalType;
4476

4577
@ApiProperty({
46-
description: 'Start block number',
47-
example: 1000000,
48-
required: false,
78+
description: 'Structured action payload for the proposal',
79+
example: { target: 'flexiRate', newValue: 10 },
80+
})
81+
@IsObject()
82+
action: Record<string, unknown>;
83+
84+
@ApiPropertyOptional({
85+
description:
86+
'Optional voting start ledger. Defaults to the current ledger plus a short review window.',
87+
example: 123456,
4988
})
5089
@IsOptional()
90+
@Type(() => Number)
5191
@IsInt()
5292
startBlock?: number;
5393

54-
@ApiProperty({
55-
description: 'End block number',
56-
example: 1100000,
57-
required: false,
94+
@ApiPropertyOptional({
95+
description:
96+
'Optional voting end ledger. Defaults to startBlock plus the configured voting period.',
97+
example: 140736,
5898
})
5999
@IsOptional()
100+
@Type(() => Number)
60101
@IsInt()
61102
endBlock?: number;
103+
104+
@ApiPropertyOptional({
105+
type: [ProposalAttachmentDto],
106+
description: 'Supporting documents or reference links',
107+
})
108+
@IsOptional()
109+
@IsArray()
110+
@ArrayMaxSize(10)
111+
@ValidateNested({ each: true })
112+
@Type(() => ProposalAttachmentDto)
113+
attachments?: ProposalAttachmentDto[];
62114
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { PartialType } from '@nestjs/swagger';
2+
import { CreateProposalDto } from './create-proposal.dto';
3+
4+
export class EditProposalDto extends PartialType(CreateProposalDto) {}

backend/src/modules/governance/dto/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './create-proposal.dto';
2+
export * from './edit-proposal.dto';
23
export * from './cast-vote.dto';
34
export * from './proposal-response.dto';
45
export * from './vote-response.dto';

backend/src/modules/governance/dto/proposal-response.dto.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { ApiProperty } from '@nestjs/swagger';
22
import {
3+
ProposalAttachment,
34
ProposalStatus,
45
ProposalCategory,
6+
ProposalType,
7+
ProposalActionPayload,
58
} from '../entities/governance-proposal.entity';
69
import { VoteResponseDto } from './vote-response.dto';
710

@@ -21,6 +24,20 @@ export class ProposalResponseDto {
2124
@ApiProperty({ enum: ProposalCategory })
2225
category: ProposalCategory;
2326

27+
@ApiProperty({
28+
enum: ProposalType,
29+
nullable: true,
30+
description: 'Structured proposal type when available',
31+
})
32+
type: ProposalType | null;
33+
34+
@ApiProperty({
35+
nullable: true,
36+
description: 'Structured action payload for the proposal',
37+
example: { target: 'flexiRate', newValue: 10 },
38+
})
39+
action: ProposalActionPayload | null;
40+
2441
@ApiProperty({ enum: ProposalStatus })
2542
status: ProposalStatus;
2643

@@ -33,6 +50,43 @@ export class ProposalResponseDto {
3350
@ApiProperty({ description: 'End block number', nullable: true })
3451
endBlock: number | null;
3552

53+
@ApiProperty({
54+
type: 'array',
55+
description: 'Supporting documents and links',
56+
example: [
57+
{
58+
name: 'Economic analysis',
59+
url: 'https://example.com/analysis.pdf',
60+
type: 'DOCUMENT',
61+
},
62+
],
63+
})
64+
attachments: ProposalAttachment[];
65+
66+
@ApiProperty({
67+
description: 'Required voting quorum for this proposal in NST units',
68+
example: '5000.00000000',
69+
})
70+
requiredQuorum: string;
71+
72+
@ApiProperty({
73+
description: 'Quorum percentage in basis points',
74+
example: 5000,
75+
})
76+
quorumBps: number;
77+
78+
@ApiProperty({
79+
description: 'Minimum voting power required to submit a proposal',
80+
example: '100.00000000',
81+
})
82+
proposalThreshold: string;
83+
84+
@ApiProperty({
85+
description: 'Whether the proposal can still be edited by its creator',
86+
example: true,
87+
})
88+
canEdit: boolean;
89+
3690
@ApiProperty({
3791
type: [VoteResponseDto],
3892
description: 'All votes on this proposal',

backend/src/modules/governance/entities/governance-proposal.entity.spec.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import {
22
GovernanceProposal,
3+
ProposalAttachmentType,
34
ProposalStatus,
45
ProposalCategory,
6+
ProposalType,
57
} from './governance-proposal.entity';
68
import { Vote, VoteDirection } from './vote.entity';
79

@@ -75,4 +77,31 @@ describe('GovernanceProposal Entity', () => {
7577
expect(proposal.startBlock).toBe(1000000);
7678
expect(proposal.endBlock).toBe(1100000);
7779
});
80+
81+
it('should support structured proposal metadata', () => {
82+
const proposal = new GovernanceProposal();
83+
proposal.type = ProposalType.TREASURY_ALLOCATION;
84+
proposal.action = {
85+
recipient: 'GRECIPIENT123',
86+
amount: 5000,
87+
asset: 'USDC',
88+
};
89+
proposal.attachments = [
90+
{
91+
name: 'Treasury memo',
92+
url: 'https://example.com/memo.pdf',
93+
type: ProposalAttachmentType.DOCUMENT,
94+
},
95+
];
96+
proposal.requiredQuorum = '5000.00000000';
97+
proposal.quorumBps = 5000;
98+
proposal.proposalThreshold = '100.00000000';
99+
100+
expect(proposal.type).toBe(ProposalType.TREASURY_ALLOCATION);
101+
expect(proposal.action?.recipient).toBe('GRECIPIENT123');
102+
expect(proposal.attachments[0].type).toBe(ProposalAttachmentType.DOCUMENT);
103+
expect(proposal.requiredQuorum).toBe('5000.00000000');
104+
expect(proposal.quorumBps).toBe(5000);
105+
expect(proposal.proposalThreshold).toBe('100.00000000');
106+
});
78107
});

0 commit comments

Comments
 (0)