Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
54bd563
docs: add snapshot voting reminder design spec
LeonardoVieira1630 Apr 1, 2026
38ab041
docs: add snapshot voting reminder implementation plan
LeonardoVieira1630 Apr 1, 2026
bc76400
feat: add offchain voting reminder message template, enum, and button…
LeonardoVieira1630 Apr 1, 2026
1c24657
feat: add getOffchainProposalNonVoters method and start field to Offc…
LeonardoVieira1630 Apr 1, 2026
9597066
feat: add VotingReminderProposal interface and mapper functions
LeonardoVieira1630 Apr 1, 2026
fa960e0
refactor: generalize VotingReminderTrigger to use normalized VotingRe…
LeonardoVieira1630 Apr 1, 2026
b48a36e
feat: add listActiveForReminder to repositories and wire offchain vot…
LeonardoVieira1630 Apr 1, 2026
2573207
feat: add OffchainVotingReminderTriggerHandler and register in dispat…
LeonardoVieira1630 Apr 1, 2026
4989c7e
test: add listActiveForReminder tests for proposal repositories
LeonardoVieira1630 Apr 1, 2026
a88711b
test: add unit tests for OffchainVotingReminderTriggerHandler
LeonardoVieira1630 Apr 1, 2026
94966e0
feat: update queries
LeonardoVieira1630 Apr 1, 2026
4b8b06b
delete: unnecessary docs
LeonardoVieira1630 Apr 1, 2026
70e5187
remove: unnecessary proposal.time declaration
LeonardoVieira1630 Apr 1, 2026
fcc4a97
refactor: const name TRIGGER_ID_PREFIX
LeonardoVieira1630 Apr 1, 2026
39ca57e
add: triggerIdPrefix to be passad as a const
LeonardoVieira1630 Apr 1, 2026
54f7beb
remove: unnecessary comment
LeonardoVieira1630 Apr 1, 2026
d268c41
remove: repo tests
LeonardoVieira1630 Apr 1, 2026
862353c
remove: duplicated test
LeonardoVieira1630 Apr 1, 2026
089cb24
add: integration tests for voting reminder
LeonardoVieira1630 Apr 2, 2026
b353c84
refactor: notification to use the new generated api files
LeonardoVieira1630 Apr 2, 2026
edd9a8c
feat: modular voting reminder handler
LeonardoVieira1630 Apr 2, 2026
4952592
fix: tests
LeonardoVieira1630 Apr 2, 2026
c22d704
refactor: re-generated client
LeonardoVieira1630 Apr 2, 2026
c398c8a
Merge branch 'dev' into feat/snapshot_voting_reminder
LeonardoVieira1630 Apr 9, 2026
150b1e1
refactor: generated files
LeonardoVieira1630 Apr 9, 2026
1178b7a
refactor: delete unnecessary docs
LeonardoVieira1630 Apr 9, 2026
5dc36ce
refactor: test mock
LeonardoVieira1630 Apr 9, 2026
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
22 changes: 18 additions & 4 deletions apps/dispatcher/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ import { NonVotingHandler } from './services/triggers/non-voting-handler.service
import { VoteConfirmationTriggerHandler } from './services/triggers/vote-confirmation-trigger.service';
import { OffchainVoteCastTriggerHandler } from './services/triggers/offchain-vote-cast-trigger.service';
import { VotingReminderTriggerHandler } from './services/triggers/voting-reminder-trigger.service';
import { NonVotersSource } from './interfaces/voting-reminder.interface';
import { RabbitMQConnection, RabbitMQPublisher } from '@notification-system/rabbitmq-client';
import { AnticaptureClient } from '@notification-system/anticapture-client';
import { NotificationTypeId } from '@notification-system/messages';
import { NotificationTypeId, votingReminderMessages, offchainVotingReminderMessages } from '@notification-system/messages';

export class App {
private rabbitMQConsumerService!: RabbitMQConsumerService;
Expand Down Expand Up @@ -104,17 +105,30 @@ export class App {
new OffchainVoteCastTriggerHandler(subscriptionClient, notificationFactory)
);

const onchainNonVotersSource: NonVotersSource = {
getNonVoters: (id, daoId, addrs) => anticaptureClient.getProposalNonVoters(id, daoId, addrs)
};

const offchainNonVotersSource: NonVotersSource = {
getNonVoters: (id, _daoId, addrs) => anticaptureClient.getOffchainProposalNonVoters(id, addrs)
};

triggerProcessorService.addHandler(
NotificationTypeId.VotingReminder30,
new VotingReminderTriggerHandler(subscriptionClient, notificationFactory, anticaptureClient)
new VotingReminderTriggerHandler(subscriptionClient, notificationFactory, anticaptureClient, onchainNonVotersSource, votingReminderMessages, 'voting-reminder')
);
triggerProcessorService.addHandler(
NotificationTypeId.VotingReminder60,
new VotingReminderTriggerHandler(subscriptionClient, notificationFactory, anticaptureClient)
new VotingReminderTriggerHandler(subscriptionClient, notificationFactory, anticaptureClient, onchainNonVotersSource, votingReminderMessages, 'voting-reminder')
);
triggerProcessorService.addHandler(
NotificationTypeId.VotingReminder90,
new VotingReminderTriggerHandler(subscriptionClient, notificationFactory, anticaptureClient)
new VotingReminderTriggerHandler(subscriptionClient, notificationFactory, anticaptureClient, onchainNonVotersSource, votingReminderMessages, 'voting-reminder')
);

triggerProcessorService.addHandler(
NotificationTypeId.OffchainVotingReminder75,
new VotingReminderTriggerHandler(subscriptionClient, notificationFactory, anticaptureClient, offchainNonVotersSource, offchainVotingReminderMessages, 'offchain-voting-reminder')
);

this.rabbitMQConsumerService = new RabbitMQConsumerService(this.rabbitmqUrl, triggerProcessorService);
Expand Down
21 changes: 21 additions & 0 deletions apps/dispatcher/src/interfaces/voting-reminder.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export interface VotingReminderEvent {
id: string;
daoId: string;
title?: string;
description?: string;
startTimestamp: number;
endTimestamp: number;
timeElapsedPercentage: number;
thresholdPercentage: number;
link?: string;
discussion?: string;
}

export interface NonVotersSource {
getNonVoters(proposalId: string, daoId: string, addresses: string[]): Promise<{ voter: string }[]>;
}

export interface VotingReminderMessageSet {
getMessageKey(thresholdPercentage: number): string;
getTemplate(key: string): string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { DispatcherMessage, MessageProcessingResult } from '../../interfaces/dis
import { ISubscriptionClient } from '../../interfaces/subscription-client.interface';
import { NotificationClientFactory } from '../notification/notification-factory.service';
import { ProposalFinishedNotification } from '../../interfaces/notification-client.interface';
import { AnticaptureClient, OrderDirection } from '@notification-system/anticapture-client';
import { AnticaptureClient, OrderDirection, QueryInput_Proposals_Status_Items } from '@notification-system/anticapture-client';
import { BatchNotificationService } from '../batch-notification.service';
import { FormattingService } from '../formatting.service';
import { ValidationService } from '../validation.service';
Expand Down Expand Up @@ -161,7 +161,7 @@ export class NonVotingHandler extends BaseTriggerHandler<ProposalFinishedNotific
currentEndTimestamp: number
): Promise<any[]> {
const proposals = await this.anticaptureClient!.listProposals({
status: ['EXECUTED', 'SUCCEEDED', 'DEFEATED', 'EXPIRED', 'CANCELED'] as any,
status: [QueryInput_Proposals_Status_Items.Executed, QueryInput_Proposals_Status_Items.Succeeded, QueryInput_Proposals_Status_Items.Defeated, QueryInput_Proposals_Status_Items.Expired, QueryInput_Proposals_Status_Items.Canceled],
limit: NonVotingHandler.PROPOSALS_TO_CHECK * NonVotingHandler.FETCH_MARGIN_MULTIPLIER,
orderDirection: OrderDirection.Desc
}, daoId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ import { ISubscriptionClient } from '../../interfaces/subscription-client.interf
import { NotificationClientFactory } from '../notification/notification-factory.service';
import { AnticaptureClient } from '@notification-system/anticapture-client';
import { FormattingService } from '../formatting.service';
import { NotificationTypeId } from '@notification-system/messages';
import { NotificationTypeId, votingReminderMessages } from '@notification-system/messages';
import { NonVotersSource, VotingReminderMessageSet } from '../../interfaces/voting-reminder.interface';

describe('VotingReminderTriggerHandler', () => {
let handler: VotingReminderTriggerHandler;
let mockSubscriptionClient: jest.Mocked<ISubscriptionClient>;
let mockNotificationFactory: jest.Mocked<NotificationClientFactory>;
let mockAnticaptureClient: jest.Mocked<AnticaptureClient>;
let mockNonVotersSource: jest.Mocked<NonVotersSource>;

const mockUser = {
id: 'user-123',
Expand Down Expand Up @@ -50,14 +52,19 @@ describe('VotingReminderTriggerHandler', () => {
})
} as any;

mockAnticaptureClient = {
getProposalNonVoters: jest.fn()
} as any;
mockAnticaptureClient = {} as any;

mockNonVotersSource = {
getNonVoters: jest.fn()
} as jest.Mocked<NonVotersSource>;

handler = new VotingReminderTriggerHandler(
mockSubscriptionClient,
mockNotificationFactory,
mockAnticaptureClient
mockAnticaptureClient,
mockNonVotersSource,
votingReminderMessages,
'voting-reminder'
);

// Mock Date.now for consistent time calculations
Expand Down Expand Up @@ -89,8 +96,8 @@ describe('VotingReminderTriggerHandler', () => {

// Setup mocks
mockSubscriptionClient.getFollowedAddresses.mockResolvedValue(['0x123', '0x456']);
mockAnticaptureClient.getProposalNonVoters.mockResolvedValue([
{voter: '0x456'} // Only 0x456 hasn't voted
mockNonVotersSource.getNonVoters.mockResolvedValue([
{ voter: '0x456' } // Only 0x456 hasn't voted
]);
mockSubscriptionClient.getWalletOwnersBatch.mockResolvedValue({
'0x456': [mockUser] // Only 0x456 (non-voter) has users
Expand All @@ -105,7 +112,7 @@ describe('VotingReminderTriggerHandler', () => {

expect(result.messageId).toMatch(/voting-reminder-/);
expect(mockSubscriptionClient.getFollowedAddresses).toHaveBeenCalledWith('test-dao');
expect(mockAnticaptureClient.getProposalNonVoters).toHaveBeenCalledWith(
expect(mockNonVotersSource.getNonVoters).toHaveBeenCalledWith(
'proposal-123',
'test-dao',
['0x123', '0x456']
Expand All @@ -124,7 +131,7 @@ describe('VotingReminderTriggerHandler', () => {
const result = await handler.handleMessage(message);

expect(result.messageId).toMatch(/voting-reminder-/);
expect(mockAnticaptureClient.getProposalNonVoters).not.toHaveBeenCalled();
expect(mockNonVotersSource.getNonVoters).not.toHaveBeenCalled();
});

it('should skip when all users have already voted', async () => {
Expand All @@ -134,7 +141,7 @@ describe('VotingReminderTriggerHandler', () => {
};

mockSubscriptionClient.getFollowedAddresses.mockResolvedValue(['0x123']);
mockAnticaptureClient.getProposalNonVoters.mockResolvedValue([]); // Empty array - all have voted
mockNonVotersSource.getNonVoters.mockResolvedValue([]); // Empty array - all have voted

const result = await handler.handleMessage(message);

Expand All @@ -149,8 +156,8 @@ describe('VotingReminderTriggerHandler', () => {
};

mockSubscriptionClient.getFollowedAddresses.mockResolvedValue(['0x456']);
mockAnticaptureClient.getProposalNonVoters.mockResolvedValue([
{voter: '0x456'}
mockNonVotersSource.getNonVoters.mockResolvedValue([
{ voter: '0x456' }
]);
mockSubscriptionClient.getWalletOwnersBatch.mockResolvedValue({
'0x456': [mockUser]
Expand Down Expand Up @@ -195,7 +202,7 @@ describe('VotingReminderTriggerHandler', () => {
};

const message = (handler as any).createReminderMessage(eventWithoutTitle);

expect(message).toContain('Proposal: "Update governance parameters. This proposal aims to improve the system."');
});

Expand All @@ -207,7 +214,7 @@ describe('VotingReminderTriggerHandler', () => {
};

const message = (handler as any).createReminderMessage(eventWithLongDescription);

expect(message).toContain('Proposal: "This is a very long description that exceeds the maximum length for a title and..."');
});
});
Expand All @@ -217,30 +224,30 @@ describe('VotingReminderTriggerHandler', () => {
// Mock current time to be 1500000 (middle of proposal period)
const endTimestamp = 2000000;
const remaining = FormattingService.calculateTimeRemaining(endTimestamp);

expect(remaining).toContain('day'); // Should show days remaining
});

it('should handle proposals that have ended', () => {
const endTimestamp = 1000000; // Before current time (1500000)
const remaining = FormattingService.calculateTimeRemaining(endTimestamp);

expect(remaining).toBe('Proposal has ended');
});

it('should format time in hours when less than a day remains', () => {
jest.spyOn(Date, 'now').mockReturnValue(1990000 * 1000); // Close to end
const endTimestamp = 2000000;
const remaining = FormattingService.calculateTimeRemaining(endTimestamp);

expect(remaining).toMatch(/hour/);
});

it('should format time in minutes when less than an hour remains', () => {
jest.spyOn(Date, 'now').mockReturnValue(1999000 * 1000); // Very close to end
const endTimestamp = 2000000;
const remaining = FormattingService.calculateTimeRemaining(endTimestamp);

expect(remaining).toMatch(/minute/);
});
});
Expand All @@ -249,7 +256,7 @@ describe('VotingReminderTriggerHandler', () => {
it('should continue processing other events when one fails', async () => {
const failingEvent = { ...mockVotingReminderEvent, id: 'failing-proposal' };
const successfulEvent = { ...mockVotingReminderEvent, id: 'successful-proposal' };

const message: DispatcherMessage = {
triggerId: NotificationTypeId.VotingReminder90,
events: [failingEvent, successfulEvent]
Expand All @@ -260,8 +267,8 @@ describe('VotingReminderTriggerHandler', () => {
.mockRejectedValueOnce(new Error('Network error'))
.mockResolvedValueOnce(['0x456']);

mockAnticaptureClient.getProposalNonVoters.mockResolvedValue([
{voter: '0x456'}
mockNonVotersSource.getNonVoters.mockResolvedValue([
{ voter: '0x456' }
]);
mockSubscriptionClient.getWalletOwnersBatch.mockResolvedValue({
'0x456': [mockUser]
Expand All @@ -279,4 +286,4 @@ describe('VotingReminderTriggerHandler', () => {
expect(mockSubscriptionClient.getFollowedAddresses).toHaveBeenCalledTimes(2);
});
});
});
});
Loading
Loading