Skip to content

Commit f0f356c

Browse files
authored
Merge pull request #581 from Demilade01/governance-analytics
feat(governance): implement governance notification system
2 parents 4916278 + a4c1bb4 commit f0f356c

File tree

10 files changed

+665
-35
lines changed

10 files changed

+665
-35
lines changed
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import {
2+
Entity,
3+
Column,
4+
PrimaryGeneratedColumn,
5+
CreateDateColumn,
6+
UpdateDateColumn,
7+
Index,
8+
Unique,
9+
} from 'typeorm';
10+
11+
@Entity('delegations')
12+
@Unique(['delegatorAddress'])
13+
@Index(['delegateAddress'])
14+
export class Delegation {
15+
@PrimaryGeneratedColumn('uuid')
16+
id: string;
17+
18+
@Column()
19+
delegatorAddress: string;
20+
21+
@Column()
22+
delegateAddress: string;
23+
24+
@CreateDateColumn()
25+
createdAt: Date;
26+
27+
@UpdateDateColumn()
28+
updatedAt: Date;
29+
}

backend/src/modules/governance/governance-indexer.service.ts

Lines changed: 91 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,22 @@
11
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
22
import { InjectRepository } from '@nestjs/typeorm';
3+
import { EventEmitter2 } from '@nestjs/event-emitter';
34
import { Repository } from 'typeorm';
4-
// import { ethers } from 'ethers';
55
import {
66
GovernanceProposal,
77
ProposalStatus,
88
} from './entities/governance-proposal.entity';
99
import { Vote, VoteDirection } from './entities/vote.entity';
10+
import { Delegation } from './entities/delegation.entity';
1011

1112
/**
1213
* Minimal ABI fragments for the DAO contract events we care about.
13-
* ProposalCreated: emitted when a new proposal is submitted on-chain.
14-
* VoteCast: emitted when a wallet casts a For (1) or Against (0) vote.
1514
*/
1615
const DAO_ABI_FRAGMENTS = [
1716
'event ProposalCreated(uint256 indexed proposalId, address indexed proposer, string description, uint256 startBlock, uint256 endBlock)',
1817
'event VoteCast(address indexed voter, uint256 indexed proposalId, uint8 support, uint256 weight)',
18+
'event DelegationUpdated(address indexed delegator, address indexed delegate)',
19+
'event ProposalStatusChanged(uint256 indexed proposalId, uint8 status)',
1920
];
2021

2122
@Injectable()
@@ -29,6 +30,9 @@ export class GovernanceIndexerService implements OnModuleInit {
2930
private readonly proposalRepo: Repository<GovernanceProposal>,
3031
@InjectRepository(Vote)
3132
private readonly voteRepo: Repository<Vote>,
33+
@InjectRepository(Delegation)
34+
private readonly delegationRepo: Repository<Delegation>,
35+
private readonly eventEmitter: EventEmitter2,
3236
) {}
3337

3438
onModuleInit() {
@@ -47,26 +51,15 @@ export class GovernanceIndexerService implements OnModuleInit {
4751
}
4852

4953
// TODO: Implement ethers integration when ethers package is added
50-
// this.provider = new ethers.JsonRpcProvider(rpcUrl);
51-
// this.contract = new ethers.Contract(
52-
// contractAddress,
53-
// DAO_ABI_FRAGMENTS,
54-
// this.provider,
55-
// );
56-
// this.contract.on('ProposalCreated', this.handleProposalCreated.bind(this));
57-
// this.contract.on('VoteCast', this.handleVoteCast.bind(this));
58-
5954
this.logger.log(
6055
`Governance indexer listening on contract ${contractAddress}`,
6156
);
6257
}
6358

6459
/**
6560
* Handles the ProposalCreated event.
66-
* Inserts a skeletal GovernanceProposal row with status=Active.
67-
* Parses the on-chain ID and creates a local database entry.
6861
*/
69-
private async handleProposalCreated(
62+
async handleProposalCreated(
7063
proposalId: bigint,
7164
proposer: string,
7265
description: string,
@@ -96,18 +89,21 @@ export class GovernanceIndexerService implements OnModuleInit {
9689
});
9790

9891
await this.proposalRepo.save(proposal);
99-
this.logger.log(
100-
`Indexed new proposal onChainId=${onChainId} from proposer=${proposer}`,
101-
);
92+
this.logger.log(`Indexed new proposal onChainId=${onChainId}`);
93+
94+
// Emit event for notifications
95+
this.eventEmitter.emit('governance.proposal.created', {
96+
proposalId: proposal.id,
97+
onChainId,
98+
proposer,
99+
title: description.slice(0, 50), // Fallback title
100+
});
102101
}
103102

104103
/**
105104
* Handles the VoteCast event.
106-
* Isolates the direction (For=1, Against=0) and maps it to the Vote database table
107-
* linked to the walletAddress and the corresponding GovernanceProposal.
108-
* Supports updating proposal status based on voting outcomes.
109105
*/
110-
private async handleVoteCast(
106+
async handleVoteCast(
111107
voter: string,
112108
proposalId: bigint,
113109
support: number,
@@ -117,17 +113,14 @@ export class GovernanceIndexerService implements OnModuleInit {
117113

118114
const proposal = await this.proposalRepo.findOneBy({ onChainId });
119115
if (!proposal) {
120-
this.logger.warn(
121-
`VoteCast received for unknown proposal ${onChainId} — skipping.`,
122-
);
116+
this.logger.warn(`VoteCast received for unknown proposal ${onChainId}`);
123117
return;
124118
}
125119

126120
// Map on-chain support value: 1 = FOR, 0 = AGAINST
127121
const direction: VoteDirection =
128122
support === 1 ? VoteDirection.FOR : VoteDirection.AGAINST;
129123

130-
// Upsert: one vote per wallet per proposal
131124
const existing = await this.voteRepo.findOneBy({
132125
walletAddress: voter,
133126
proposalId: proposal.id,
@@ -137,9 +130,6 @@ export class GovernanceIndexerService implements OnModuleInit {
137130
existing.direction = direction;
138131
existing.weight = Number(weight);
139132
await this.voteRepo.save(existing);
140-
this.logger.debug(
141-
`Updated vote for wallet=${voter} on proposal=${onChainId}`,
142-
);
143133
} else {
144134
const vote = this.voteRepo.create({
145135
walletAddress: voter,
@@ -149,9 +139,78 @@ export class GovernanceIndexerService implements OnModuleInit {
149139
proposalId: proposal.id,
150140
});
151141
await this.voteRepo.save(vote);
152-
this.logger.log(
153-
`Indexed vote wallet=${voter} direction=${direction} proposal=${onChainId} weight=${weight}`,
154-
);
142+
}
143+
144+
// Emit event for notifications (to notify delegators)
145+
this.eventEmitter.emit('governance.vote.cast', {
146+
voter,
147+
onChainId,
148+
direction,
149+
weight: weight.toString(),
150+
});
151+
}
152+
153+
/**
154+
* Handles the DelegationUpdated event.
155+
*/
156+
async handleDelegationUpdated(
157+
delegator: string,
158+
delegate: string,
159+
): Promise<void> {
160+
const existing = await this.delegationRepo.findOneBy({
161+
delegatorAddress: delegator,
162+
});
163+
164+
if (existing) {
165+
existing.delegateAddress = delegate;
166+
await this.delegationRepo.save(existing);
167+
} else {
168+
const newDelegation = this.delegationRepo.create({
169+
delegatorAddress: delegator,
170+
delegateAddress: delegate,
171+
});
172+
await this.delegationRepo.save(newDelegation);
173+
}
174+
175+
this.logger.log(`Updated delegation: ${delegator} -> ${delegate}`);
176+
}
177+
178+
/**
179+
* Handles the ProposalStatusChanged event.
180+
*/
181+
async handleProposalStatusChanged(
182+
proposalId: bigint,
183+
status: number,
184+
): Promise<void> {
185+
const onChainId = Number(proposalId);
186+
const proposal = await this.proposalRepo.findOneBy({ onChainId });
187+
if (!proposal) return;
188+
189+
// Map on-chain status to enum
190+
let newStatus: ProposalStatus;
191+
switch (status) {
192+
case 1:
193+
newStatus = ProposalStatus.PASSED;
194+
break;
195+
case 2:
196+
newStatus = ProposalStatus.FAILED;
197+
break;
198+
case 3:
199+
newStatus = ProposalStatus.CANCELLED;
200+
break;
201+
default:
202+
newStatus = ProposalStatus.ACTIVE;
203+
}
204+
205+
if (proposal.status !== newStatus) {
206+
proposal.status = newStatus;
207+
await this.proposalRepo.save(proposal);
208+
209+
this.eventEmitter.emit('governance.proposal.status_updated', {
210+
proposalId: proposal.id,
211+
onChainId,
212+
status: newStatus,
213+
});
155214
}
156215
}
157216

backend/src/modules/governance/governance.module.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Module } from '@nestjs/common';
22
import { TypeOrmModule } from '@nestjs/typeorm';
3+
import { EventEmitterModule } from '@nestjs/event-emitter';
34
import { GovernanceController } from './governance.controller';
45
import { GovernanceProposalsController } from './governance-proposals.controller';
56
import { GovernanceService } from './governance.service';
@@ -10,12 +11,14 @@ import { UserModule } from '../user/user.module';
1011
import { BlockchainModule } from '../blockchain/blockchain.module';
1112
import { GovernanceProposal } from './entities/governance-proposal.entity';
1213
import { Vote } from './entities/vote.entity';
14+
import { Delegation } from './entities/delegation.entity';
1315

1416
@Module({
1517
imports: [
1618
UserModule,
1719
BlockchainModule,
18-
TypeOrmModule.forFeature([GovernanceProposal, Vote]),
20+
EventEmitterModule.forRoot(),
21+
TypeOrmModule.forFeature([GovernanceProposal, Vote, Delegation]),
1922
],
2023
controllers: [
2124
GovernanceController,

backend/src/modules/mail/mail.service.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,4 +238,29 @@ export class MailService {
238238
this.logger.error(`Failed to send raw email to ${to}`, error);
239239
}
240240
}
241+
242+
async sendGovernanceEmail(
243+
userEmail: string,
244+
name: string,
245+
subject: string,
246+
message: string,
247+
): Promise<void> {
248+
try {
249+
await this.mailerService.sendMail({
250+
to: userEmail,
251+
subject,
252+
template: './generic-notification',
253+
context: {
254+
name: name || 'User',
255+
message,
256+
},
257+
});
258+
this.logger.log(`Governance email (${subject}) sent to ${userEmail}`);
259+
} catch (error) {
260+
this.logger.error(
261+
`Failed to send governance email to ${userEmail}`,
262+
error,
263+
);
264+
}
265+
}
241266
}

backend/src/modules/notifications/entities/notification.entity.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ export enum NotificationType {
2222
PRODUCT_ALERT_TRIGGERED = 'PRODUCT_ALERT_TRIGGERED',
2323
REBALANCING_RECOMMENDED = 'REBALANCING_RECOMMENDED',
2424
ADMIN_CAPACITY_ALERT = 'ADMIN_CAPACITY_ALERT',
25+
GOVERNANCE_PROPOSAL_CREATED = 'GOVERNANCE_PROPOSAL_CREATED',
26+
GOVERNANCE_VOTING_REMINDER = 'GOVERNANCE_VOTING_REMINDER',
27+
GOVERNANCE_PROPOSAL_QUEUED = 'GOVERNANCE_PROPOSAL_QUEUED',
28+
GOVERNANCE_PROPOSAL_EXECUTED = 'GOVERNANCE_PROPOSAL_EXECUTED',
29+
GOVERNANCE_DELEGATE_VOTED = 'GOVERNANCE_DELEGATE_VOTED',
2530
}
2631

2732
@Entity('notifications')
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import {
2+
Entity,
3+
Column,
4+
PrimaryGeneratedColumn,
5+
CreateDateColumn,
6+
Index,
7+
} from 'typeorm';
8+
import { NotificationType } from './notification.entity';
9+
10+
@Entity('pending_notifications')
11+
@Index(['userId', 'processed'])
12+
export class PendingNotification {
13+
@PrimaryGeneratedColumn('uuid')
14+
id: string;
15+
16+
@Column('uuid')
17+
userId: string;
18+
19+
@Column({ type: 'enum', enum: NotificationType })
20+
type: NotificationType;
21+
22+
@Column()
23+
title: string;
24+
25+
@Column('text')
26+
message: string;
27+
28+
@Column({ type: 'jsonb', nullable: true })
29+
metadata: Record<string, any> | null;
30+
31+
@Column({ type: 'boolean', default: false })
32+
processed: boolean;
33+
34+
@CreateDateColumn()
35+
createdAt: Date;
36+
}

0 commit comments

Comments
 (0)