diff --git a/apps/dispatcher/src/app.ts b/apps/dispatcher/src/app.ts index 25bee677..8afcd8a5 100644 --- a/apps/dispatcher/src/app.ts +++ b/apps/dispatcher/src/app.ts @@ -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; @@ -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); diff --git a/apps/dispatcher/src/interfaces/voting-reminder.interface.ts b/apps/dispatcher/src/interfaces/voting-reminder.interface.ts new file mode 100644 index 00000000..e07961cd --- /dev/null +++ b/apps/dispatcher/src/interfaces/voting-reminder.interface.ts @@ -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; +} diff --git a/apps/dispatcher/src/services/triggers/non-voting-handler.service.ts b/apps/dispatcher/src/services/triggers/non-voting-handler.service.ts index f9e69d3c..2c0f966c 100644 --- a/apps/dispatcher/src/services/triggers/non-voting-handler.service.ts +++ b/apps/dispatcher/src/services/triggers/non-voting-handler.service.ts @@ -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'; @@ -161,7 +161,7 @@ export class NonVotingHandler extends BaseTriggerHandler { 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); diff --git a/apps/dispatcher/src/services/triggers/voting-reminder-trigger.service.test.ts b/apps/dispatcher/src/services/triggers/voting-reminder-trigger.service.test.ts index e3190c7c..d71f1c13 100644 --- a/apps/dispatcher/src/services/triggers/voting-reminder-trigger.service.test.ts +++ b/apps/dispatcher/src/services/triggers/voting-reminder-trigger.service.test.ts @@ -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; let mockNotificationFactory: jest.Mocked; let mockAnticaptureClient: jest.Mocked; + let mockNonVotersSource: jest.Mocked; const mockUser = { id: 'user-123', @@ -50,14 +52,19 @@ describe('VotingReminderTriggerHandler', () => { }) } as any; - mockAnticaptureClient = { - getProposalNonVoters: jest.fn() - } as any; + mockAnticaptureClient = {} as any; + + mockNonVotersSource = { + getNonVoters: jest.fn() + } as jest.Mocked; handler = new VotingReminderTriggerHandler( mockSubscriptionClient, mockNotificationFactory, - mockAnticaptureClient + mockAnticaptureClient, + mockNonVotersSource, + votingReminderMessages, + 'voting-reminder' ); // Mock Date.now for consistent time calculations @@ -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 @@ -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'] @@ -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 () => { @@ -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); @@ -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] @@ -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."'); }); @@ -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..."'); }); }); @@ -217,14 +224,14 @@ 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'); }); @@ -232,7 +239,7 @@ describe('VotingReminderTriggerHandler', () => { jest.spyOn(Date, 'now').mockReturnValue(1990000 * 1000); // Close to end const endTimestamp = 2000000; const remaining = FormattingService.calculateTimeRemaining(endTimestamp); - + expect(remaining).toMatch(/hour/); }); @@ -240,7 +247,7 @@ describe('VotingReminderTriggerHandler', () => { jest.spyOn(Date, 'now').mockReturnValue(1999000 * 1000); // Very close to end const endTimestamp = 2000000; const remaining = FormattingService.calculateTimeRemaining(endTimestamp); - + expect(remaining).toMatch(/minute/); }); }); @@ -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] @@ -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] @@ -279,4 +286,4 @@ describe('VotingReminderTriggerHandler', () => { expect(mockSubscriptionClient.getFollowedAddresses).toHaveBeenCalledTimes(2); }); }); -}); \ No newline at end of file +}); diff --git a/apps/dispatcher/src/services/triggers/voting-reminder-trigger.service.ts b/apps/dispatcher/src/services/triggers/voting-reminder-trigger.service.ts index 5849ea67..6b5aaa3e 100644 --- a/apps/dispatcher/src/services/triggers/voting-reminder-trigger.service.ts +++ b/apps/dispatcher/src/services/triggers/voting-reminder-trigger.service.ts @@ -1,26 +1,18 @@ import type { NotificationTypeId } from '@notification-system/messages'; + import { BaseTriggerHandler } from './base-trigger.service'; import { DispatcherMessage, MessageProcessingResult } from '../../interfaces/dispatcher-message.interface'; import { NotificationClientFactory } from '../notification/notification-factory.service'; import { ISubscriptionClient } from '../../interfaces/subscription-client.interface'; import { AnticaptureClient } from '@notification-system/anticapture-client'; import { FormattingService } from '../formatting.service'; -import { votingReminderMessages, replacePlaceholders, buildButtons } from '@notification-system/messages'; +import { replacePlaceholders, buildButtons } from '@notification-system/messages'; import { BatchNotificationService } from '../batch-notification.service'; - -/** - * Event data received from logic system for voting reminders - */ -interface VotingReminderEvent { - id: string; - daoId: string; - title?: string; - description: string; - startTimestamp: number; - endTimestamp: number; - timeElapsedPercentage: number; - thresholdPercentage: number; -} +import { + VotingReminderEvent, + NonVotersSource, + VotingReminderMessageSet, +} from '../../interfaces/voting-reminder.interface'; /** * Processing statistics for monitoring @@ -41,7 +33,10 @@ export class VotingReminderTriggerHandler extends BaseTriggerHandler): Promise { const events = message.events; - + if (!events || events.length === 0) { - return { - messageId: `voting-reminder-empty-${Date.now()}`, - timestamp: new Date().toISOString() + return { + messageId: `${this.triggerType}-empty-${Date.now()}`, + timestamp: new Date().toISOString(), }; } @@ -70,11 +65,13 @@ export class VotingReminderTriggerHandler extends BaseTriggerHandler nv.voter); if (nonVotingAddresses.length === 0) { return { sent: 0, skipped: 1, failed: 0 }; @@ -102,9 +96,11 @@ export class VotingReminderTriggerHandler extends BaseTriggerHandler `${event.id}-${event.thresholdPercentage}-reminder`, - (address) => this.createReminderMessage(event, address), + () => `${event.id}-${event.thresholdPercentage}-${this.triggerType}`, + (_address) => this.createReminderMessage(event), (address) => ({ - triggerType: 'votingReminder', + triggerType: this.triggerType, proposalId: event.id, thresholdPercentage: event.thresholdPercentage, timeElapsedPercentage: event.timeElapsedPercentage, timeRemaining: FormattingService.calculateTimeRemaining(event.endTimestamp), - addresses: { address: address } + addresses: { address: address }, }), - () => buttons + () => buttons, ); - return { - sent: sentCount, - skipped: 0, - failed: 0 - }; - } - /** - * Gets addresses that haven't voted on the specific proposal - */ - private async getNonVotingAddresses( - proposalId: string, - daoId: string, - subscribedAddresses: string[] - ): Promise { - const nonVoters = await this.anticaptureClient!.getProposalNonVoters( - proposalId, - daoId, - subscribedAddresses - ); - return nonVoters.map(nv => nv.voter); + return { sent: sentCount, skipped: 0, failed: 0 }; } /** * Creates the reminder message based on proposal data and urgency level */ - private createReminderMessage(event: VotingReminderEvent, address?: string): string { + private createReminderMessage(event: VotingReminderEvent): string { const timeRemaining = FormattingService.calculateTimeRemaining(event.endTimestamp); - const title = event.title || FormattingService.extractTitle(event.description); + const title = + event.title || FormattingService.extractTitle(event.description ?? '') || 'Untitled Proposal'; // Get the message key based on threshold - const messageKey = votingReminderMessages.getMessageKey(event.thresholdPercentage); + const messageKey = this.messages.getMessageKey(event.thresholdPercentage); // Get the complete message template - const messageTemplate = votingReminderMessages[messageKey]; + const messageTemplate = this.messages.getTemplate(messageKey); // Replace all placeholders return replacePlaceholders(messageTemplate, { daoId: event.daoId, title, timeRemaining, - thresholdPercentage: event.thresholdPercentage.toString() + thresholdPercentage: event.thresholdPercentage.toString(), }); } -} \ No newline at end of file +} diff --git a/apps/integrated-tests/src/fixtures/factories/offchain-proposal-factory.ts b/apps/integrated-tests/src/fixtures/factories/offchain-proposal-factory.ts index 61acf82d..85fe0a63 100644 --- a/apps/integrated-tests/src/fixtures/factories/offchain-proposal-factory.ts +++ b/apps/integrated-tests/src/fixtures/factories/offchain-proposal-factory.ts @@ -11,6 +11,7 @@ export interface OffchainProposalData { state: string; created: number; end: number; + start?: number; // actual voting start time (falls back to created if absent) } /** diff --git a/apps/integrated-tests/src/mocks/graphql-mock-setup.ts b/apps/integrated-tests/src/mocks/graphql-mock-setup.ts index 3ad67787..0a156855 100644 --- a/apps/integrated-tests/src/mocks/graphql-mock-setup.ts +++ b/apps/integrated-tests/src/mocks/graphql-mock-setup.ts @@ -88,7 +88,7 @@ export class GraphQLMockSetup { filtered = filtered.filter(p => p.daoId === config.headers['anticapture-dao-id']); } return Promise.resolve({ - data: { data: { offchainProposals: { items: filtered.map(p => ({ id: p.id, title: p.title, discussion: p.discussion, link: p.link, state: p.state, created: p.created, end: p.end })), totalCount: filtered.length } } } + data: { data: { offchainProposals: { items: filtered.map(p => ({ id: p.id, title: p.title, discussion: p.discussion, link: p.link, state: p.state, created: p.created, end: p.end, start: p.start })), totalCount: filtered.length } } } }); } @@ -198,6 +198,30 @@ export class GraphQLMockSetup { }); } + // Handle offchain proposal non-voters (MUST come before ProposalNonVoters check) + if (data.query?.includes('OffchainProposalNonVoters')) { + const proposalId = data.variables?.id; + const addressesFilter = data.variables?.addresses || []; + + // Get addresses that voted on this offchain proposal + const votersSet = new Set( + offchainVotesData + .filter((v: any) => v.proposalId === proposalId) + .map((v: any) => v.voter.toLowerCase()) + ); + + // Filter to find non-voters from the provided address list + const nonVoterItems = addressesFilter + .filter((addr: string) => !votersSet.has(addr.toLowerCase())) + .map((addr: string) => ({ + voter: addr + })); + + return Promise.resolve({ + data: { data: { offchainProposalNonVoters: { items: nonVoterItems, totalCount: nonVoterItems.length } } } + }); + } + // Handle proposal non-voters if (data.query?.includes('ProposalNonVoters')) { const proposalId = data.variables?.id; diff --git a/apps/integrated-tests/src/setup/services/apps.ts b/apps/integrated-tests/src/setup/services/apps.ts index 6b4acf2a..f15910a1 100644 --- a/apps/integrated-tests/src/setup/services/apps.ts +++ b/apps/integrated-tests/src/setup/services/apps.ts @@ -24,6 +24,7 @@ import { SlackTestClient } from '../../test-clients/slack-test.client'; import { jest } from '@jest/globals'; import { mockTelegramSendMessage } from '../../mocks/telegram-mock-setup'; import { mockSlackSendMessage } from '../../mocks/slack-mock-setup'; +import { QueryInput_Proposals_Status_Items } from '@notification-system/anticapture-client'; /** * @notice Type definition for test applications container @@ -185,7 +186,8 @@ const startConsumer = async ( rabbitmqUrl, mockEnsResolver, telegramClient, - slackClient + slackClient, + 3003 ); await consumerApp.start(); return consumerApp; @@ -226,7 +228,7 @@ const startLogicSystem = async ( const oneYearAgo = Math.floor((Date.now() - 365 * 24 * 60 * 60 * 1000) / 1000).toString(); const logicSystemApp = new LogicSystemApp( TEST_CONFIG.logicSystem.interval, - TEST_CONFIG.logicSystem.proposalState, + QueryInput_Proposals_Status_Items.Active, mockHttpClient, rabbitmqUrl, oneYearAgo diff --git a/apps/integrated-tests/tests/slack/voting-reminder-trigger.test.ts b/apps/integrated-tests/tests/slack/voting-reminder-trigger.test.ts index 53c0f132..892c1ebb 100644 --- a/apps/integrated-tests/tests/slack/voting-reminder-trigger.test.ts +++ b/apps/integrated-tests/tests/slack/voting-reminder-trigger.test.ts @@ -438,8 +438,8 @@ describe('Slack Voting Reminder Trigger - Integration Test', () => { const eventIds = relevantNotifs.map(n => n.event_id); expect(eventIds).toEqual( expect.arrayContaining([ - expect.stringContaining('30-reminder'), - expect.stringContaining('60-reminder') + expect.stringContaining('30-voting-reminder'), + expect.stringContaining('60-voting-reminder') ]) ); }); diff --git a/apps/integrated-tests/tests/telegram/offchain-voting-reminder-trigger.test.ts b/apps/integrated-tests/tests/telegram/offchain-voting-reminder-trigger.test.ts new file mode 100644 index 00000000..715fab2f --- /dev/null +++ b/apps/integrated-tests/tests/telegram/offchain-voting-reminder-trigger.test.ts @@ -0,0 +1,210 @@ +/** + * @fileoverview Integration tests for the Snapshot (off-chain) voting reminder feature + * Tests the complete flow for the offchainVotingReminderTrigger75 trigger + * which fires at 75% elapsed time (within 75-80% window) + */ + +import { describe, test, expect, beforeEach, beforeAll } from '@jest/globals'; +import { db, TestApps } from '../../src/setup'; +import { HttpClientMockSetup, GraphQLMockSetup } from '../../src/mocks'; +import { UserFactory, OffchainProposalFactory } from '../../src/fixtures'; +import { TelegramTestHelper, DatabaseTestHelper, TestCleanup } from '../../src/helpers'; +import { testConstants, timeouts } from '../../src/config'; +import { waitForCondition } from '../../src/helpers/utilities/wait-for'; + +describe('Offchain Voting Reminder Integration Tests', () => { + let apps: TestApps; + let httpMockSetup: HttpClientMockSetup; + let telegramHelper: TelegramTestHelper; + let dbHelper: DatabaseTestHelper; + + const testDaoId = 'test-dao-offchain-reminder'; + const testUser = { + chatId: testConstants.profiles.p1.chatId, + address: '0x1234567890abcdef1234567890abcdef12345678' + }; + + /** + * Creates an offchain proposal with a specific elapsed time percentage + * @param proposalId - Unique identifier for the proposal + * @param elapsedPercentage - Percentage of voting period that has elapsed (0-100) + */ + const createOffchainProposalWithElapsedTime = (proposalId: string, elapsedPercentage: number) => { + const now = Math.floor(Date.now() / 1000); + const duration = 100000; // seconds + const elapsed = Math.floor(duration * (elapsedPercentage / 100)); + const start = now - elapsed; + const end = start + duration; + + return OffchainProposalFactory.createProposal(testDaoId, proposalId, { + state: 'active', + start, + end, + created: start, + title: `Snapshot Proposal ${elapsedPercentage}% Test` + }); + }; + + beforeAll(async () => { + apps = TestCleanup.getGlobalApps(); + httpMockSetup = TestCleanup.getGlobalHttpMockSetup(); + telegramHelper = new TelegramTestHelper(global.mockTelegramSendMessage); + dbHelper = new DatabaseTestHelper(db); + }); + + beforeEach(async () => { + await TestCleanup.cleanupBetweenTests(); + + // Create test user with subscription to the DAO and wallet address + const pastTimestamp = new Date(Date.now() - timeouts.wait.long).toISOString(); + await UserFactory.createUserWithFollowedAddresses( + testUser.chatId, + 'offchain-voting-reminder-user', + testDaoId, + [testUser.address], + true, + pastTimestamp + ); + }); + + describe('75% Reminder Threshold', () => { + test('should send Snapshot voting reminder when 77% of voting period has elapsed and user has not voted', async () => { + // Create proposal where 77% of time has elapsed (within 75-80% window) + const proposal = createOffchainProposalWithElapsedTime('offchain-proposal-75-reminder', 77); + + // Setup mock with no offchain votes — user has NOT voted + GraphQLMockSetup.setupMock( + httpMockSetup.getMockClient(), + [], // no on-chain proposals + [], // no voting power data + { [testDaoId]: 1 }, + [], // no on-chain votes + [proposal], + [] // no offchain votes + ); + + // Wait for the notification to be sent + const message = await telegramHelper.waitForMessage( + msg => + msg.text.includes('Snapshot Voting Reminder') || + msg.text.includes('75% of voting period has passed'), + { timeout: timeouts.notification.delivery } + ); + + // Verify message content matches the expected template + expect(message.chatId).toBe(testUser.chatId); + expect(message.text).toContain('⏰ Snapshot Voting Reminder'); + expect(message.text).toContain('75% of voting period has passed'); + expect(message.text).toContain(testDaoId); + + // Verify database record exists for deduplication + const notifications = await dbHelper.getNotifications(); + const relevantNotifs = notifications.filter(n => + n.event_id?.includes('75-reminder') || n.event_id?.includes('offchain-proposal-75-reminder') + ); + expect(relevantNotifs).toHaveLength(1); + }); + + test('should NOT send reminder when user has already voted on the Snapshot proposal', async () => { + // Create proposal where 77% of time has elapsed + const proposal = createOffchainProposalWithElapsedTime('offchain-proposal-75-voted', 77); + + // Setup mock with user's offchain vote already recorded + const offchainVotes = [{ + voter: testUser.address, + proposalId: proposal.id, + daoId: testDaoId, + created: Math.floor(Date.now() / 1000), + vp: 1000 + }]; + + GraphQLMockSetup.setupMock( + httpMockSetup.getMockClient(), + [], + [], + { [testDaoId]: 1 }, + [], + [proposal], + offchainVotes // User HAS voted + ); + + // Wait for processing to complete and verify no messages were sent + await waitForCondition( + () => { + const messages = telegramHelper.getAllMessages(); + return messages.length === 0; + }, + 'Expected no offchain voting reminder when user has already voted', + { timeout: 500, interval: 50 } + ); + + const messages = telegramHelper.getAllMessages(); + const snapshotReminderMessages = messages.filter(m => + m.text.includes('Snapshot Voting Reminder') + ); + expect(snapshotReminderMessages).toHaveLength(0); + }); + + test('should NOT send reminder when proposal is at 60% elapsed (below 75% threshold)', async () => { + // Create proposal where only 60% of time has elapsed — below the 75% trigger + const proposal = createOffchainProposalWithElapsedTime('offchain-proposal-below-threshold', 60); + + GraphQLMockSetup.setupMock( + httpMockSetup.getMockClient(), + [], + [], + { [testDaoId]: 1 }, + [], + [proposal], + [] + ); + + // Wait for processing and verify no messages were sent + await waitForCondition( + () => { + const messages = telegramHelper.getAllMessages(); + return messages.length === 0; + }, + 'Expected no offchain voting reminder for proposal below 75% threshold', + { timeout: 500, interval: 50 } + ); + + const messages = telegramHelper.getAllMessages(); + const snapshotReminderMessages = messages.filter(m => + m.text.includes('Snapshot Voting Reminder') + ); + expect(snapshotReminderMessages).toHaveLength(0); + }); + + test('should NOT send reminder when proposal is at 83% elapsed (above 80% window)', async () => { + // Create proposal where 83% of time has elapsed — above the 75-80% window + const proposal = createOffchainProposalWithElapsedTime('offchain-proposal-above-window', 83); + + GraphQLMockSetup.setupMock( + httpMockSetup.getMockClient(), + [], + [], + { [testDaoId]: 1 }, + [], + [proposal], + [] + ); + + // Wait for processing and verify no messages were sent + await waitForCondition( + () => { + const messages = telegramHelper.getAllMessages(); + return messages.length === 0; + }, + 'Expected no offchain voting reminder for proposal above 80% window', + { timeout: 500, interval: 50 } + ); + + const messages = telegramHelper.getAllMessages(); + const snapshotReminderMessages = messages.filter(m => + m.text.includes('Snapshot Voting Reminder') + ); + expect(snapshotReminderMessages).toHaveLength(0); + }); + }); +}); diff --git a/apps/integrated-tests/tests/telegram/voting-reminder-trigger.test.ts b/apps/integrated-tests/tests/telegram/voting-reminder-trigger.test.ts index e6b707e4..bc65a1d9 100644 --- a/apps/integrated-tests/tests/telegram/voting-reminder-trigger.test.ts +++ b/apps/integrated-tests/tests/telegram/voting-reminder-trigger.test.ts @@ -330,8 +330,8 @@ describe('Voting Reminder Integration Tests', () => { const eventIds = relevantNotifs.map(n => n.event_id); expect(eventIds).toEqual( expect.arrayContaining([ - expect.stringContaining('30-reminder'), - expect.stringContaining('60-reminder') + expect.stringContaining('30-voting-reminder'), + expect.stringContaining('60-voting-reminder') ]) ); }); diff --git a/apps/logic-system/src/app.ts b/apps/logic-system/src/app.ts index 8da39bee..f6b56a12 100644 --- a/apps/logic-system/src/app.ts +++ b/apps/logic-system/src/app.ts @@ -13,9 +13,8 @@ import { ThresholdRepository } from './repositories/threshold.repository'; import { VotesRepository } from './repositories/votes.repository'; import { OffchainVotesRepository } from './repositories/offchain-votes.repository'; import { RabbitMQDispatcherService } from './api-clients/rabbitmq-dispatcher.service'; -import { AnticaptureClient } from '@notification-system/anticapture-client'; +import { AnticaptureClient, QueryInput_Proposals_Status_Items } from '@notification-system/anticapture-client'; import { RabbitMQConnection, RabbitMQPublisher } from '@notification-system/rabbitmq-client'; -import { ProposalStatus } from './interfaces/proposal.interface'; import { AxiosInstance } from 'axios'; export class App { @@ -29,14 +28,15 @@ export class App { private votingReminderTrigger30!: VotingReminderTrigger; private votingReminderTrigger60!: VotingReminderTrigger; private votingReminderTrigger90!: VotingReminderTrigger; - private proposalStatus: ProposalStatus; + private offchainVotingReminderTrigger75!: VotingReminderTrigger; + private proposalStatus: QueryInput_Proposals_Status_Items; private rabbitMQConnection!: RabbitMQConnection; private rabbitMQPublisher!: RabbitMQPublisher; private initPromise: Promise; constructor( - triggerInterval: number, - proposalStatus: ProposalStatus, + triggerInterval: number, + proposalStatus: QueryInput_Proposals_Status_Items, anticaptureHttpClient: AxiosInstance, rabbitmqUrl: string, initialTimestamp?: string @@ -139,6 +139,15 @@ export class App { triggerInterval, 90, // 90% threshold ); + + this.offchainVotingReminderTrigger75 = new VotingReminderTrigger( + dispatcherService, + offchainProposalRepository, + triggerInterval, + 75, // 75% threshold + 5, // default window size + 'offchain-voting-reminder' // prefix → produces ID 'offchain-voting-reminder-75' + ); } async start(): Promise { @@ -155,6 +164,7 @@ export class App { this.votingReminderTrigger30.start(); this.votingReminderTrigger60.start(); this.votingReminderTrigger90.start(); + this.offchainVotingReminderTrigger75.start(); console.log('Logic system is running. Press Ctrl+C to stop.'); } @@ -187,6 +197,22 @@ export class App { if (this.offchainProposalFinishedTrigger) { this.offchainProposalFinishedTrigger.reset(initialTimestamp); } + if (this.votingReminderTrigger30) { + this.votingReminderTrigger30.stop(); + this.votingReminderTrigger30.start(); + } + if (this.votingReminderTrigger60) { + this.votingReminderTrigger60.stop(); + this.votingReminderTrigger60.start(); + } + if (this.votingReminderTrigger90) { + this.votingReminderTrigger90.stop(); + this.votingReminderTrigger90.start(); + } + if (this.offchainVotingReminderTrigger75) { + this.offchainVotingReminderTrigger75.stop(); + this.offchainVotingReminderTrigger75.start(); + } } async stop(): Promise { @@ -200,6 +226,7 @@ export class App { await this.votingReminderTrigger30.stop(); await this.votingReminderTrigger60.stop(); await this.votingReminderTrigger90.stop(); + await this.offchainVotingReminderTrigger75.stop(); if (this.rabbitMQPublisher) { await this.rabbitMQPublisher.close(); } diff --git a/apps/logic-system/src/config/env.ts b/apps/logic-system/src/config/env.ts index d3bedb29..73e43d66 100644 --- a/apps/logic-system/src/config/env.ts +++ b/apps/logic-system/src/config/env.ts @@ -5,19 +5,13 @@ import { QueryInput_Proposals_Status_Items } from '@notification-system/anticapt // Load environment variables dotenv.config(); -// Define valid proposal statuses -const validProposalStatuses = [ - 'PENDING', 'ACTIVE', 'SUCCEEDED', 'DEFEATED', - 'EXECUTED', 'CANCELED', 'QUEUED', 'EXPIRED' -] as const; - // Define environment variables schema with validation const envSchema = z.object({ ANTICAPTURE_GRAPHQL_ENDPOINT: z.string().url('ANTICAPTURE_GRAPHQL_ENDPOINT must be a valid URL'), BLOCKFUL_API_TOKEN: z.string().optional(), RABBITMQ_URL: z.string().url(), TRIGGER_INTERVAL: z.coerce.number().optional().default(60000), - PROPOSAL_STATUS: z.enum(validProposalStatuses).transform(s => s as QueryInput_Proposals_Status_Items), + PROPOSAL_STATUS: z.nativeEnum(QueryInput_Proposals_Status_Items), }); const _env = envSchema.safeParse(process.env); diff --git a/apps/logic-system/src/interfaces/proposal.interface.ts b/apps/logic-system/src/interfaces/proposal.interface.ts index 4dff010c..eb183e56 100644 --- a/apps/logic-system/src/interfaces/proposal.interface.ts +++ b/apps/logic-system/src/interfaces/proposal.interface.ts @@ -1,10 +1,10 @@ -import type { ListProposalsQuery } from '@notification-system/anticapture-client'; -import { QueryInput_Proposals_Status_Items } from '@notification-system/anticapture-client'; +import type { GetProposalByIdQuery, QueryInput_Proposals_Status_Items, OrderDirection } from '@notification-system/anticapture-client'; -export type ProposalOnChain = NonNullable['items'][number]>; +type RawProposal = NonNullable; +export type ProposalOnChain = Extract; export type ProposalOrNull = ProposalOnChain | null; -export type ProposalStatus = QueryInput_Proposals_Status_Items; +export type { QueryInput_Proposals_Status_Items as ProposalStatus }; /** * Options for listing proposals (matches new API parameters) @@ -14,7 +14,7 @@ export interface ListProposalsOptions { skip?: number; /** Maximum number of proposals to return */ limit?: number; - /** Filter by status */ + /** Filter by proposal status */ status?: QueryInput_Proposals_Status_Items | QueryInput_Proposals_Status_Items[]; /** Filter by DAO (passed as header, not query param) */ daoId?: string; @@ -22,8 +22,8 @@ export interface ListProposalsOptions { fromDate?: number; /** Filter proposals by end timestamp (timestamp in seconds) */ fromEndDate?: number; - /** Order direction - asc or desc */ - orderDirection?: 'asc' | 'desc'; + /** Order direction */ + orderDirection?: OrderDirection; /** Whether to include optimistic proposals (true=include, false=exclude, undefined=both) */ includeOptimisticProposals?: boolean; } diff --git a/apps/logic-system/src/interfaces/voting-reminder.interface.ts b/apps/logic-system/src/interfaces/voting-reminder.interface.ts new file mode 100644 index 00000000..6ee11805 --- /dev/null +++ b/apps/logic-system/src/interfaces/voting-reminder.interface.ts @@ -0,0 +1,22 @@ +/** + * Normalized proposal data for voting reminders. + * Both on-chain and off-chain proposals are mapped to this shape. + */ +export interface VotingReminderProposal { + id: string; + daoId: string; + title?: string; + description?: string; + startTime: number; + endTime: number; + link?: string; + discussion?: string; +} + +/** + * Data source interface for fetching proposals ready for voting reminders. + * Implemented by both ProposalRepository and OffchainProposalRepository. + */ +export interface VotingReminderDataSource { + listActiveForReminder(): Promise; +} diff --git a/apps/logic-system/src/mappers/proposal-reminder.mapper.ts b/apps/logic-system/src/mappers/proposal-reminder.mapper.ts new file mode 100644 index 00000000..815ed6f8 --- /dev/null +++ b/apps/logic-system/src/mappers/proposal-reminder.mapper.ts @@ -0,0 +1,33 @@ +import { ProposalOnChain } from '../interfaces/proposal.interface'; +import { OffchainProposal } from '../interfaces/offchain-proposal.interface'; +import { VotingReminderProposal } from '../interfaces/voting-reminder.interface'; + +/** + * Maps an on-chain proposal to the normalized VotingReminderProposal shape. + */ +export function mapOnchainToReminderProposal(p: ProposalOnChain): VotingReminderProposal { + return { + id: p.id, + daoId: p.daoId, + title: p.title || undefined, + description: p.description, + startTime: p.timestamp, + endTime: p.endTimestamp, + }; +} + +/** + * Maps an off-chain (Snapshot) proposal to the normalized VotingReminderProposal shape. + * Uses `start` (actual voting start) when available, falls back to `created`. + */ +export function mapOffchainToReminderProposal(p: OffchainProposal): VotingReminderProposal { + return { + id: p.id, + daoId: p.daoId, + title: p.title || undefined, + startTime: p.start ?? p.created, + endTime: p.end, + link: p.link, + discussion: p.discussion, + }; +} diff --git a/apps/logic-system/src/repositories/offchain-proposal.repository.ts b/apps/logic-system/src/repositories/offchain-proposal.repository.ts index af11cd4d..d06d62ee 100644 --- a/apps/logic-system/src/repositories/offchain-proposal.repository.ts +++ b/apps/logic-system/src/repositories/offchain-proposal.repository.ts @@ -1,7 +1,9 @@ import { AnticaptureClient } from '@notification-system/anticapture-client'; import { OffchainProposalDataSource, OffchainProposal, ListOffchainProposalsOptions } from '../interfaces/offchain-proposal.interface'; +import { VotingReminderProposal, VotingReminderDataSource } from '../interfaces/voting-reminder.interface'; +import { mapOffchainToReminderProposal } from '../mappers/proposal-reminder.mapper'; -export class OffchainProposalRepository implements OffchainProposalDataSource { +export class OffchainProposalRepository implements OffchainProposalDataSource, VotingReminderDataSource { private anticaptureClient: AnticaptureClient; constructor(anticaptureClient: AnticaptureClient) { @@ -33,4 +35,9 @@ export class OffchainProposalRepository implements OffchainProposalDataSource { return await this.anticaptureClient.listOffchainProposals(variables); } + + async listActiveForReminder(): Promise { + const proposals = await this.listAll({ status: 'active' }); + return proposals.map(mapOffchainToReminderProposal); + } } diff --git a/apps/logic-system/src/repositories/proposal.repository.ts b/apps/logic-system/src/repositories/proposal.repository.ts index b3435ea5..c4cdf21e 100644 --- a/apps/logic-system/src/repositories/proposal.repository.ts +++ b/apps/logic-system/src/repositories/proposal.repository.ts @@ -1,7 +1,9 @@ import { ProposalDataSource, ProposalOnChain, ProposalOrNull, ListProposalsOptions } from '../interfaces/proposal.interface'; -import { AnticaptureClient, ListProposalsQueryVariables, OrderDirection } from '@notification-system/anticapture-client'; +import { VotingReminderDataSource, VotingReminderProposal } from '../interfaces/voting-reminder.interface'; +import { AnticaptureClient, ListProposalsQueryVariables, OrderDirection, QueryInput_Proposals_Status_Items } from '@notification-system/anticapture-client'; +import { mapOnchainToReminderProposal } from '../mappers/proposal-reminder.mapper'; -export class ProposalRepository implements ProposalDataSource { +export class ProposalRepository implements ProposalDataSource, VotingReminderDataSource { private anticaptureClient: AnticaptureClient; constructor(anticaptureClient: AnticaptureClient) { @@ -9,19 +11,19 @@ export class ProposalRepository implements ProposalDataSource { } async getById(id: string): Promise { - const proposal = await this.anticaptureClient.getProposalById(id); - if (!proposal || proposal.__typename !== 'OnchainProposal') return null; - return proposal; + const result = await this.anticaptureClient.getProposalById(id); + if (!result || result.__typename !== 'OnchainProposal') return null; + return result as ProposalOnChain; } async listAll(options?: ListProposalsOptions, limit: number = 100): Promise { const variables: ListProposalsQueryVariables = {}; - + // Status filtering if (options?.status) { variables.status = options.status; } - + // Date filtering if (options?.fromDate) { variables.fromDate = options.fromDate; @@ -31,7 +33,7 @@ export class ProposalRepository implements ProposalDataSource { variables.fromEndDate = options.fromEndDate; } - // Optimistic proposal filtering + // Optimistic proposal filtering - now a plain boolean if (options?.includeOptimisticProposals !== undefined) { variables.includeOptimisticProposals = options.includeOptimisticProposals; } @@ -39,23 +41,33 @@ export class ProposalRepository implements ProposalDataSource { // Pagination if (options?.limit) { variables.limit = Math.min(options.limit, limit); - } - + } + if (options?.skip) { variables.skip = options.skip; } - + // Ordering if (options?.orderDirection) { - const directionMap: Record<'asc' | 'desc', OrderDirection> = { asc: OrderDirection.Asc, desc: OrderDirection.Desc }; - variables.orderDirection = directionMap[options.orderDirection]; + variables.orderDirection = options.orderDirection; } - + const daoId = options?.daoId; const result = await this.anticaptureClient.listProposals(variables, daoId); - + // Filter out null values and ensure we return ProposalOnChain[] return (result || []).filter(proposal => proposal !== null) as ProposalOnChain[]; } -} \ No newline at end of file + async listActiveForReminder(): Promise { + const proposals = await this.listAll({ + status: QueryInput_Proposals_Status_Items.Active, + includeOptimisticProposals: false, + }); + + return proposals + .filter(p => p.timestamp != null && p.endTimestamp != null) + .map(mapOnchainToReminderProposal); + } + +} diff --git a/apps/logic-system/src/repositories/voting-power.repository.ts b/apps/logic-system/src/repositories/voting-power.repository.ts index 163df423..bd7157a5 100644 --- a/apps/logic-system/src/repositories/voting-power.repository.ts +++ b/apps/logic-system/src/repositories/voting-power.repository.ts @@ -19,7 +19,7 @@ export class VotingPowerRepository { orderBy: QueryInput_HistoricalVotingPower_OrderBy.Timestamp, orderDirection: OrderDirection.Asc, limit: 100, - fromDate: parseInt(timestampGt) + fromDate: parseInt(timestampGt, 10) }; return await this.anticaptureClient.listVotingPowerHistory(variables); diff --git a/apps/logic-system/src/triggers/new-proposal-trigger.ts b/apps/logic-system/src/triggers/new-proposal-trigger.ts index 1f592a2e..1e78dd61 100644 --- a/apps/logic-system/src/triggers/new-proposal-trigger.ts +++ b/apps/logic-system/src/triggers/new-proposal-trigger.ts @@ -55,7 +55,7 @@ export class NewProposalTrigger extends Trigger { protected async fetchData(): Promise { return await this.proposalRepository.listAll({ - status: this.finishedStatuses, // API accepts array + status: this.finishedStatuses, fromEndDate: this.endTimestampCursor, - orderDirection: 'desc', // API orders by endTimestamp when using fromEndDate + orderDirection: OrderDirection.Desc, limit: 100 }); } @@ -67,7 +67,7 @@ export class ProposalFinishedTrigger extends Trigger { daoId: proposal?.daoId || '', ...(proposal?.title ? { title: proposal.title } : {}), description: proposal?.description || '', - endTimestamp: Number(proposal?.endTimestamp) || 0, + endTimestamp: proposal?.endTimestamp ?? 0, status: proposal?.status || 'unknown', forVotes: proposal?.forVotes || '0', againstVotes: proposal?.againstVotes || '0', diff --git a/apps/logic-system/src/triggers/voting-reminder-trigger.ts b/apps/logic-system/src/triggers/voting-reminder-trigger.ts index 897df47b..fd05ab46 100644 --- a/apps/logic-system/src/triggers/voting-reminder-trigger.ts +++ b/apps/logic-system/src/triggers/voting-reminder-trigger.ts @@ -5,8 +5,7 @@ */ import { Trigger } from './base-trigger'; -import { ProposalOnChain, ProposalDataSource } from '../interfaces/proposal.interface'; -import { QueryInput_Proposals_Status_Items } from '@notification-system/anticapture-client'; +import { VotingReminderProposal, VotingReminderDataSource } from '../interfaces/voting-reminder.interface'; import { DispatcherService, DispatcherMessage } from '../interfaces/dispatcher.interface'; /** @@ -16,29 +15,32 @@ export interface VotingReminderEvent { id: string; daoId: string; title?: string; - description: string; + description?: string; startTimestamp: number; endTimestamp: number; timeElapsedPercentage: number; thresholdPercentage: number; + link?: string; + discussion?: string; } -const TRIGGER_ID_PREFIX = 'voting-reminder'; +const DEFAULT_TRIGGER_ID_PREFIX = 'voting-reminder'; // 5% window the event will be triggered between thresholdPercentage and thresholdPercentage + window -const DEFAULT_WINDOW_SIZE = 5; +const DEFAULT_WINDOW_SIZE = 5; -export class VotingReminderTrigger extends Trigger { +export class VotingReminderTrigger extends Trigger { private thresholdPercentage: number; private windowSize: number; constructor( private readonly dispatcherService: DispatcherService, - private readonly proposalRepository: ProposalDataSource, + private readonly dataSource: VotingReminderDataSource, interval: number, thresholdPercentage: number = 75, - windowSize: number = DEFAULT_WINDOW_SIZE + windowSize: number = DEFAULT_WINDOW_SIZE, + triggerIdPrefix: string = DEFAULT_TRIGGER_ID_PREFIX ) { - super(`${TRIGGER_ID_PREFIX}-${thresholdPercentage}`, interval); + super(`${triggerIdPrefix}-${thresholdPercentage}`, interval); this.thresholdPercentage = thresholdPercentage; this.windowSize = windowSize; } @@ -46,18 +48,18 @@ export class VotingReminderTrigger extends Trigger { /** * Processes proposals and sends voting reminders for eligible ones */ - async process(proposals: ProposalOnChain[]): Promise { + async process(proposals: VotingReminderProposal[]): Promise { if (proposals.length === 0) { return; } const eligibleProposals = this.filterEligibleProposals(proposals); - + if (eligibleProposals.length === 0) { return; } - const reminderEvents = eligibleProposals.map(proposal => + const reminderEvents = eligibleProposals.map(proposal => this.createReminderEvent(proposal) ); @@ -72,9 +74,9 @@ export class VotingReminderTrigger extends Trigger { /** * Filters proposals that are eligible for reminders */ - private filterEligibleProposals(proposals: ProposalOnChain[]): ProposalOnChain[] { + private filterEligibleProposals(proposals: VotingReminderProposal[]): VotingReminderProposal[] { const now = Math.floor(Date.now() / 1000); - + return proposals.filter(proposal => { // Skip null proposals if (!proposal) { @@ -82,28 +84,25 @@ export class VotingReminderTrigger extends Trigger { } // Skip if proposal doesn't have required timestamps - if (!proposal.timestamp || !proposal.endTimestamp) { + if (!proposal.startTime || !proposal.endTime) { return false; } - const startTime = Number(proposal.timestamp); - const endTime = Number(proposal.endTimestamp); - // Skip if proposal is not active - if (now <= startTime || now >= endTime) { + if (now <= proposal.startTime || now >= proposal.endTime) { return false; } const timeElapsedPercentage = this.calculateTimeElapsedPercentage( - startTime, - endTime, + proposal.startTime, + proposal.endTime, now ); // Check if proposal is within the notification window (threshold to threshold + windowSize) const threshold = this.thresholdPercentage; const windowEnd = Math.min(threshold + this.windowSize, 100); - + return timeElapsedPercentage >= threshold && timeElapsedPercentage <= windowEnd; }); } @@ -113,49 +112,45 @@ export class VotingReminderTrigger extends Trigger { * Calculates the percentage of time elapsed for a proposal */ private calculateTimeElapsedPercentage( - startTime: number, - endTime: number, + startTime: number, + endTime: number, currentTime: number ): number { if (currentTime <= startTime) return 0; if (currentTime >= endTime) return 100; - + return ((currentTime - startTime) / (endTime - startTime)) * 100; } /** * Creates a reminder event from a proposal */ - private createReminderEvent(proposal: ProposalOnChain): VotingReminderEvent { - if (!proposal || !proposal.timestamp || !proposal.endTimestamp) { + private createReminderEvent(proposal: VotingReminderProposal): VotingReminderEvent { + if (!proposal || !proposal.startTime || !proposal.endTime) { throw new Error('Invalid proposal data for reminder event'); } - + const now = Math.floor(Date.now() / 1000); - const startTime = Number(proposal.timestamp); - const endTime = Number(proposal.endTimestamp); - const timeElapsedPercentage = this.calculateTimeElapsedPercentage(startTime, endTime, now); + const timeElapsedPercentage = this.calculateTimeElapsedPercentage(proposal.startTime, proposal.endTime, now); return { id: proposal.id, daoId: proposal.daoId, - title: proposal.title || undefined, + title: proposal.title, description: proposal.description, - startTimestamp: startTime, - endTimestamp: endTime, + startTimestamp: proposal.startTime, + endTimestamp: proposal.endTime, timeElapsedPercentage: Math.round(timeElapsedPercentage * 100) / 100, // Round to 2 decimal places - thresholdPercentage: this.thresholdPercentage + thresholdPercentage: this.thresholdPercentage, + link: proposal.link, + discussion: proposal.discussion, }; } /** - * Fetches active proposals from the repository - * Excludes optimistic proposals (includeOptimisticProposals: false) + * Fetches active proposals from the data source */ - protected async fetchData(): Promise { - return await this.proposalRepository.listAll({ - status: QueryInput_Proposals_Status_Items.Active, - includeOptimisticProposals: false - }); + protected async fetchData(): Promise { + return await this.dataSource.listActiveForReminder(); } -} \ No newline at end of file +} diff --git a/apps/logic-system/tests/new-proposal-trigger.test.ts b/apps/logic-system/tests/new-proposal-trigger.test.ts index 00d5c093..8f3773a1 100644 --- a/apps/logic-system/tests/new-proposal-trigger.test.ts +++ b/apps/logic-system/tests/new-proposal-trigger.test.ts @@ -7,6 +7,7 @@ import { NewProposalTrigger } from '../src/triggers/new-proposal-trigger'; import { ProposalOnChain } from '../src/interfaces/proposal.interface'; import { createProposal, createMockDispatcherService, createMockProposalDataSource } from './mocks'; import { NotificationTypeId } from '@notification-system/messages'; +import { QueryInput_Proposals_Status_Items } from '@notification-system/anticapture-client'; describe('NewProposalTrigger', () => { let mockDispatcherService: ReturnType; @@ -33,8 +34,8 @@ describe('NewProposalTrigger', () => { it('should send proposals and update timestampCursor', async () => { const proposals: ProposalOnChain[] = [ - createProposal({ status: 'ACTIVE', timestamp: '1000' }), - createProposal({ id: '2', status: 'ACTIVE', description: 'Second proposal\nWith details', timestamp: '900' }) + createProposal({ status: 'ACTIVE', timestamp: 1000 }), + createProposal({ id: '2', status: 'ACTIVE', description: 'Second proposal\nWith details', timestamp: 900 }) ]; await trigger.process(proposals); @@ -88,7 +89,7 @@ describe('NewProposalTrigger', () => { it('should start the interval and fetch proposals with status and timestamp filter', () => { const initialTimestamp = trigger['timestampCursor']; - trigger.start({ status: 'ACTIVE' }); + trigger.start({ status: QueryInput_Proposals_Status_Items.Active }); jest.advanceTimersByTime(60000); expect(mockProposalDataSource.listAll).toHaveBeenCalledTimes(1); @@ -101,8 +102,8 @@ describe('NewProposalTrigger', () => { it('should stop and restart the interval if start is called twice', () => { const stopSpy = jest.spyOn(trigger, 'stop'); const initialTimestamp = trigger['timestampCursor']; - trigger.start({ status: 'ACTIVE' }); - trigger.start({ status: 'ACTIVE' }); + trigger.start({ status: QueryInput_Proposals_Status_Items.Active }); + trigger.start({ status: QueryInput_Proposals_Status_Items.Active }); expect(stopSpy).toHaveBeenCalledTimes(1); jest.advanceTimersByTime(60000); @@ -114,7 +115,7 @@ describe('NewProposalTrigger', () => { it('should stop the interval when stop is called', () => { const initialTimestamp = trigger['timestampCursor']; - trigger.start({ status: 'ACTIVE' }); + trigger.start({ status: QueryInput_Proposals_Status_Items.Active }); jest.advanceTimersByTime(60000); expect(mockProposalDataSource.listAll).toHaveBeenCalledTimes(1); diff --git a/apps/logic-system/tests/proposal-finished-trigger.test.ts b/apps/logic-system/tests/proposal-finished-trigger.test.ts index 116f5664..d7ccf601 100644 --- a/apps/logic-system/tests/proposal-finished-trigger.test.ts +++ b/apps/logic-system/tests/proposal-finished-trigger.test.ts @@ -76,15 +76,15 @@ describe('ProposalFinishedTrigger', () => { id: 'prop1', daoId: 'dao1', description: 'Test proposal 1 description', - timestamp: '1625097600', - endTimestamp: '1625097600' + timestamp: 1625097600, + endTimestamp: 1625097600 }), createFinishedProposal('DEFEATED', { id: 'prop2', daoId: 'dao2', description: 'Test proposal 2 description', - timestamp: '1625184000', - endTimestamp: '1625184000', + timestamp: 1625184000, + endTimestamp: 1625184000, forVotes: '200000000000000000000', againstVotes: '800000000000000000000', abstainVotes: '50000000000000000000' @@ -125,7 +125,6 @@ describe('ProposalFinishedTrigger', () => { it('should handle proposals with missing optional fields', async () => { const proposal = createProposalWithMissingFields(); - // @ts-expect-error - we want to test the case where the proposal is missing some fields proposal.id = 'prop1'; await trigger.process([proposal]); @@ -175,15 +174,15 @@ describe('ProposalFinishedTrigger', () => { const proposalA = createFinishedProposal('EXECUTED', { id: 'proposal-a', daoId: 'dao1', - timestamp: '1000', - endTimestamp: '2000' + timestamp: 1000, + endTimestamp: 2000 }); const proposalB = createFinishedProposal('DEFEATED', { id: 'proposal-b', daoId: 'dao1', - timestamp: '1100', - endTimestamp: '2100' + timestamp: 1100, + endTimestamp: 2100 }); // First execution: process proposal A diff --git a/apps/logic-system/tests/voting-reminder-trigger.test.ts b/apps/logic-system/tests/voting-reminder-trigger.test.ts index d0db9c68..899bb2d9 100644 --- a/apps/logic-system/tests/voting-reminder-trigger.test.ts +++ b/apps/logic-system/tests/voting-reminder-trigger.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals'; import { VotingReminderTrigger } from '../src/triggers/voting-reminder-trigger'; -import { ProposalOnChain } from '../src/interfaces/proposal.interface'; +import { VotingReminderProposal } from '../src/interfaces/voting-reminder.interface'; import { DispatcherService } from '../src/interfaces/dispatcher.interface'; import { MockedFunction } from 'jest-mock'; import { NotificationTypeId } from '@notification-system/messages'; @@ -12,25 +12,25 @@ import { NotificationTypeId } from '@notification-system/messages'; describe('VotingReminderTrigger', () => { let trigger: VotingReminderTrigger; let mockDispatcherService: jest.Mocked; - let mockProposalRepository: any; + let mockDataSource: any; const baseTime = Math.floor(Date.now() / 1000); - + beforeEach(() => { mockDispatcherService = { sendMessage: jest.fn().mockResolvedValue(undefined as never) as MockedFunction, }; - mockProposalRepository = { - listAll: jest.fn().mockResolvedValue([] as never), + mockDataSource = { + listActiveForReminder: jest.fn().mockResolvedValue([] as never), }; // Mock Date.now for consistent testing jest.spyOn(Date, 'now').mockReturnValue(baseTime * 1000); - + trigger = new VotingReminderTrigger( mockDispatcherService, - mockProposalRepository, + mockDataSource, 30000, // 30 second interval for testing 90 // 90% threshold ); @@ -42,22 +42,34 @@ describe('VotingReminderTrigger', () => { describe('constructor', () => { it('should create trigger with unique IDs including threshold', () => { - const trigger30 = new VotingReminderTrigger(mockDispatcherService, mockProposalRepository, 30000, 30); - const trigger60 = new VotingReminderTrigger(mockDispatcherService, mockProposalRepository, 30000, 60); - const trigger90 = new VotingReminderTrigger(mockDispatcherService, mockProposalRepository, 30000, 90); - const trigger75 = new VotingReminderTrigger(mockDispatcherService, mockProposalRepository, 30000, 75); + const trigger30 = new VotingReminderTrigger(mockDispatcherService, mockDataSource, 30000, 30); + const trigger60 = new VotingReminderTrigger(mockDispatcherService, mockDataSource, 30000, 60); + const trigger90 = new VotingReminderTrigger(mockDispatcherService, mockDataSource, 30000, 90); + const trigger75 = new VotingReminderTrigger(mockDispatcherService, mockDataSource, 30000, 75); expect(trigger30.id).toBe(NotificationTypeId.VotingReminder30); expect(trigger60.id).toBe(NotificationTypeId.VotingReminder60); expect(trigger90.id).toBe(NotificationTypeId.VotingReminder90); expect(trigger75.id).toBe('voting-reminder-75'); }); + + it('should create trigger with custom prefix', () => { + const offchainTrigger = new VotingReminderTrigger( + mockDispatcherService, + mockDataSource, + 30000, + 75, + 5, + 'offchain-voting-reminder' + ); + expect(offchainTrigger.id).toBe('offchain-voting-reminder-75'); + }); }); describe('process', () => { it('should not send messages for empty proposals array', async () => { await trigger.process([]); - + expect(mockDispatcherService.sendMessage).not.toHaveBeenCalled(); }); @@ -65,16 +77,15 @@ describe('VotingReminderTrigger', () => { const proposalStart = baseTime - 3600; // Started 1 hour ago const proposalEnd = baseTime + 300; // Ends in 5 minutes // Total duration: 65 minutes, elapsed: 60 minutes = 92.3% elapsed (within 90-95% window) - - const proposal: ProposalOnChain = { + + const proposal: VotingReminderProposal = { id: 'proposal-123', daoId: 'test-dao', title: 'Test Proposal', description: 'A test proposal for voting reminder', - timestamp: proposalStart.toString(), - endTimestamp: proposalEnd.toString(), - status: 'ACTIVE' - } as ProposalOnChain; + startTime: proposalStart, + endTime: proposalEnd, + }; await trigger.process([proposal]); @@ -88,7 +99,9 @@ describe('VotingReminderTrigger', () => { startTimestamp: proposalStart, endTimestamp: proposalEnd, timeElapsedPercentage: 92.31, - thresholdPercentage: 90 + thresholdPercentage: 90, + link: undefined, + discussion: undefined, }] }); }); @@ -97,15 +110,14 @@ describe('VotingReminderTrigger', () => { const proposalStart = baseTime - 9600; // Started 160 minutes ago const proposalEnd = baseTime + 400; // Ends in ~6.7 minutes // Total duration: 166.7 minutes, elapsed: 160 minutes = 96% elapsed (> 95% window) - - const proposal: ProposalOnChain = { + + const proposal: VotingReminderProposal = { id: 'proposal-123', daoId: 'test-dao', description: 'A test proposal for voting reminder', - timestamp: proposalStart.toString(), - endTimestamp: proposalEnd.toString(), - status: 'ACTIVE' - } as ProposalOnChain; + startTime: proposalStart, + endTime: proposalEnd, + }; await trigger.process([proposal]); @@ -113,13 +125,14 @@ describe('VotingReminderTrigger', () => { }); it('should skip proposals without required timestamps', async () => { - const proposal: ProposalOnChain = { + const proposal: VotingReminderProposal = { id: 'proposal-123', daoId: 'test-dao', description: 'Test proposal without timestamps', - status: 'ACTIVE' - // Missing startTimestamp and endTimestamp - } as ProposalOnChain; + startTime: 0, + endTime: 0, + // Missing valid startTime and endTime (0 is falsy) + }; await trigger.process([proposal]); @@ -129,15 +142,14 @@ describe('VotingReminderTrigger', () => { it('should skip proposals that have already ended', async () => { const proposalStart = baseTime - 7200; // Started 2 hours ago const proposalEnd = baseTime - 1800; // Ended 30 minutes ago - - const proposal: ProposalOnChain = { + + const proposal: VotingReminderProposal = { id: 'proposal-123', daoId: 'test-dao', description: 'Test proposal that ended', - timestamp: proposalStart.toString(), - endTimestamp: proposalEnd.toString(), - status: 'ACTIVE' - } as ProposalOnChain; + startTime: proposalStart, + endTime: proposalEnd, + }; await trigger.process([proposal]); @@ -147,15 +159,14 @@ describe('VotingReminderTrigger', () => { it('should skip proposals that have not started yet', async () => { const proposalStart = baseTime + 3600; // Starts in 1 hour const proposalEnd = baseTime + 7200; // Ends in 2 hours - - const proposal: ProposalOnChain = { + + const proposal: VotingReminderProposal = { id: 'proposal-123', daoId: 'test-dao', description: 'Test proposal that has not started', - timestamp: proposalStart.toString(), - endTimestamp: proposalEnd.toString(), - status: 'ACTIVE' - } as ProposalOnChain; + startTime: proposalStart, + endTime: proposalEnd, + }; await trigger.process([proposal]); @@ -164,21 +175,14 @@ describe('VotingReminderTrigger', () => { }); describe('fetchData', () => { - it('should fetch active proposals without timestamp filter', async () => { - const proposals = [ - { id: 'prop-1', status: 'ACTIVE' }, - { id: 'prop-2', status: 'ACTIVE' } - ] as ProposalOnChain[]; - - mockProposalRepository.listAll.mockResolvedValue(proposals); - + it('should fetch active proposals from data source', async () => { + const proposals: VotingReminderProposal[] = [ + { id: 'prop-1', daoId: 'dao-1', startTime: 1000, endTime: 2000 }, + { id: 'prop-2', daoId: 'dao-1', startTime: 1000, endTime: 2000 } + ]; + mockDataSource.listActiveForReminder.mockResolvedValue(proposals); const result = await trigger['fetchData'](); - - // Should only filter by status ACTIVE, no fromDate - expect(mockProposalRepository.listAll).toHaveBeenCalledWith({ - status: 'ACTIVE', - includeOptimisticProposals: false - }); + expect(mockDataSource.listActiveForReminder).toHaveBeenCalled(); expect(result).toEqual(proposals); }); }); @@ -188,11 +192,11 @@ describe('VotingReminderTrigger', () => { const startTime = 1000; const endTime = 2000; const currentTime = 1500; - + // Use reflection to access private method for testing const calculateTime = (trigger as any).calculateTimeElapsedPercentage; const percentage = calculateTime(startTime, endTime, currentTime); - + expect(percentage).toBe(50); // 50% elapsed }); @@ -200,10 +204,10 @@ describe('VotingReminderTrigger', () => { const startTime = 2000; const endTime = 3000; const currentTime = 1000; - + const calculateTime = (trigger as any).calculateTimeElapsedPercentage; const percentage = calculateTime(startTime, endTime, currentTime); - + expect(percentage).toBe(0); }); @@ -211,11 +215,11 @@ describe('VotingReminderTrigger', () => { const startTime = 1000; const endTime = 2000; const currentTime = 3000; - + const calculateTime = (trigger as any).calculateTimeElapsedPercentage; const percentage = calculateTime(startTime, endTime, currentTime); - + expect(percentage).toBe(100); }); }); -}); \ No newline at end of file +}); diff --git a/packages/anticapture-client/dist/anticapture-client.d.ts b/packages/anticapture-client/dist/anticapture-client.d.ts index 5a5c22a4..17068316 100644 --- a/packages/anticapture-client/dist/anticapture-client.d.ts +++ b/packages/anticapture-client/dist/anticapture-client.d.ts @@ -1,11 +1,11 @@ import { AxiosInstance } from 'axios'; import { z } from 'zod'; -import type { GetProposalByIdQuery, ListProposalsQuery, ListProposalsQueryVariables, ListHistoricalVotingPowerQueryVariables, ListVotesQuery, ListVotesQueryVariables, ListOffchainProposalsQueryVariables, ListOffchainVotesQueryVariables } from './gql/graphql'; -import { SafeProposalNonVotersResponseSchema, ProcessedVotingPowerHistory, FeedEventType, FeedRelevance, OffchainProposalItem, OffchainVoteItem } from './schemas'; +import type { GetProposalByIdQuery, ListProposalsQuery, ListProposalsQueryVariables, ListHistoricalVotingPowerQueryVariables, ListVotesQueryVariables, ListOffchainProposalsQueryVariables, ListOffchainVotesQueryVariables } from './gql/graphql'; +import { SafeVotesResponseSchema, SafeProposalNonVotersResponseSchema, ProcessedVotingPowerHistory, FeedEventType, FeedRelevance, OffchainProposalItem, OffchainVoteItem } from './schemas'; type ProposalItems = NonNullable['items']; type VotingPowerHistoryItems = ProcessedVotingPowerHistory[]; type ProposalNonVoter = z.infer['proposalNonVoters']['items'][0]; -type VoteItem = NonNullable['items'][0]>; +type VoteItem = z.infer['votes']['items'][0]; export type VoteWithDaoId = VoteItem & { daoId: string; }; @@ -72,6 +72,16 @@ export declare class AnticaptureClient { * @returns List of non-voters with their voting power details */ getProposalNonVoters(proposalId: string, daoId: string, addresses?: string[]): Promise; + /** + * Fetches addresses that haven't voted on a specific offchain (Snapshot) proposal + * @param proposalId The Snapshot proposal ID to check + * @param addresses Optional array of addresses to filter by + * @returns List of non-voters + */ + getOffchainProposalNonVoters(proposalId: string, addresses?: string[]): Promise<{ + voter: string; + votingPower?: string; + }[]>; /** * List recent votes from all DAOs since a given timestamp * @param timestampGt Fetch votes with timestamp greater than this value (unix timestamp as string) diff --git a/packages/anticapture-client/dist/anticapture-client.js b/packages/anticapture-client/dist/anticapture-client.js index f174dcee..6ade71fa 100644 --- a/packages/anticapture-client/dist/anticapture-client.js +++ b/packages/anticapture-client/dist/anticapture-client.js @@ -228,7 +228,7 @@ class AnticaptureClient { async listVotes(daoId, variables) { try { const validated = await this.query(graphql_2.ListVotesDocument, schemas_1.SafeVotesResponseSchema, variables, daoId); - return validated.votes.items.filter(item => item !== null); + return validated.votes.items; } catch (error) { console.warn(`Error fetching votes for DAO ${daoId}:`, error); @@ -257,6 +257,27 @@ class AnticaptureClient { return []; } } + /** + * Fetches addresses that haven't voted on a specific offchain (Snapshot) proposal + * @param proposalId The Snapshot proposal ID to check + * @param addresses Optional array of addresses to filter by + * @returns List of non-voters + */ + async getOffchainProposalNonVoters(proposalId, addresses) { + try { + const variables = { + id: proposalId, + ...(addresses && { addresses }), + orderDirection: graphql_2.OrderDirection.Desc, + }; + const validated = await this.query(graphql_2.OffchainProposalNonVotersDocument, schemas_1.SafeOffchainProposalNonVotersResponseSchema, variables); + return validated.offchainProposalNonVoters.items; + } + catch (error) { + console.warn(`Error fetching offchain non-voters for proposal ${proposalId}:`, error); + return []; + } + } /** * List recent votes from all DAOs since a given timestamp * @param timestampGt Fetch votes with timestamp greater than this value (unix timestamp as string) diff --git a/packages/anticapture-client/dist/gql/gql.d.ts b/packages/anticapture-client/dist/gql/gql.d.ts index c50823f1..a7504e37 100644 --- a/packages/anticapture-client/dist/gql/gql.d.ts +++ b/packages/anticapture-client/dist/gql/gql.d.ts @@ -13,6 +13,7 @@ import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document- */ type Documents = { "query GetDAOs {\n daos {\n items {\n id\n votingDelay\n chainId\n alreadySupportCalldataReview\n supportOffchainData\n }\n }\n}": typeof types.GetDaOsDocument; + "query OffchainProposalNonVoters($id: String!, $addresses: [String], $orderDirection: OrderDirection) {\n offchainProposalNonVoters(\n id: $id\n addresses: $addresses\n orderDirection: $orderDirection\n ) {\n ... on OffchainVotersResponse {\n items {\n voter\n votingPower\n }\n }\n }\n}": typeof types.OffchainProposalNonVotersDocument; "query ListOffchainProposals($skip: Int, $limit: Int, $orderDirection: OrderDirection, $status: [queryInput_offchainProposals_status_items], $fromDate: Int, $endDate: Int) {\n offchainProposals(\n skip: $skip\n limit: $limit\n orderDirection: $orderDirection\n status: $status\n fromDate: $fromDate\n endDate: $endDate\n ) {\n items {\n id\n title\n discussion\n link\n state\n created\n end\n }\n totalCount\n }\n}": typeof types.ListOffchainProposalsDocument; "query ListOffchainVotes($fromDate: Int, $toDate: Int, $limit: Int, $skip: Int, $orderBy: queryInput_votesOffchain_orderBy, $orderDirection: OrderDirection, $voterAddresses: [String]) {\n votesOffchain(\n fromDate: $fromDate\n toDate: $toDate\n limit: $limit\n skip: $skip\n orderBy: $orderBy\n orderDirection: $orderDirection\n voterAddresses: $voterAddresses\n ) {\n items {\n voter\n created\n proposalId\n proposalTitle\n reason\n vp\n }\n totalCount\n }\n}": typeof types.ListOffchainVotesDocument; "query ProposalNonVoters($id: String!, $addresses: [String]) {\n proposalNonVoters(id: $id, addresses: $addresses) {\n items {\n voter\n }\n }\n}": typeof types.ProposalNonVotersDocument; @@ -39,6 +40,10 @@ export declare function graphql(source: string): unknown; * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export declare function graphql(source: "query GetDAOs {\n daos {\n items {\n id\n votingDelay\n chainId\n alreadySupportCalldataReview\n supportOffchainData\n }\n }\n}"): (typeof documents)["query GetDAOs {\n daos {\n items {\n id\n votingDelay\n chainId\n alreadySupportCalldataReview\n supportOffchainData\n }\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export declare function graphql(source: "query OffchainProposalNonVoters($id: String!, $addresses: [String], $orderDirection: OrderDirection) {\n offchainProposalNonVoters(\n id: $id\n addresses: $addresses\n orderDirection: $orderDirection\n ) {\n ... on OffchainVotersResponse {\n items {\n voter\n votingPower\n }\n }\n }\n}"): (typeof documents)["query OffchainProposalNonVoters($id: String!, $addresses: [String], $orderDirection: OrderDirection) {\n offchainProposalNonVoters(\n id: $id\n addresses: $addresses\n orderDirection: $orderDirection\n ) {\n ... on OffchainVotersResponse {\n items {\n voter\n votingPower\n }\n }\n }\n}"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/packages/anticapture-client/dist/gql/gql.js b/packages/anticapture-client/dist/gql/gql.js index 1e50e3e1..b0f4bfe2 100644 --- a/packages/anticapture-client/dist/gql/gql.js +++ b/packages/anticapture-client/dist/gql/gql.js @@ -38,6 +38,7 @@ exports.graphql = graphql; const types = __importStar(require("./graphql")); const documents = { "query GetDAOs {\n daos {\n items {\n id\n votingDelay\n chainId\n alreadySupportCalldataReview\n supportOffchainData\n }\n }\n}": types.GetDaOsDocument, + "query OffchainProposalNonVoters($id: String!, $addresses: [String], $orderDirection: OrderDirection) {\n offchainProposalNonVoters(\n id: $id\n addresses: $addresses\n orderDirection: $orderDirection\n ) {\n ... on OffchainVotersResponse {\n items {\n voter\n votingPower\n }\n }\n }\n}": types.OffchainProposalNonVotersDocument, "query ListOffchainProposals($skip: Int, $limit: Int, $orderDirection: OrderDirection, $status: [queryInput_offchainProposals_status_items], $fromDate: Int, $endDate: Int) {\n offchainProposals(\n skip: $skip\n limit: $limit\n orderDirection: $orderDirection\n status: $status\n fromDate: $fromDate\n endDate: $endDate\n ) {\n items {\n id\n title\n discussion\n link\n state\n created\n end\n }\n totalCount\n }\n}": types.ListOffchainProposalsDocument, "query ListOffchainVotes($fromDate: Int, $toDate: Int, $limit: Int, $skip: Int, $orderBy: queryInput_votesOffchain_orderBy, $orderDirection: OrderDirection, $voterAddresses: [String]) {\n votesOffchain(\n fromDate: $fromDate\n toDate: $toDate\n limit: $limit\n skip: $skip\n orderBy: $orderBy\n orderDirection: $orderDirection\n voterAddresses: $voterAddresses\n ) {\n items {\n voter\n created\n proposalId\n proposalTitle\n reason\n vp\n }\n totalCount\n }\n}": types.ListOffchainVotesDocument, "query ProposalNonVoters($id: String!, $addresses: [String]) {\n proposalNonVoters(id: $id, addresses: $addresses) {\n items {\n voter\n }\n }\n}": types.ProposalNonVotersDocument, diff --git a/packages/anticapture-client/dist/gql/graphql.d.ts b/packages/anticapture-client/dist/gql/graphql.d.ts index 0f16454d..4ad53604 100644 --- a/packages/anticapture-client/dist/gql/graphql.d.ts +++ b/packages/anticapture-client/dist/gql/graphql.d.ts @@ -370,6 +370,11 @@ export type LastUpdateResponse = { /** Latest refresh time in ISO-8601 format. */ lastUpdate: Scalars['DateTime']['output']; }; +export type OffchainNonVoter = { + __typename?: 'OffchainNonVoter'; + voter: Scalars['String']['output']; + votingPower: Scalars['String']['output']; +}; export type OffchainProposal = { __typename?: 'OffchainProposal'; /** Address or ENS of the author. */ @@ -413,7 +418,7 @@ export type OffchainProposalsResponse = { }; export type OffchainVote = { __typename?: 'OffchainVote'; - choice: Array>; + choice?: Maybe>>; created: Scalars['Int']['output']; proposalId: Scalars['String']['output']; proposalTitle?: Maybe; @@ -421,6 +426,11 @@ export type OffchainVote = { voter: Scalars['String']['output']; vp?: Maybe; }; +export type OffchainVotersResponse = { + __typename?: 'OffchainVotersResponse'; + items: Array>; + totalCount: Scalars['Int']['output']; +}; export type OffchainVotesResponse = { __typename?: 'OffchainVotesResponse'; items: Array>; @@ -675,6 +685,8 @@ export type Query = { lastUpdate?: Maybe; /** Returns a single offchain (Snapshot) proposal by its ID */ offchainProposalById?: Maybe; + /** Returns the active delegates that did not vote on a given offchain proposal */ + offchainProposalNonVoters?: Maybe; /** Returns a list of offchain (Snapshot) proposals */ offchainProposals?: Maybe; /** Returns a single proposal by its ID */ @@ -896,6 +908,13 @@ export type QueryLastUpdateArgs = { export type QueryOffchainProposalByIdArgs = { id: Scalars['String']['input']; }; +export type QueryOffchainProposalNonVotersArgs = { + addresses?: InputMaybe>>; + id: Scalars['String']['input']; + limit?: InputMaybe; + orderDirection?: InputMaybe; + skip?: InputMaybe; +}; export type QueryOffchainProposalsArgs = { endDate?: InputMaybe; fromDate?: InputMaybe; @@ -1298,6 +1317,7 @@ export type Health_503_Response = { }; export type Health_Response = Health_200_Response | Health_503_Response; export type OffchainProposalById_Response = ErrorResponse | OffchainProposal; +export type OffchainProposalNonVoters_Response = ErrorResponse | OffchainVotersResponse; export declare enum Ok_Const { Ok = "ok" } @@ -1523,6 +1543,24 @@ export type GetDaOsQuery = { }>; }; }; +export type OffchainProposalNonVotersQueryVariables = Exact<{ + id: Scalars['String']['input']; + addresses?: InputMaybe> | InputMaybe>; + orderDirection?: InputMaybe; +}>; +export type OffchainProposalNonVotersQuery = { + __typename?: 'Query'; + offchainProposalNonVoters?: { + __typename?: 'ErrorResponse'; + } | { + __typename?: 'OffchainVotersResponse'; + items: Array<{ + __typename?: 'OffchainNonVoter'; + voter: string; + votingPower: string; + } | null>; + } | null; +}; export type ListOffchainProposalsQueryVariables = Exact<{ skip?: InputMaybe; limit?: InputMaybe; @@ -1723,6 +1761,7 @@ export type ListHistoricalVotingPowerQuery = { } | null; }; export declare const GetDaOsDocument: DocumentNode; +export declare const OffchainProposalNonVotersDocument: DocumentNode; export declare const ListOffchainProposalsDocument: DocumentNode; export declare const ListOffchainVotesDocument: DocumentNode; export declare const ProposalNonVotersDocument: DocumentNode; diff --git a/packages/anticapture-client/dist/gql/graphql.js b/packages/anticapture-client/dist/gql/graphql.js index 5e2406a5..3100cc35 100644 --- a/packages/anticapture-client/dist/gql/graphql.js +++ b/packages/anticapture-client/dist/gql/graphql.js @@ -1,6 +1,6 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -exports.ListHistoricalVotingPowerDocument = exports.ListVotesDocument = exports.GetEventRelevanceThresholdDocument = exports.ListProposalsDocument = exports.GetProposalByIdDocument = exports.ProposalNonVotersDocument = exports.ListOffchainVotesDocument = exports.ListOffchainProposalsDocument = exports.GetDaOsDocument = exports.QueryInput_VotingPowers_OrderBy = exports.QueryInput_Votes_OrderBy = exports.QueryInput_VotesOffchain_OrderBy = exports.QueryInput_VotesOffchainByProposalId_OrderBy = exports.QueryInput_VotesByProposalId_OrderBy = exports.QueryInput_Transfers_OrderBy = exports.QueryInput_Transactions_Includes_Items = exports.QueryInput_Transactions_AffectedSupply_Items = exports.QueryInput_Token_Currency = exports.QueryInput_TokenMetrics_MetricType = exports.QueryInput_Proposals_Status_Items = exports.QueryInput_ProposalsActivity_UserVoteFilter = exports.QueryInput_ProposalsActivity_OrderBy = exports.QueryInput_OffchainProposals_Status_Items = exports.QueryInput_LastUpdate_Chart = exports.QueryInput_HistoricalVotingPower_OrderBy = exports.QueryInput_HistoricalVotingPowerByAccountId_OrderBy = exports.QueryInput_HistoricalBalances_OrderBy = exports.QueryInput_FeedEvents_Type = exports.QueryInput_FeedEvents_Relevance = exports.QueryInput_FeedEvents_OrderBy = exports.QueryInput_Delegators_OrderBy = exports.QueryInput_AccountInteractions_OrderBy = exports.QueryInput_AccountBalances_OrderBy = exports.Ok_Const = exports.Error_Const = exports.OrderDirection = exports.HttpMethod = exports.FeedRelevance = exports.FeedEventType = exports.DaysWindow = void 0; +exports.ListHistoricalVotingPowerDocument = exports.ListVotesDocument = exports.GetEventRelevanceThresholdDocument = exports.ListProposalsDocument = exports.GetProposalByIdDocument = exports.ProposalNonVotersDocument = exports.ListOffchainVotesDocument = exports.ListOffchainProposalsDocument = exports.OffchainProposalNonVotersDocument = exports.GetDaOsDocument = exports.QueryInput_VotingPowers_OrderBy = exports.QueryInput_Votes_OrderBy = exports.QueryInput_VotesOffchain_OrderBy = exports.QueryInput_VotesOffchainByProposalId_OrderBy = exports.QueryInput_VotesByProposalId_OrderBy = exports.QueryInput_Transfers_OrderBy = exports.QueryInput_Transactions_Includes_Items = exports.QueryInput_Transactions_AffectedSupply_Items = exports.QueryInput_Token_Currency = exports.QueryInput_TokenMetrics_MetricType = exports.QueryInput_Proposals_Status_Items = exports.QueryInput_ProposalsActivity_UserVoteFilter = exports.QueryInput_ProposalsActivity_OrderBy = exports.QueryInput_OffchainProposals_Status_Items = exports.QueryInput_LastUpdate_Chart = exports.QueryInput_HistoricalVotingPower_OrderBy = exports.QueryInput_HistoricalVotingPowerByAccountId_OrderBy = exports.QueryInput_HistoricalBalances_OrderBy = exports.QueryInput_FeedEvents_Type = exports.QueryInput_FeedEvents_Relevance = exports.QueryInput_FeedEvents_OrderBy = exports.QueryInput_Delegators_OrderBy = exports.QueryInput_AccountInteractions_OrderBy = exports.QueryInput_AccountBalances_OrderBy = exports.Ok_Const = exports.Error_Const = exports.OrderDirection = exports.HttpMethod = exports.FeedRelevance = exports.FeedEventType = exports.DaysWindow = void 0; var DaysWindow; (function (DaysWindow) { DaysWindow["7d"] = "_7d"; @@ -220,6 +220,7 @@ var QueryInput_VotingPowers_OrderBy; QueryInput_VotingPowers_OrderBy["VotingPower"] = "votingPower"; })(QueryInput_VotingPowers_OrderBy || (exports.QueryInput_VotingPowers_OrderBy = QueryInput_VotingPowers_OrderBy = {})); exports.GetDaOsDocument = { "kind": "Document", "definitions": [{ "kind": "OperationDefinition", "operation": "query", "name": { "kind": "Name", "value": "GetDAOs" }, "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "daos" }, "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "items" }, "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "id" } }, { "kind": "Field", "name": { "kind": "Name", "value": "votingDelay" } }, { "kind": "Field", "name": { "kind": "Name", "value": "chainId" } }, { "kind": "Field", "name": { "kind": "Name", "value": "alreadySupportCalldataReview" } }, { "kind": "Field", "name": { "kind": "Name", "value": "supportOffchainData" } }] } }] } }] } }] }; +exports.OffchainProposalNonVotersDocument = { "kind": "Document", "definitions": [{ "kind": "OperationDefinition", "operation": "query", "name": { "kind": "Name", "value": "OffchainProposalNonVoters" }, "variableDefinitions": [{ "kind": "VariableDefinition", "variable": { "kind": "Variable", "name": { "kind": "Name", "value": "id" } }, "type": { "kind": "NonNullType", "type": { "kind": "NamedType", "name": { "kind": "Name", "value": "String" } } } }, { "kind": "VariableDefinition", "variable": { "kind": "Variable", "name": { "kind": "Name", "value": "addresses" } }, "type": { "kind": "ListType", "type": { "kind": "NamedType", "name": { "kind": "Name", "value": "String" } } } }, { "kind": "VariableDefinition", "variable": { "kind": "Variable", "name": { "kind": "Name", "value": "orderDirection" } }, "type": { "kind": "NamedType", "name": { "kind": "Name", "value": "OrderDirection" } } }], "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "offchainProposalNonVoters" }, "arguments": [{ "kind": "Argument", "name": { "kind": "Name", "value": "id" }, "value": { "kind": "Variable", "name": { "kind": "Name", "value": "id" } } }, { "kind": "Argument", "name": { "kind": "Name", "value": "addresses" }, "value": { "kind": "Variable", "name": { "kind": "Name", "value": "addresses" } } }, { "kind": "Argument", "name": { "kind": "Name", "value": "orderDirection" }, "value": { "kind": "Variable", "name": { "kind": "Name", "value": "orderDirection" } } }], "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "InlineFragment", "typeCondition": { "kind": "NamedType", "name": { "kind": "Name", "value": "OffchainVotersResponse" } }, "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "items" }, "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "voter" } }, { "kind": "Field", "name": { "kind": "Name", "value": "votingPower" } }] } }] } }] } }] } }] }; exports.ListOffchainProposalsDocument = { "kind": "Document", "definitions": [{ "kind": "OperationDefinition", "operation": "query", "name": { "kind": "Name", "value": "ListOffchainProposals" }, "variableDefinitions": [{ "kind": "VariableDefinition", "variable": { "kind": "Variable", "name": { "kind": "Name", "value": "skip" } }, "type": { "kind": "NamedType", "name": { "kind": "Name", "value": "Int" } } }, { "kind": "VariableDefinition", "variable": { "kind": "Variable", "name": { "kind": "Name", "value": "limit" } }, "type": { "kind": "NamedType", "name": { "kind": "Name", "value": "Int" } } }, { "kind": "VariableDefinition", "variable": { "kind": "Variable", "name": { "kind": "Name", "value": "orderDirection" } }, "type": { "kind": "NamedType", "name": { "kind": "Name", "value": "OrderDirection" } } }, { "kind": "VariableDefinition", "variable": { "kind": "Variable", "name": { "kind": "Name", "value": "status" } }, "type": { "kind": "ListType", "type": { "kind": "NamedType", "name": { "kind": "Name", "value": "queryInput_offchainProposals_status_items" } } } }, { "kind": "VariableDefinition", "variable": { "kind": "Variable", "name": { "kind": "Name", "value": "fromDate" } }, "type": { "kind": "NamedType", "name": { "kind": "Name", "value": "Int" } } }, { "kind": "VariableDefinition", "variable": { "kind": "Variable", "name": { "kind": "Name", "value": "endDate" } }, "type": { "kind": "NamedType", "name": { "kind": "Name", "value": "Int" } } }], "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "offchainProposals" }, "arguments": [{ "kind": "Argument", "name": { "kind": "Name", "value": "skip" }, "value": { "kind": "Variable", "name": { "kind": "Name", "value": "skip" } } }, { "kind": "Argument", "name": { "kind": "Name", "value": "limit" }, "value": { "kind": "Variable", "name": { "kind": "Name", "value": "limit" } } }, { "kind": "Argument", "name": { "kind": "Name", "value": "orderDirection" }, "value": { "kind": "Variable", "name": { "kind": "Name", "value": "orderDirection" } } }, { "kind": "Argument", "name": { "kind": "Name", "value": "status" }, "value": { "kind": "Variable", "name": { "kind": "Name", "value": "status" } } }, { "kind": "Argument", "name": { "kind": "Name", "value": "fromDate" }, "value": { "kind": "Variable", "name": { "kind": "Name", "value": "fromDate" } } }, { "kind": "Argument", "name": { "kind": "Name", "value": "endDate" }, "value": { "kind": "Variable", "name": { "kind": "Name", "value": "endDate" } } }], "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "items" }, "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "id" } }, { "kind": "Field", "name": { "kind": "Name", "value": "title" } }, { "kind": "Field", "name": { "kind": "Name", "value": "discussion" } }, { "kind": "Field", "name": { "kind": "Name", "value": "link" } }, { "kind": "Field", "name": { "kind": "Name", "value": "state" } }, { "kind": "Field", "name": { "kind": "Name", "value": "created" } }, { "kind": "Field", "name": { "kind": "Name", "value": "end" } }] } }, { "kind": "Field", "name": { "kind": "Name", "value": "totalCount" } }] } }] } }] }; exports.ListOffchainVotesDocument = { "kind": "Document", "definitions": [{ "kind": "OperationDefinition", "operation": "query", "name": { "kind": "Name", "value": "ListOffchainVotes" }, "variableDefinitions": [{ "kind": "VariableDefinition", "variable": { "kind": "Variable", "name": { "kind": "Name", "value": "fromDate" } }, "type": { "kind": "NamedType", "name": { "kind": "Name", "value": "Int" } } }, { "kind": "VariableDefinition", "variable": { "kind": "Variable", "name": { "kind": "Name", "value": "toDate" } }, "type": { "kind": "NamedType", "name": { "kind": "Name", "value": "Int" } } }, { "kind": "VariableDefinition", "variable": { "kind": "Variable", "name": { "kind": "Name", "value": "limit" } }, "type": { "kind": "NamedType", "name": { "kind": "Name", "value": "Int" } } }, { "kind": "VariableDefinition", "variable": { "kind": "Variable", "name": { "kind": "Name", "value": "skip" } }, "type": { "kind": "NamedType", "name": { "kind": "Name", "value": "Int" } } }, { "kind": "VariableDefinition", "variable": { "kind": "Variable", "name": { "kind": "Name", "value": "orderBy" } }, "type": { "kind": "NamedType", "name": { "kind": "Name", "value": "queryInput_votesOffchain_orderBy" } } }, { "kind": "VariableDefinition", "variable": { "kind": "Variable", "name": { "kind": "Name", "value": "orderDirection" } }, "type": { "kind": "NamedType", "name": { "kind": "Name", "value": "OrderDirection" } } }, { "kind": "VariableDefinition", "variable": { "kind": "Variable", "name": { "kind": "Name", "value": "voterAddresses" } }, "type": { "kind": "ListType", "type": { "kind": "NamedType", "name": { "kind": "Name", "value": "String" } } } }], "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "votesOffchain" }, "arguments": [{ "kind": "Argument", "name": { "kind": "Name", "value": "fromDate" }, "value": { "kind": "Variable", "name": { "kind": "Name", "value": "fromDate" } } }, { "kind": "Argument", "name": { "kind": "Name", "value": "toDate" }, "value": { "kind": "Variable", "name": { "kind": "Name", "value": "toDate" } } }, { "kind": "Argument", "name": { "kind": "Name", "value": "limit" }, "value": { "kind": "Variable", "name": { "kind": "Name", "value": "limit" } } }, { "kind": "Argument", "name": { "kind": "Name", "value": "skip" }, "value": { "kind": "Variable", "name": { "kind": "Name", "value": "skip" } } }, { "kind": "Argument", "name": { "kind": "Name", "value": "orderBy" }, "value": { "kind": "Variable", "name": { "kind": "Name", "value": "orderBy" } } }, { "kind": "Argument", "name": { "kind": "Name", "value": "orderDirection" }, "value": { "kind": "Variable", "name": { "kind": "Name", "value": "orderDirection" } } }, { "kind": "Argument", "name": { "kind": "Name", "value": "voterAddresses" }, "value": { "kind": "Variable", "name": { "kind": "Name", "value": "voterAddresses" } } }], "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "items" }, "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "voter" } }, { "kind": "Field", "name": { "kind": "Name", "value": "created" } }, { "kind": "Field", "name": { "kind": "Name", "value": "proposalId" } }, { "kind": "Field", "name": { "kind": "Name", "value": "proposalTitle" } }, { "kind": "Field", "name": { "kind": "Name", "value": "reason" } }, { "kind": "Field", "name": { "kind": "Name", "value": "vp" } }] } }, { "kind": "Field", "name": { "kind": "Name", "value": "totalCount" } }] } }] } }] }; exports.ProposalNonVotersDocument = { "kind": "Document", "definitions": [{ "kind": "OperationDefinition", "operation": "query", "name": { "kind": "Name", "value": "ProposalNonVoters" }, "variableDefinitions": [{ "kind": "VariableDefinition", "variable": { "kind": "Variable", "name": { "kind": "Name", "value": "id" } }, "type": { "kind": "NonNullType", "type": { "kind": "NamedType", "name": { "kind": "Name", "value": "String" } } } }, { "kind": "VariableDefinition", "variable": { "kind": "Variable", "name": { "kind": "Name", "value": "addresses" } }, "type": { "kind": "ListType", "type": { "kind": "NamedType", "name": { "kind": "Name", "value": "String" } } } }], "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "proposalNonVoters" }, "arguments": [{ "kind": "Argument", "name": { "kind": "Name", "value": "id" }, "value": { "kind": "Variable", "name": { "kind": "Name", "value": "id" } } }, { "kind": "Argument", "name": { "kind": "Name", "value": "addresses" }, "value": { "kind": "Variable", "name": { "kind": "Name", "value": "addresses" } } }], "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "items" }, "selectionSet": { "kind": "SelectionSet", "selections": [{ "kind": "Field", "name": { "kind": "Name", "value": "voter" } }] } }] } }] } }] }; diff --git a/packages/anticapture-client/dist/index.d.ts b/packages/anticapture-client/dist/index.d.ts index 3a75d58c..d230889f 100644 --- a/packages/anticapture-client/dist/index.d.ts +++ b/packages/anticapture-client/dist/index.d.ts @@ -1,6 +1,6 @@ export { AnticaptureClient } from './anticapture-client'; export type { VoteWithDaoId, OffchainVoteWithDaoId } from './anticapture-client'; export type { GetDaOsQuery, GetProposalByIdQuery, GetProposalByIdQueryVariables, ListProposalsQuery, ListProposalsQueryVariables, ListVotesQuery, ListVotesQueryVariables, ListHistoricalVotingPowerQuery, ListHistoricalVotingPowerQueryVariables } from './gql/graphql'; -export { OrderDirection, QueryInput_HistoricalVotingPower_OrderBy, QueryInput_Proposals_Status_Items, QueryInput_Votes_OrderBy, QueryInput_VotesOffchain_OrderBy, } from './gql/graphql'; +export { OrderDirection, QueryInput_HistoricalVotingPower_OrderBy, QueryInput_Votes_OrderBy, QueryInput_VotesOffchain_OrderBy, QueryInput_Proposals_Status_Items, } from './gql/graphql'; export { FeedEventType, FeedRelevance } from './schemas'; export type { ProcessedVotingPowerHistory, OffchainProposalItem, OffchainVoteItem } from './schemas'; diff --git a/packages/anticapture-client/dist/index.js b/packages/anticapture-client/dist/index.js index 6e1a9d84..8f3ba7fa 100644 --- a/packages/anticapture-client/dist/index.js +++ b/packages/anticapture-client/dist/index.js @@ -1,15 +1,15 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -exports.FeedRelevance = exports.FeedEventType = exports.QueryInput_VotesOffchain_OrderBy = exports.QueryInput_Votes_OrderBy = exports.QueryInput_Proposals_Status_Items = exports.QueryInput_HistoricalVotingPower_OrderBy = exports.OrderDirection = exports.AnticaptureClient = void 0; +exports.FeedRelevance = exports.FeedEventType = exports.QueryInput_Proposals_Status_Items = exports.QueryInput_VotesOffchain_OrderBy = exports.QueryInput_Votes_OrderBy = exports.QueryInput_HistoricalVotingPower_OrderBy = exports.OrderDirection = exports.AnticaptureClient = void 0; var anticapture_client_1 = require("./anticapture-client"); Object.defineProperty(exports, "AnticaptureClient", { enumerable: true, get: function () { return anticapture_client_1.AnticaptureClient; } }); // Export GraphQL enums var graphql_1 = require("./gql/graphql"); Object.defineProperty(exports, "OrderDirection", { enumerable: true, get: function () { return graphql_1.OrderDirection; } }); Object.defineProperty(exports, "QueryInput_HistoricalVotingPower_OrderBy", { enumerable: true, get: function () { return graphql_1.QueryInput_HistoricalVotingPower_OrderBy; } }); -Object.defineProperty(exports, "QueryInput_Proposals_Status_Items", { enumerable: true, get: function () { return graphql_1.QueryInput_Proposals_Status_Items; } }); Object.defineProperty(exports, "QueryInput_Votes_OrderBy", { enumerable: true, get: function () { return graphql_1.QueryInput_Votes_OrderBy; } }); Object.defineProperty(exports, "QueryInput_VotesOffchain_OrderBy", { enumerable: true, get: function () { return graphql_1.QueryInput_VotesOffchain_OrderBy; } }); +Object.defineProperty(exports, "QueryInput_Proposals_Status_Items", { enumerable: true, get: function () { return graphql_1.QueryInput_Proposals_Status_Items; } }); var schemas_1 = require("./schemas"); Object.defineProperty(exports, "FeedEventType", { enumerable: true, get: function () { return schemas_1.FeedEventType; } }); Object.defineProperty(exports, "FeedRelevance", { enumerable: true, get: function () { return schemas_1.FeedRelevance; } }); diff --git a/packages/anticapture-client/dist/schemas.d.ts b/packages/anticapture-client/dist/schemas.d.ts index bccf7d85..3d183270 100644 --- a/packages/anticapture-client/dist/schemas.d.ts +++ b/packages/anticapture-client/dist/schemas.d.ts @@ -578,6 +578,65 @@ export declare const SafeProposalNonVotersResponseSchema: z.ZodEffects; +export declare const SafeOffchainProposalNonVotersResponseSchema: z.ZodEffects; + }, "strip", z.ZodTypeAny, { + voter: string; + votingPower?: string | undefined; + }, { + voter: string; + votingPower?: string | undefined; + }>>, "many">; + totalCount: z.ZodOptional; + }, "strip", z.ZodTypeAny, { + items: ({ + voter: string; + votingPower?: string | undefined; + } | null)[]; + totalCount?: number | undefined; + }, { + items: ({ + voter: string; + votingPower?: string | undefined; + } | null)[]; + totalCount?: number | undefined; + }>>; +}, "strip", z.ZodTypeAny, { + offchainProposalNonVoters: { + items: ({ + voter: string; + votingPower?: string | undefined; + } | null)[]; + totalCount?: number | undefined; + } | null; +}, { + offchainProposalNonVoters: { + items: ({ + voter: string; + votingPower?: string | undefined; + } | null)[]; + totalCount?: number | undefined; + } | null; +}>, { + offchainProposalNonVoters: { + items: { + voter: string; + votingPower?: string; + }[]; + totalCount?: number | undefined; + }; +}, { + offchainProposalNonVoters: { + items: ({ + voter: string; + votingPower?: string | undefined; + } | null)[]; + totalCount?: number | undefined; + } | null; +}>; export declare const EventThresholdResponseSchema: z.ZodObject<{ getEventRelevanceThreshold: z.ZodObject<{ threshold: z.ZodString; @@ -603,6 +662,7 @@ export declare const OffchainProposalItemSchema: z.ZodObject<{ state: z.ZodString; created: z.ZodNumber; end: z.ZodNumber; + start: z.ZodOptional; }, "strip", z.ZodTypeAny, { link: string; id: string; @@ -611,6 +671,7 @@ export declare const OffchainProposalItemSchema: z.ZodObject<{ state: string; created: number; end: number; + start?: number | undefined; }, { link: string; id: string; @@ -619,6 +680,7 @@ export declare const OffchainProposalItemSchema: z.ZodObject<{ state: string; created: number; end: number; + start?: number | undefined; }>; export type OffchainProposalItem = z.infer; export declare const SafeOffchainProposalsResponseSchema: z.ZodEffects; }, "strip", z.ZodTypeAny, { link: string; id: string; @@ -639,6 +702,7 @@ export declare const SafeOffchainProposalsResponseSchema: z.ZodEffects>, "many">; totalCount: z.ZodNumber; }, "strip", z.ZodTypeAny, { @@ -658,6 +723,7 @@ export declare const SafeOffchainProposalsResponseSchema: z.ZodEffects>; @@ -682,6 +749,7 @@ export declare const SafeOffchainProposalsResponseSchema: z.ZodEffects>; vp: z.ZodOptional>; }, "strip", z.ZodTypeAny, { - created: number; voter: string; + created: number; proposalId: string; proposalTitle: string; reason?: string | null | undefined; vp?: number | null | undefined; }, { - created: number; voter: string; + created: number; proposalId: string; proposalTitle: string; reason?: string | null | undefined; @@ -758,15 +829,15 @@ export declare const SafeOffchainVotesResponseSchema: z.ZodEffects>; vp: z.ZodOptional>; }, "strip", z.ZodTypeAny, { - created: number; voter: string; + created: number; proposalId: string; proposalTitle: string; reason?: string | null | undefined; vp?: number | null | undefined; }, { - created: number; voter: string; + created: number; proposalId: string; proposalTitle: string; reason?: string | null | undefined; @@ -775,8 +846,8 @@ export declare const SafeOffchainVotesResponseSchema: z.ZodEffects, { votesOffchain: { items: { - created: number; voter: string; + created: number; proposalId: string; proposalTitle: string; reason?: string | null | undefined; @@ -833,8 +904,8 @@ export declare const SafeOffchainVotesResponseSchema: z.ZodEffects { + if (!data.offchainProposalNonVoters) { + console.warn('OffchainProposalNonVotersResponse has null offchainProposalNonVoters:', data); + return { offchainProposalNonVoters: { items: [], totalCount: 0 } }; + } + return { + offchainProposalNonVoters: { + ...data.offchainProposalNonVoters, + items: data.offchainProposalNonVoters.items.filter((item) => item !== null) + } + }; +}); exports.EventThresholdResponseSchema = zod_1.z.object({ getEventRelevanceThreshold: zod_1.z.object({ threshold: zod_1.z.string() @@ -134,6 +154,7 @@ exports.OffchainProposalItemSchema = zod_1.z.object({ state: zod_1.z.string(), created: zod_1.z.number(), end: zod_1.z.number(), + start: zod_1.z.number().optional(), }); exports.SafeOffchainProposalsResponseSchema = zod_1.z.object({ offchainProposals: zod_1.z.object({ diff --git a/packages/anticapture-client/queries/offchain-proposal-non-voters.graphql b/packages/anticapture-client/queries/offchain-proposal-non-voters.graphql new file mode 100644 index 00000000..bef1565e --- /dev/null +++ b/packages/anticapture-client/queries/offchain-proposal-non-voters.graphql @@ -0,0 +1,10 @@ +query OffchainProposalNonVoters($id: String!, $addresses: [String], $orderDirection: OrderDirection) { + offchainProposalNonVoters(id: $id, addresses: $addresses, orderDirection: $orderDirection) { + ... on OffchainVotersResponse { + items { + voter + votingPower + } + } + } +} diff --git a/packages/anticapture-client/src/anticapture-client.ts b/packages/anticapture-client/src/anticapture-client.ts index 5335f33d..ebe197fd 100644 --- a/packages/anticapture-client/src/anticapture-client.ts +++ b/packages/anticapture-client/src/anticapture-client.ts @@ -10,13 +10,13 @@ import type { ListProposalsQuery, ListProposalsQueryVariables, ListHistoricalVotingPowerQueryVariables, - ListVotesQuery, ListVotesQueryVariables, ProposalNonVotersQueryVariables, ListOffchainProposalsQueryVariables, ListOffchainVotesQueryVariables, } from './gql/graphql'; -import { GetDaOsDocument, GetProposalByIdDocument, ListProposalsDocument, ListHistoricalVotingPowerDocument, ListVotesDocument, ProposalNonVotersDocument, GetEventRelevanceThresholdDocument, QueryInput_Votes_OrderBy, OrderDirection, QueryInput_VotesOffchain_OrderBy, ListOffchainProposalsDocument, ListOffchainVotesDocument } from './gql/graphql'; +import { GetDaOsDocument, GetProposalByIdDocument, ListProposalsDocument, ListHistoricalVotingPowerDocument, ListVotesDocument, ProposalNonVotersDocument, GetEventRelevanceThresholdDocument, QueryInput_Votes_OrderBy, OrderDirection, QueryInput_VotesOffchain_OrderBy, ListOffchainProposalsDocument, ListOffchainVotesDocument, OffchainProposalNonVotersDocument } from './gql/graphql'; +import type { OffchainProposalNonVotersQueryVariables } from './gql/graphql'; import { SafeDaosResponseSchema, SafeProposalByIdResponseSchema, @@ -26,6 +26,7 @@ import { SafeProposalNonVotersResponseSchema, SafeOffchainProposalsResponseSchema, SafeOffchainVotesResponseSchema, + SafeOffchainProposalNonVotersResponseSchema, processProposals, processVotingPowerHistory, ProcessedVotingPowerHistory, @@ -37,7 +38,7 @@ import { type ProposalItems = NonNullable['items']; type VotingPowerHistoryItems = ProcessedVotingPowerHistory[]; type ProposalNonVoter = z.infer['proposalNonVoters']['items'][0]; -type VoteItem = NonNullable['items'][0]>; +type VoteItem = z.infer['votes']['items'][0]; export type VoteWithDaoId = VoteItem & { daoId: string }; export type OffchainVoteWithDaoId = OffchainVoteItem & { daoId: string }; @@ -270,7 +271,7 @@ export class AnticaptureClient { variables, daoId ); - return validated.votes.items.filter(item => item !== null) as VoteItem[]; + return validated.votes.items; } catch (error) { console.warn(`Error fetching votes for DAO ${daoId}:`, error); return []; @@ -310,6 +311,36 @@ export class AnticaptureClient { } } + /** + * Fetches addresses that haven't voted on a specific offchain (Snapshot) proposal + * @param proposalId The Snapshot proposal ID to check + * @param addresses Optional array of addresses to filter by + * @returns List of non-voters + */ + async getOffchainProposalNonVoters( + proposalId: string, + addresses?: string[], + ): Promise<{ voter: string; votingPower?: string }[]> { + try { + const variables: OffchainProposalNonVotersQueryVariables = { + id: proposalId, + ...(addresses && { addresses }), + orderDirection: OrderDirection.Desc, + }; + + const validated = await this.query( + OffchainProposalNonVotersDocument, + SafeOffchainProposalNonVotersResponseSchema, + variables, + ); + + return validated.offchainProposalNonVoters.items; + } catch (error) { + console.warn(`Error fetching offchain non-voters for proposal ${proposalId}:`, error); + return []; + } + } + /** * List recent votes from all DAOs since a given timestamp * @param timestampGt Fetch votes with timestamp greater than this value (unix timestamp as string) diff --git a/packages/anticapture-client/src/gql/gql.ts b/packages/anticapture-client/src/gql/gql.ts index 5043eb3a..cce93fea 100644 --- a/packages/anticapture-client/src/gql/gql.ts +++ b/packages/anticapture-client/src/gql/gql.ts @@ -15,6 +15,7 @@ import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document- */ type Documents = { "query GetDAOs {\n daos {\n items {\n id\n votingDelay\n chainId\n alreadySupportCalldataReview\n supportOffchainData\n }\n }\n}": typeof types.GetDaOsDocument, + "query OffchainProposalNonVoters($id: String!, $addresses: [String], $orderDirection: OrderDirection) {\n offchainProposalNonVoters(\n id: $id\n addresses: $addresses\n orderDirection: $orderDirection\n ) {\n ... on OffchainVotersResponse {\n items {\n voter\n votingPower\n }\n }\n }\n}": typeof types.OffchainProposalNonVotersDocument, "query ListOffchainProposals($skip: Int, $limit: Int, $orderDirection: OrderDirection, $status: [queryInput_offchainProposals_status_items], $fromDate: Int, $endDate: Int) {\n offchainProposals(\n skip: $skip\n limit: $limit\n orderDirection: $orderDirection\n status: $status\n fromDate: $fromDate\n endDate: $endDate\n ) {\n items {\n id\n title\n discussion\n link\n state\n created\n end\n }\n totalCount\n }\n}": typeof types.ListOffchainProposalsDocument, "query ListOffchainVotes($fromDate: Int, $toDate: Int, $limit: Int, $skip: Int, $orderBy: queryInput_votesOffchain_orderBy, $orderDirection: OrderDirection, $voterAddresses: [String]) {\n votesOffchain(\n fromDate: $fromDate\n toDate: $toDate\n limit: $limit\n skip: $skip\n orderBy: $orderBy\n orderDirection: $orderDirection\n voterAddresses: $voterAddresses\n ) {\n items {\n voter\n created\n proposalId\n proposalTitle\n reason\n vp\n }\n totalCount\n }\n}": typeof types.ListOffchainVotesDocument, "query ProposalNonVoters($id: String!, $addresses: [String]) {\n proposalNonVoters(id: $id, addresses: $addresses) {\n items {\n voter\n }\n }\n}": typeof types.ProposalNonVotersDocument, @@ -25,6 +26,7 @@ type Documents = { }; const documents: Documents = { "query GetDAOs {\n daos {\n items {\n id\n votingDelay\n chainId\n alreadySupportCalldataReview\n supportOffchainData\n }\n }\n}": types.GetDaOsDocument, + "query OffchainProposalNonVoters($id: String!, $addresses: [String], $orderDirection: OrderDirection) {\n offchainProposalNonVoters(\n id: $id\n addresses: $addresses\n orderDirection: $orderDirection\n ) {\n ... on OffchainVotersResponse {\n items {\n voter\n votingPower\n }\n }\n }\n}": types.OffchainProposalNonVotersDocument, "query ListOffchainProposals($skip: Int, $limit: Int, $orderDirection: OrderDirection, $status: [queryInput_offchainProposals_status_items], $fromDate: Int, $endDate: Int) {\n offchainProposals(\n skip: $skip\n limit: $limit\n orderDirection: $orderDirection\n status: $status\n fromDate: $fromDate\n endDate: $endDate\n ) {\n items {\n id\n title\n discussion\n link\n state\n created\n end\n }\n totalCount\n }\n}": types.ListOffchainProposalsDocument, "query ListOffchainVotes($fromDate: Int, $toDate: Int, $limit: Int, $skip: Int, $orderBy: queryInput_votesOffchain_orderBy, $orderDirection: OrderDirection, $voterAddresses: [String]) {\n votesOffchain(\n fromDate: $fromDate\n toDate: $toDate\n limit: $limit\n skip: $skip\n orderBy: $orderBy\n orderDirection: $orderDirection\n voterAddresses: $voterAddresses\n ) {\n items {\n voter\n created\n proposalId\n proposalTitle\n reason\n vp\n }\n totalCount\n }\n}": types.ListOffchainVotesDocument, "query ProposalNonVoters($id: String!, $addresses: [String]) {\n proposalNonVoters(id: $id, addresses: $addresses) {\n items {\n voter\n }\n }\n}": types.ProposalNonVotersDocument, @@ -52,6 +54,10 @@ export function graphql(source: string): unknown; * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "query GetDAOs {\n daos {\n items {\n id\n votingDelay\n chainId\n alreadySupportCalldataReview\n supportOffchainData\n }\n }\n}"): (typeof documents)["query GetDAOs {\n daos {\n items {\n id\n votingDelay\n chainId\n alreadySupportCalldataReview\n supportOffchainData\n }\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "query OffchainProposalNonVoters($id: String!, $addresses: [String], $orderDirection: OrderDirection) {\n offchainProposalNonVoters(\n id: $id\n addresses: $addresses\n orderDirection: $orderDirection\n ) {\n ... on OffchainVotersResponse {\n items {\n voter\n votingPower\n }\n }\n }\n}"): (typeof documents)["query OffchainProposalNonVoters($id: String!, $addresses: [String], $orderDirection: OrderDirection) {\n offchainProposalNonVoters(\n id: $id\n addresses: $addresses\n orderDirection: $orderDirection\n ) {\n ... on OffchainVotersResponse {\n items {\n voter\n votingPower\n }\n }\n }\n}"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/packages/anticapture-client/src/gql/graphql.ts b/packages/anticapture-client/src/gql/graphql.ts index 089eeb96..49da3a25 100644 --- a/packages/anticapture-client/src/gql/graphql.ts +++ b/packages/anticapture-client/src/gql/graphql.ts @@ -367,6 +367,12 @@ export type LastUpdateResponse = { lastUpdate: Scalars['DateTime']['output']; }; +export type OffchainNonVoter = { + __typename?: 'OffchainNonVoter'; + voter: Scalars['String']['output']; + votingPower: Scalars['String']['output']; +}; + export type OffchainProposal = { __typename?: 'OffchainProposal'; /** Address or ENS of the author. */ @@ -412,7 +418,7 @@ export type OffchainProposalsResponse = { export type OffchainVote = { __typename?: 'OffchainVote'; - choice: Array>; + choice?: Maybe>>; created: Scalars['Int']['output']; proposalId: Scalars['String']['output']; proposalTitle?: Maybe; @@ -421,6 +427,12 @@ export type OffchainVote = { vp?: Maybe; }; +export type OffchainVotersResponse = { + __typename?: 'OffchainVotersResponse'; + items: Array>; + totalCount: Scalars['Int']['output']; +}; + export type OffchainVotesResponse = { __typename?: 'OffchainVotesResponse'; items: Array>; @@ -688,6 +700,8 @@ export type Query = { lastUpdate?: Maybe; /** Returns a single offchain (Snapshot) proposal by its ID */ offchainProposalById?: Maybe; + /** Returns the active delegates that did not vote on a given offchain proposal */ + offchainProposalNonVoters?: Maybe; /** Returns a list of offchain (Snapshot) proposals */ offchainProposals?: Maybe; /** Returns a single proposal by its ID */ @@ -979,6 +993,15 @@ export type QueryOffchainProposalByIdArgs = { }; +export type QueryOffchainProposalNonVotersArgs = { + addresses?: InputMaybe>>; + id: Scalars['String']['input']; + limit?: InputMaybe; + orderDirection?: InputMaybe; + skip?: InputMaybe; +}; + + export type QueryOffchainProposalsArgs = { endDate?: InputMaybe; fromDate?: InputMaybe; @@ -1442,6 +1465,8 @@ export type Health_Response = Health_200_Response | Health_503_Response; export type OffchainProposalById_Response = ErrorResponse | OffchainProposal; +export type OffchainProposalNonVoters_Response = ErrorResponse | OffchainVotersResponse; + export enum Ok_Const { Ok = 'ok' } @@ -1688,6 +1713,15 @@ export type GetDaOsQueryVariables = Exact<{ [key: string]: never; }>; export type GetDaOsQuery = { __typename?: 'Query', daos: { __typename?: 'DAOList', items: Array<{ __typename?: 'DaoResponse', id: string, votingDelay: string, chainId: number, alreadySupportCalldataReview: boolean, supportOffchainData: boolean }> } }; +export type OffchainProposalNonVotersQueryVariables = Exact<{ + id: Scalars['String']['input']; + addresses?: InputMaybe> | InputMaybe>; + orderDirection?: InputMaybe; +}>; + + +export type OffchainProposalNonVotersQuery = { __typename?: 'Query', offchainProposalNonVoters?: { __typename?: 'ErrorResponse' } | { __typename?: 'OffchainVotersResponse', items: Array<{ __typename?: 'OffchainNonVoter', voter: string, votingPower: string } | null> } | null }; + export type ListOffchainProposalsQueryVariables = Exact<{ skip?: InputMaybe; limit?: InputMaybe; @@ -1777,6 +1811,7 @@ export type ListHistoricalVotingPowerQuery = { __typename?: 'Query', historicalV export const GetDaOsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetDAOs"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"daos"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"votingDelay"}},{"kind":"Field","name":{"kind":"Name","value":"chainId"}},{"kind":"Field","name":{"kind":"Name","value":"alreadySupportCalldataReview"}},{"kind":"Field","name":{"kind":"Name","value":"supportOffchainData"}}]}}]}}]}}]} as unknown as DocumentNode; +export const OffchainProposalNonVotersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"OffchainProposalNonVoters"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"addresses"}},"type":{"kind":"ListType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"orderDirection"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"OrderDirection"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"offchainProposalNonVoters"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"addresses"},"value":{"kind":"Variable","name":{"kind":"Name","value":"addresses"}}},{"kind":"Argument","name":{"kind":"Name","value":"orderDirection"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orderDirection"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"OffchainVotersResponse"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"voter"}},{"kind":"Field","name":{"kind":"Name","value":"votingPower"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const ListOffchainProposalsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ListOffchainProposals"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"skip"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"orderDirection"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"OrderDirection"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"status"}},"type":{"kind":"ListType","type":{"kind":"NamedType","name":{"kind":"Name","value":"queryInput_offchainProposals_status_items"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"fromDate"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"endDate"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"offchainProposals"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"skip"},"value":{"kind":"Variable","name":{"kind":"Name","value":"skip"}}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}},{"kind":"Argument","name":{"kind":"Name","value":"orderDirection"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orderDirection"}}},{"kind":"Argument","name":{"kind":"Name","value":"status"},"value":{"kind":"Variable","name":{"kind":"Name","value":"status"}}},{"kind":"Argument","name":{"kind":"Name","value":"fromDate"},"value":{"kind":"Variable","name":{"kind":"Name","value":"fromDate"}}},{"kind":"Argument","name":{"kind":"Name","value":"endDate"},"value":{"kind":"Variable","name":{"kind":"Name","value":"endDate"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"discussion"}},{"kind":"Field","name":{"kind":"Name","value":"link"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"created"}},{"kind":"Field","name":{"kind":"Name","value":"end"}}]}},{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}}]}}]} as unknown as DocumentNode; export const ListOffchainVotesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ListOffchainVotes"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"fromDate"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"toDate"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"skip"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"orderBy"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"queryInput_votesOffchain_orderBy"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"orderDirection"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"OrderDirection"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"voterAddresses"}},"type":{"kind":"ListType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"votesOffchain"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"fromDate"},"value":{"kind":"Variable","name":{"kind":"Name","value":"fromDate"}}},{"kind":"Argument","name":{"kind":"Name","value":"toDate"},"value":{"kind":"Variable","name":{"kind":"Name","value":"toDate"}}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}},{"kind":"Argument","name":{"kind":"Name","value":"skip"},"value":{"kind":"Variable","name":{"kind":"Name","value":"skip"}}},{"kind":"Argument","name":{"kind":"Name","value":"orderBy"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orderBy"}}},{"kind":"Argument","name":{"kind":"Name","value":"orderDirection"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orderDirection"}}},{"kind":"Argument","name":{"kind":"Name","value":"voterAddresses"},"value":{"kind":"Variable","name":{"kind":"Name","value":"voterAddresses"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"voter"}},{"kind":"Field","name":{"kind":"Name","value":"created"}},{"kind":"Field","name":{"kind":"Name","value":"proposalId"}},{"kind":"Field","name":{"kind":"Name","value":"proposalTitle"}},{"kind":"Field","name":{"kind":"Name","value":"reason"}},{"kind":"Field","name":{"kind":"Name","value":"vp"}}]}},{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}}]}}]} as unknown as DocumentNode; export const ProposalNonVotersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ProposalNonVoters"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"addresses"}},"type":{"kind":"ListType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"proposalNonVoters"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"addresses"},"value":{"kind":"Variable","name":{"kind":"Name","value":"addresses"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"voter"}}]}}]}}]}}]} as unknown as DocumentNode; diff --git a/packages/anticapture-client/src/index.ts b/packages/anticapture-client/src/index.ts index 0c91943c..ed3c606e 100644 --- a/packages/anticapture-client/src/index.ts +++ b/packages/anticapture-client/src/index.ts @@ -20,9 +20,9 @@ export type { export { OrderDirection, QueryInput_HistoricalVotingPower_OrderBy, - QueryInput_Proposals_Status_Items, QueryInput_Votes_OrderBy, QueryInput_VotesOffchain_OrderBy, + QueryInput_Proposals_Status_Items, } from './gql/graphql'; export { FeedEventType, FeedRelevance } from './schemas'; diff --git a/packages/anticapture-client/src/schemas.ts b/packages/anticapture-client/src/schemas.ts index f7996cff..948b49c1 100644 --- a/packages/anticapture-client/src/schemas.ts +++ b/packages/anticapture-client/src/schemas.ts @@ -127,6 +127,27 @@ export const SafeProposalNonVotersResponseSchema = z.object({ }; }); +export const SafeOffchainProposalNonVotersResponseSchema = z.object({ + offchainProposalNonVoters: z.object({ + items: z.array(z.object({ + voter: z.string(), + votingPower: z.string().optional() + }).nullable()), + totalCount: z.number().optional() + }).nullable() +}).transform((data) => { + if (!data.offchainProposalNonVoters) { + console.warn('OffchainProposalNonVotersResponse has null offchainProposalNonVoters:', data); + return { offchainProposalNonVoters: { items: [], totalCount: 0 } }; + } + return { + offchainProposalNonVoters: { + ...data.offchainProposalNonVoters, + items: data.offchainProposalNonVoters.items.filter((item): item is { voter: string; votingPower?: string } => item !== null) + } + }; +}); + export const EventThresholdResponseSchema = z.object({ getEventRelevanceThreshold: z.object({ threshold: z.string() @@ -141,6 +162,7 @@ export const OffchainProposalItemSchema = z.object({ state: z.string(), created: z.number(), end: z.number(), + start: z.number().optional(), }); export type OffchainProposalItem = z.infer; diff --git a/packages/messages/src/index.ts b/packages/messages/src/index.ts index 29c10728..9eeceb0d 100644 --- a/packages/messages/src/index.ts +++ b/packages/messages/src/index.ts @@ -13,6 +13,7 @@ export * from './triggers/voting-power'; export * from './triggers/offchain-vote-cast'; export * from './triggers/non-voting'; export * from './triggers/delegation-change'; +export * from './triggers/offchain-voting-reminder'; export * from './triggers/buttons'; // Export UI messages diff --git a/packages/messages/src/notification-types.ts b/packages/messages/src/notification-types.ts index 5a1920ab..6a161e90 100644 --- a/packages/messages/src/notification-types.ts +++ b/packages/messages/src/notification-types.ts @@ -10,6 +10,7 @@ export enum NotificationTypeId { VoteConfirmation = 'vote-confirmation', OffchainVoteCast = 'offchain-vote-cast', OffchainProposalFinished = 'offchain-proposal-finished', + OffchainVotingReminder75 = 'offchain-voting-reminder-75', } export const NOTIFICATION_TYPES: Record = { @@ -24,4 +25,5 @@ export const NOTIFICATION_TYPES: Record = { [NotificationTypeId.VoteConfirmation]: 'Vote Confirmation', [NotificationTypeId.OffchainVoteCast]: 'Offchain Vote', [NotificationTypeId.OffchainProposalFinished]: 'Offchain Proposal Finished', + [NotificationTypeId.OffchainVotingReminder75]: 'Offchain Vote Reminder 75%', }; diff --git a/packages/messages/src/triggers/buttons.ts b/packages/messages/src/triggers/buttons.ts index e6be6f81..19425904 100644 --- a/packages/messages/src/triggers/buttons.ts +++ b/packages/messages/src/triggers/buttons.ts @@ -64,6 +64,13 @@ const ctaButtonConfigs: Record = { ? `${BASE_URL}/${daoId}/governance/proposal/${proposalId}` : BASE_URL }, + 'voting-reminder': { + text: 'Cast your vote', + buildUrl: ({ daoId, proposalId }) => + daoId && proposalId + ? `${BASE_URL}/${daoId}/governance/proposal/${proposalId}` + : BASE_URL + }, newOffchainProposal: { text: 'Cast your vote', buildUrl: ({ proposalUrl }) => @@ -73,7 +80,17 @@ const ctaButtonConfigs: Record = { text: 'View proposal results', buildUrl: ({ proposalUrl }) => proposalUrl || BASE_URL - } + }, + offchainVotingReminder: { + text: 'Cast your vote', + buildUrl: ({ proposalUrl }) => + proposalUrl || BASE_URL + }, + 'offchain-voting-reminder': { + text: 'Cast your vote', + buildUrl: ({ proposalUrl }) => + proposalUrl || BASE_URL + }, }; /** diff --git a/packages/messages/src/triggers/offchain-voting-reminder.ts b/packages/messages/src/triggers/offchain-voting-reminder.ts new file mode 100644 index 00000000..846509d1 --- /dev/null +++ b/packages/messages/src/triggers/offchain-voting-reminder.ts @@ -0,0 +1,18 @@ +export const offchainVotingReminderMessages = { + default: `⏰ Snapshot Voting Reminder - {{daoId}} + +Proposal: "{{title}}" + +⏱️ Time remaining: {{timeRemaining}} +📊 {{thresholdPercentage}}% of voting period has passed +🗳️ {{address}}'s vote hasn't been recorded yet + +Don't miss your chance to participate!`, + getMessageKey(_thresholdPercentage: number): string { + return 'default'; + }, + + getTemplate(_key: string): string { + return this.default; + }, +}; diff --git a/packages/messages/src/triggers/voting-reminder.ts b/packages/messages/src/triggers/voting-reminder.ts index 759d16a8..53fc3124 100644 --- a/packages/messages/src/triggers/voting-reminder.ts +++ b/packages/messages/src/triggers/voting-reminder.ts @@ -54,6 +54,16 @@ Participate in governance!`, return 'default'; }, + getTemplate(key: string): string { + const templates: Record = { + urgent: this.urgent, + midPeriod: this.midPeriod, + early: this.early, + default: this.default, + }; + return templates[key] ?? this.default; + }, + headers: { urgent: '🚨 URGENT Voting Reminder - {{daoId}}', midPeriod: '⏰ Mid-Period Voting Reminder - {{daoId}}',