Skip to content

Commit 8ccfaf5

Browse files
Merge pull request #215 from blockful/dev
v1.0.1
2 parents 3198ee7 + b89286d commit 8ccfaf5

37 files changed

Lines changed: 2022 additions & 3889 deletions

apps/dispatcher/src/services/triggers/new-proposal-trigger.service.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ describe('NewProposalTriggerHandler', () => {
8080
getProposalById: jest.fn(),
8181
listProposals: jest.fn(),
8282
listVotingPowerHistory: jest.fn(),
83-
listVotesOnchains: jest.fn(),
83+
listVotes: jest.fn(),
8484
listRecentVotesFromAllDaos: jest.fn()
8585
} as unknown as jest.Mocked<AnticaptureClient>;
8686

apps/dispatcher/src/services/triggers/non-voting-handler.test-factory.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,9 @@ export function createUser(overrides: Partial<User> = {}): User {
5353
};
5454
}
5555

56-
export function createVote(voterAccountId: string, proposalId: string) {
56+
export function createVote(voterAddress: string, proposalId: string) {
5757
return {
58-
voterAccountId,
58+
voterAddress,
5959
proposalId
6060
};
6161
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { describe, it, expect } from '@jest/globals';
2+
import { VoteConfirmationTriggerHandler } from './vote-confirmation-trigger.service';
3+
import { NotificationClientFactory } from '../notification/notification-factory.service';
4+
import { AnticaptureClient } from '@notification-system/anticapture-client';
5+
import { NotificationPayload } from '../../interfaces/notification-client.interface';
6+
7+
function createHandler() {
8+
const sentNotifications: NotificationPayload[] = [];
9+
const sentEventIds = new Set<string>();
10+
const stubUser = { id: '1', channel: 'telegram', channel_user_id: '123', created_at: new Date() };
11+
12+
const handler = new VoteConfirmationTriggerHandler(
13+
{
14+
getDaoSubscribers: async () => [stubUser],
15+
shouldSend: async (users, eventId) => {
16+
if (sentEventIds.has(eventId)) return [];
17+
return users.map(u => ({ user_id: u.id, event_id: eventId, dao_id: 'test-dao' }));
18+
},
19+
shouldSendBatch: async () => [],
20+
markAsSent: async (notifications) => {
21+
notifications.forEach(n => sentEventIds.add(n.event_id));
22+
},
23+
getWalletOwners: async () => [],
24+
getWalletOwnersBatch: async () => ({ '0xVoter123': [stubUser] }),
25+
getFollowedAddresses: async () => []
26+
},
27+
{
28+
getClient: () => ({ sendNotification: async (n: NotificationPayload) => { sentNotifications.push(n); } }),
29+
supportsChannel: () => true
30+
} as unknown as NotificationClientFactory,
31+
{
32+
getDAOs: async () => [{ id: 'test-dao', chainId: 1, blockTime: 12, votingDelay: '0' }]
33+
} as AnticaptureClient
34+
);
35+
36+
return { handler, sentNotifications };
37+
}
38+
39+
describe('VoteConfirmationTriggerHandler', () => {
40+
it('should send one notification per vote in batch voting (same tx, multiple proposals)', async () => {
41+
const { handler, sentNotifications } = createHandler();
42+
43+
await handler.handleMessage({
44+
triggerId: 'vote-confirmation',
45+
events: [
46+
{ daoId: 'test-dao', proposalId: 'proposal-1', voterAddress: '0xVoter123', support: 1, votingPower: '1000000000000000000', timestamp: 1767225600, transactionHash: '0xSameTxHash', proposalTitle: 'Proposal 1' },
47+
{ daoId: 'test-dao', proposalId: 'proposal-2', voterAddress: '0xVoter123', support: 0, votingPower: '1000000000000000000', timestamp: 1767225600, transactionHash: '0xSameTxHash', proposalTitle: 'Proposal 2' },
48+
{ daoId: 'test-dao', proposalId: 'proposal-3', voterAddress: '0xVoter123', support: 2, votingPower: '1000000000000000000', timestamp: 1767225600, transactionHash: '0xSameTxHash', proposalTitle: 'Proposal 3' },
49+
]
50+
});
51+
52+
expect(sentNotifications).toHaveLength(3);
53+
});
54+
});

apps/dispatcher/src/services/triggers/vote-confirmation-trigger.service.ts

Lines changed: 23 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,13 @@ import { BaseTriggerHandler } from './base-trigger.service';
22
import { DispatcherMessage, MessageProcessingResult } from '../../interfaces/dispatcher-message.interface';
33
import { NotificationClientFactory } from '../notification/notification-factory.service';
44
import { ISubscriptionClient } from '../../interfaces/subscription-client.interface';
5-
import { AnticaptureClient } from '@notification-system/anticapture-client';
5+
import { AnticaptureClient, VoteWithDaoId } from '@notification-system/anticapture-client';
66
import { formatTokenAmount } from '../../lib/number-formatter';
77
import { voteConfirmationMessages, replacePlaceholders, buildButtons } from '@notification-system/messages';
8-
import { FormattingService } from '../formatting.service';
9-
10-
interface VoteEvent {
11-
daoId: string;
12-
proposalId: string;
13-
voterAccountId: string;
14-
support: string;
15-
votingPower: string;
16-
timestamp: string;
17-
txHash: string;
18-
reason?: string | null;
19-
proposalDescription?: string | null;
20-
}
218

229
interface UserVoteCombination {
2310
user: any;
24-
vote: VoteEvent;
11+
vote: VoteWithDaoId;
2512
}
2613

2714
interface ProcessingResult {
@@ -33,7 +20,7 @@ interface ProcessingResult {
3320
type ProcessingStatus = 'sent' | 'skipped';
3421

3522

36-
export class VoteConfirmationTriggerHandler extends BaseTriggerHandler<VoteEvent> {
23+
export class VoteConfirmationTriggerHandler extends BaseTriggerHandler<VoteWithDaoId> {
3724
constructor(
3825
protected readonly subscriptionClient: ISubscriptionClient,
3926
protected readonly notificationFactory: NotificationClientFactory,
@@ -42,7 +29,7 @@ export class VoteConfirmationTriggerHandler extends BaseTriggerHandler<VoteEvent
4229
super(subscriptionClient, notificationFactory, anticaptureClient);
4330
}
4431

45-
async handleMessage(message: DispatcherMessage<VoteEvent>): Promise<MessageProcessingResult> {
32+
async handleMessage(message: DispatcherMessage<VoteWithDaoId>): Promise<MessageProcessingResult> {
4633
const events = message.events;
4734

4835
if (!events || events.length === 0) {
@@ -54,7 +41,7 @@ export class VoteConfirmationTriggerHandler extends BaseTriggerHandler<VoteEvent
5441
}
5542

5643
// Extract unique voter addresses and batch fetch wallet owners
57-
const voterAddresses = [...new Set(events.map(event => event.voterAccountId))];
44+
const voterAddresses = [...new Set(events.map(event => event.voterAddress))];
5845
const walletOwners = await this.subscriptionClient.getWalletOwnersBatch(voterAddresses);
5946

6047
// Create all user-vote combinations
@@ -75,11 +62,11 @@ export class VoteConfirmationTriggerHandler extends BaseTriggerHandler<VoteEvent
7562
* Creates combinations of users and votes for processing
7663
*/
7764
private createUserVoteCombinations(
78-
events: VoteEvent[],
65+
events: VoteWithDaoId[],
7966
walletOwners: Record<string, any[]>
8067
): UserVoteCombination[] {
8168
return events.flatMap(voteEvent => {
82-
const usersForWallet = walletOwners[voteEvent.voterAccountId] || [];
69+
const usersForWallet = walletOwners[voteEvent.voterAddress] || [];
8370
return usersForWallet.map(user => ({ user, vote: voteEvent }));
8471
});
8572
}
@@ -106,9 +93,11 @@ export class VoteConfirmationTriggerHandler extends BaseTriggerHandler<VoteEvent
10693
/**
10794
* Processes a single user-vote combination
10895
*/
109-
private async processUserVote(user: any, vote: VoteEvent): Promise<ProcessingStatus> {
96+
private async processUserVote(user: any, vote: VoteWithDaoId): Promise<ProcessingStatus> {
97+
const eventId = `${vote.transactionHash}-${vote.proposalId}-${vote.voterAddress}-vote`;
98+
11099
// Check if user is subscribed to the DAO
111-
const subscribers = await this.getSubscribers(vote.daoId, vote.txHash, vote.timestamp);
100+
const subscribers = await this.getSubscribers(vote.daoId, eventId, String(vote.timestamp));
112101
const isSubscribed = subscribers.some(sub => sub.id === user.id);
113102

114103
if (!isSubscribed) {
@@ -117,14 +106,14 @@ export class VoteConfirmationTriggerHandler extends BaseTriggerHandler<VoteEvent
117106
}
118107

119108
// Check deduplication
120-
const notifications = await this.subscriptionClient.shouldSend([user], vote.txHash, vote.daoId);
109+
const notifications = await this.subscriptionClient.shouldSend([user], eventId, vote.daoId);
121110
if (notifications.length === 0) {
122-
console.log(`[VoteConfirmationHandler] Notification already sent for vote ${vote.txHash}`);
111+
console.log(`[VoteConfirmationHandler] Notification already sent for vote ${vote.transactionHash}`);
123112
return 'skipped';
124113
}
125114

126115
// Send notification
127-
await this.sendVoteNotification(user, vote);
116+
await this.sendVoteNotification(user, vote, eventId);
128117
console.log(`[VoteConfirmationHandler] Sent vote notification to user ${user.id}`);
129118

130119
return 'sent';
@@ -133,55 +122,51 @@ export class VoteConfirmationTriggerHandler extends BaseTriggerHandler<VoteEvent
133122
/**
134123
* Sends notification for a single vote
135124
*/
136-
private async sendVoteNotification(user: any, vote: VoteEvent): Promise<void> {
125+
private async sendVoteNotification(user: any, vote: VoteWithDaoId, eventId: string): Promise<void> {
137126
const message = this.formatVoteMessage(vote);
138127
const chainId = await this.getChainIdForDao(vote.daoId);
139128

140129
// Build buttons
141130
const buttons = buildButtons({
142131
triggerType: 'voteConfirmation',
143-
txHash: vote.txHash,
132+
txHash: vote.transactionHash,
144133
chainId
145134
});
146135

147136
await this.sendNotificationsToSubscribers(
148137
[user],
149138
message,
150-
vote.txHash,
139+
eventId,
151140
vote.daoId,
152141
{
153142
transaction: {
154-
hash: vote.txHash,
143+
hash: vote.transactionHash,
155144
chainId
156145
},
157146
addresses: {
158-
address: vote.voterAccountId
147+
address: vote.voterAddress
159148
},
160149
proposalId: vote.proposalId,
161-
support: vote.support,
150+
support: String(vote.support),
162151
votingPower: vote.votingPower,
163152
reason: vote.reason
164153
},
165154
buttons
166155
);
167156
}
168157

169-
private formatVoteMessage(vote: VoteEvent): string {
170-
const supportKey = voteConfirmationMessages.getSupportKey(vote.support);
158+
private formatVoteMessage(vote: VoteWithDaoId): string {
159+
const supportKey = voteConfirmationMessages.getSupportKey(String(vote.support));
171160
const votingPower = formatTokenAmount(vote.votingPower, 18);
172161
const hasReason = vote.reason && vote.reason.trim();
173-
const proposalTitle = FormattingService.extractTitle(
174-
vote.proposalDescription || '',
175-
vote.proposalId
176-
);
177162

178163
const messageTemplate = hasReason
179164
? voteConfirmationMessages.withReason[supportKey]
180165
: voteConfirmationMessages.withoutReason[supportKey];
181166

182167
return replacePlaceholders(messageTemplate, {
183168
daoId: vote.daoId,
184-
proposalTitle,
169+
proposalTitle: vote.proposalTitle,
185170
votingPower,
186171
...(hasReason && { reason: vote.reason! })
187172
});

0 commit comments

Comments
 (0)