Skip to content

Commit abc6a61

Browse files
Merge pull request #93 from blockful/feat/proposal-finished-notification
Feat/proposal finished notification
2 parents ea8ea74 + 1db8852 commit abc6a61

35 files changed

Lines changed: 1324 additions & 266 deletions

apps/consumers/src/services/dao.service.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,16 +50,16 @@ export class DAOService {
5050
return;
5151
}
5252

53-
const userPreferences = await this.subscriptionApi.getUserPreferences(chatId, 'telegram', daos);
53+
const userPreferences = await this.subscriptionApi.getUserPreferences(chatId, 'telegram', daos.map(dao => dao.id));
5454
const currentSelections = new Set<string>(userPreferences);
5555
this.ensureSession(ctx);
5656
ctx.session.daoSelections = currentSelections;
5757

5858
const keyboard = {
5959
inline_keyboard: [
6060
daos.map(dao => {
61-
const normalizedDao = dao.toUpperCase();
62-
const daoWithEmoji = this.getDaoWithEmoji(dao);
61+
const normalizedDao = dao.id.toUpperCase();
62+
const daoWithEmoji = this.getDaoWithEmoji(dao.id);
6363
return {
6464
text: currentSelections.has(normalizedDao) ? `✅ ${daoWithEmoji}` : daoWithEmoji,
6565
callback_data: `dao_toggle_${normalizedDao}`
@@ -97,9 +97,9 @@ export class DAOService {
9797
const daos = await this.anticaptureClient.getDAOs();
9898
const keyboard = {
9999
inline_keyboard: [
100-
daos.map((dao: string) => {
101-
const normalizedDao = dao.toUpperCase();
102-
const daoWithEmoji = this.getDaoWithEmoji(dao);
100+
daos.map(dao => {
101+
const normalizedDao = dao.id.toUpperCase();
102+
const daoWithEmoji = this.getDaoWithEmoji(dao.id);
103103
return {
104104
text: userSelectedDAOs.has(normalizedDao) ? `✅ ${daoWithEmoji}` : daoWithEmoji,
105105
callback_data: `dao_toggle_${normalizedDao}`
@@ -140,7 +140,7 @@ export class DAOService {
140140

141141
private async updateSubscriptions(chatId: number, selectedDAOs: Set<string>) {
142142
const daos = await this.anticaptureClient.getDAOs();
143-
const currentPreferences = await this.subscriptionApi.getUserPreferences(chatId, 'telegram', daos);
143+
const currentPreferences = await this.subscriptionApi.getUserPreferences(chatId, 'telegram', daos.map(dao => dao.id));
144144
const currentPreferencesSet = new Set(currentPreferences);
145145

146146
const toSubscribe = Array.from(selectedDAOs).filter(dao => !currentPreferencesSet.has(dao));

apps/dispatcher/src/app.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { NotificationClientFactory } from './services/notification/notification-
66
import { RabbitMQNotificationService } from './services/notification/rabbitmq-notification.service';
77
import { NewProposalTriggerHandler } from './services/triggers/new-proposal-trigger.service';
88
import { VotingPowerTriggerHandler } from './services/triggers/voting-power-trigger.service';
9+
import { ProposalFinishedTriggerHandler } from './services/triggers/proposal-finished-trigger.service';
910
import { RabbitMQConnection, RabbitMQPublisher } from '@notification-system/rabbitmq-client';
1011

1112
export class App {
@@ -42,6 +43,11 @@ export class App {
4243
new VotingPowerTriggerHandler(subscriptionClient, notificationFactory)
4344
);
4445

46+
triggerProcessorService.addHandler(
47+
'proposal-finished',
48+
new ProposalFinishedTriggerHandler(subscriptionClient, notificationFactory)
49+
);
50+
4551
this.rabbitMQConsumerService = new RabbitMQConsumerService(this.rabbitmqUrl, triggerProcessorService);
4652
this.isCreated = true;
4753
}

apps/dispatcher/src/interfaces/notification-client.interface.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,14 @@ export interface INotificationClient {
1919
* @throws Error if notification fails to be queued/sent
2020
*/
2121
sendNotification(payload: NotificationPayload): Promise<void>;
22-
}
22+
}
23+
24+
/**
25+
* Data structure for proposal finished notifications
26+
*/
27+
export interface ProposalFinishedNotification {
28+
id: string;
29+
daoId: string;
30+
description: string;
31+
endTimestamp: number;
32+
}
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
import { describe, it, expect, jest, beforeEach, afterEach, beforeAll } from '@jest/globals';
2+
import { ProposalFinishedTriggerHandler } from './proposal-finished-trigger.service';
3+
import { ISubscriptionClient, User, Notification } from '../../interfaces/subscription-client.interface';
4+
import { NotificationClientFactory } from '../notification/notification-factory.service';
5+
import { INotificationClient } from '../../interfaces/notification-client.interface';
6+
import { DispatcherMessage } from '../../interfaces/dispatcher-message.interface';
7+
8+
describe('ProposalFinishedTriggerHandler', () => {
9+
let mockSubscriptionClient: jest.Mocked<ISubscriptionClient>;
10+
let mockNotificationFactory: jest.Mocked<NotificationClientFactory>;
11+
let mockNotificationClient: jest.Mocked<INotificationClient>;
12+
let handler: ProposalFinishedTriggerHandler;
13+
let mockUsers: User[];
14+
let mockNotifications: Notification[];
15+
let mockProposal: any;
16+
17+
beforeAll(() => {
18+
mockUsers = [
19+
{ id: '1', channel: 'telegram', channel_user_id: '123', created_at: new Date() },
20+
{ id: '2', channel: 'telegram', channel_user_id: '456', created_at: new Date() }
21+
];
22+
23+
mockNotifications = [
24+
{ user_id: '1', event_id: 'prop456-finished', dao_id: 'dao123' },
25+
{ user_id: '2', event_id: 'prop456-finished', dao_id: 'dao123' }
26+
];
27+
28+
mockProposal = {
29+
id: 'prop456',
30+
daoId: 'dao123',
31+
description: 'Test Proposal\nDetailed description',
32+
endTimestamp: 1625086400
33+
};
34+
});
35+
36+
beforeEach(() => {
37+
mockSubscriptionClient = {
38+
getDaoSubscribers: jest.fn(),
39+
shouldSend: jest.fn(),
40+
markAsSent: jest.fn(),
41+
getWalletOwners: jest.fn()
42+
} as jest.Mocked<ISubscriptionClient>;
43+
44+
mockNotificationClient = {
45+
sendNotification: jest.fn()
46+
} as jest.Mocked<INotificationClient>;
47+
48+
mockNotificationFactory = {
49+
addClient: jest.fn(),
50+
getClient: jest.fn().mockReturnValue(mockNotificationClient),
51+
supportsChannel: jest.fn().mockReturnValue(true)
52+
} as any;
53+
54+
mockSubscriptionClient.getDaoSubscribers.mockResolvedValue(mockUsers);
55+
mockSubscriptionClient.shouldSend.mockResolvedValue(mockNotifications);
56+
mockSubscriptionClient.markAsSent.mockResolvedValue();
57+
mockNotificationClient.sendNotification.mockResolvedValue();
58+
59+
handler = new ProposalFinishedTriggerHandler(mockSubscriptionClient, mockNotificationFactory);
60+
});
61+
62+
afterEach(() => {
63+
jest.clearAllMocks();
64+
});
65+
66+
describe('handleMessage', () => {
67+
it('should process single proposal finished message correctly', async () => {
68+
const mockMessage: DispatcherMessage<any> = {
69+
triggerId: 'proposal-finished',
70+
events: [mockProposal]
71+
};
72+
73+
await handler.handleMessage(mockMessage);
74+
75+
expect(mockSubscriptionClient.getDaoSubscribers).toHaveBeenCalledWith('dao123', '1625086400');
76+
expect(mockSubscriptionClient.shouldSend).toHaveBeenCalledWith(mockUsers, 'prop456-finished', 'dao123');
77+
expect(mockNotificationClient.sendNotification).toHaveBeenCalledTimes(2);
78+
expect(mockNotificationClient.sendNotification).toHaveBeenCalledWith(expect.objectContaining({
79+
userId: expect.any(String),
80+
channel: expect.any(String),
81+
channelUserId: expect.any(String),
82+
message: 'The proposal "Test Proposal" has ended on dao dao123.'
83+
}));
84+
});
85+
86+
it('should process multiple proposals in a single message', async () => {
87+
const mockUsersForMultiple: User[] = [
88+
{ id: '1', channel: 'telegram', channel_user_id: '123', created_at: new Date() }
89+
];
90+
const mockNotificationsForMultiple: Notification[] = [
91+
{ user_id: '1', event_id: 'prop1-finished', dao_id: 'dao123' },
92+
{ user_id: '1', event_id: 'prop2-finished', dao_id: 'dao456' }
93+
];
94+
const mockMessage: DispatcherMessage<any> = {
95+
triggerId: 'proposal-finished',
96+
events: [
97+
{ id: 'prop1', daoId: 'dao123', description: 'First Proposal', endTimestamp: 1625086401 },
98+
{ id: 'prop2', daoId: 'dao456', description: 'Second Proposal', endTimestamp: 1625086402 }
99+
]
100+
};
101+
102+
mockSubscriptionClient.getDaoSubscribers.mockResolvedValue(mockUsersForMultiple);
103+
mockSubscriptionClient.shouldSend.mockResolvedValue(mockNotificationsForMultiple);
104+
105+
await handler.handleMessage(mockMessage);
106+
107+
expect(mockSubscriptionClient.getDaoSubscribers).toHaveBeenCalledTimes(2);
108+
expect(mockSubscriptionClient.getDaoSubscribers).toHaveBeenCalledWith('dao123', '1625086401');
109+
expect(mockSubscriptionClient.getDaoSubscribers).toHaveBeenCalledWith('dao456', '1625086402');
110+
expect(mockNotificationClient.sendNotification).toHaveBeenCalledTimes(2);
111+
});
112+
113+
it('should handle empty proposals array', async () => {
114+
const mockMessage: DispatcherMessage<any> = {
115+
triggerId: 'proposal-finished',
116+
events: []
117+
};
118+
119+
await handler.handleMessage(mockMessage);
120+
121+
expect(mockSubscriptionClient.getDaoSubscribers).not.toHaveBeenCalled();
122+
expect(mockNotificationClient.sendNotification).not.toHaveBeenCalled();
123+
});
124+
125+
it('should skip proposals with no subscribers', async () => {
126+
const mockMessage: DispatcherMessage<any> = {
127+
triggerId: 'proposal-finished',
128+
events: [mockProposal]
129+
};
130+
131+
mockSubscriptionClient.getDaoSubscribers.mockResolvedValue([]);
132+
mockSubscriptionClient.shouldSend.mockResolvedValue([]);
133+
134+
await handler.handleMessage(mockMessage);
135+
136+
expect(mockSubscriptionClient.getDaoSubscribers).toHaveBeenCalledWith('dao123', '1625086400');
137+
expect(mockNotificationClient.sendNotification).not.toHaveBeenCalled();
138+
});
139+
140+
it('should extract title from multiline descriptions', async () => {
141+
const mockUsersForMultiline: User[] = [
142+
{ id: '1', channel: 'telegram', channel_user_id: '123', created_at: new Date() }
143+
];
144+
const mockNotificationsForMultiline: Notification[] = [
145+
{ user_id: '1', event_id: 'prop456-finished', dao_id: 'dao123' }
146+
];
147+
const proposalWithMultilineDesc = {
148+
id: 'prop456',
149+
daoId: 'dao123',
150+
description: 'Main Title\nDetailed description\nMore details',
151+
endTimestamp: 1625086400
152+
};
153+
const mockMessage: DispatcherMessage<any> = {
154+
triggerId: 'proposal-finished',
155+
events: [proposalWithMultilineDesc]
156+
};
157+
158+
mockSubscriptionClient.getDaoSubscribers.mockResolvedValue(mockUsersForMultiline);
159+
mockSubscriptionClient.shouldSend.mockResolvedValue(mockNotificationsForMultiline);
160+
161+
await handler.handleMessage(mockMessage);
162+
163+
expect(mockNotificationClient.sendNotification).toHaveBeenCalledWith(expect.objectContaining({
164+
message: 'The proposal "Main Title" has ended on dao dao123.'
165+
}));
166+
});
167+
168+
it('should handle markdown headers in descriptions', async () => {
169+
const mockUsersForMarkdown: User[] = [
170+
{ id: '1', channel: 'telegram', channel_user_id: '123', created_at: new Date() }
171+
];
172+
const mockNotificationsForMarkdown: Notification[] = [
173+
{ user_id: '1', event_id: 'prop456-finished', dao_id: 'dao123' }
174+
];
175+
const proposalWithMarkdownDesc = {
176+
id: 'prop456',
177+
daoId: 'dao123',
178+
description: '# Markdown Title\nDetailed description',
179+
endTimestamp: 1625086400
180+
};
181+
const mockMessage: DispatcherMessage<any> = {
182+
triggerId: 'proposal-finished',
183+
events: [proposalWithMarkdownDesc]
184+
};
185+
186+
mockSubscriptionClient.getDaoSubscribers.mockResolvedValue(mockUsersForMarkdown);
187+
mockSubscriptionClient.shouldSend.mockResolvedValue(mockNotificationsForMarkdown);
188+
189+
await handler.handleMessage(mockMessage);
190+
191+
expect(mockNotificationClient.sendNotification).toHaveBeenCalledWith(expect.objectContaining({
192+
message: 'The proposal "Markdown Title" has ended on dao dao123.'
193+
}));
194+
});
195+
196+
it('should handle empty descriptions', async () => {
197+
const mockUsersForEmpty: User[] = [
198+
{ id: '1', channel: 'telegram', channel_user_id: '123', created_at: new Date() }
199+
];
200+
const mockNotificationsForEmpty: Notification[] = [
201+
{ user_id: '1', event_id: 'prop456-finished', dao_id: 'dao123' }
202+
];
203+
const proposalWithEmptyDesc = {
204+
id: 'prop456',
205+
daoId: 'dao123',
206+
description: '',
207+
endTimestamp: 1625086400
208+
};
209+
const mockMessage: DispatcherMessage<any> = {
210+
triggerId: 'proposal-finished',
211+
events: [proposalWithEmptyDesc]
212+
};
213+
214+
mockSubscriptionClient.getDaoSubscribers.mockResolvedValue(mockUsersForEmpty);
215+
mockSubscriptionClient.shouldSend.mockResolvedValue(mockNotificationsForEmpty);
216+
217+
await handler.handleMessage(mockMessage);
218+
219+
expect(mockNotificationClient.sendNotification).toHaveBeenCalledWith(expect.objectContaining({
220+
message: 'A proposal has ended on dao dao123.'
221+
}));
222+
});
223+
224+
it('should return correct MessageProcessingResult', async () => {
225+
const mockMessage: DispatcherMessage<any> = {
226+
triggerId: 'proposal-finished',
227+
events: [mockProposal]
228+
};
229+
230+
const result = await handler.handleMessage(mockMessage);
231+
232+
expect(result).toEqual({
233+
messageId: 'proposal-finished',
234+
timestamp: expect.any(String)
235+
});
236+
expect(new Date(result.timestamp)).toBeInstanceOf(Date);
237+
});
238+
});
239+
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { BaseTriggerHandler } from './base-trigger.service';
2+
import { DispatcherMessage, MessageProcessingResult } from '../../interfaces/dispatcher-message.interface';
3+
import { ISubscriptionClient } from '../../interfaces/subscription-client.interface';
4+
import { NotificationClientFactory } from '../notification/notification-factory.service';
5+
import { ProposalFinishedNotification } from '../../interfaces/notification-client.interface';
6+
7+
/**
8+
* Handler for proposal finished trigger events
9+
*/
10+
export class ProposalFinishedTriggerHandler extends BaseTriggerHandler<ProposalFinishedNotification> {
11+
constructor(
12+
subscriptionClient: ISubscriptionClient,
13+
notificationFactory: NotificationClientFactory
14+
) {
15+
super(subscriptionClient, notificationFactory);
16+
}
17+
18+
async handleMessage(message: DispatcherMessage<ProposalFinishedNotification>): Promise<MessageProcessingResult> {
19+
const proposals = message.events;
20+
21+
for (const proposal of proposals) {
22+
const eventId = `${proposal.id}-finished`;
23+
24+
const subscribers = await this.getSubscribers(
25+
proposal.daoId,
26+
eventId,
27+
proposal.endTimestamp.toString()
28+
);
29+
30+
if (subscribers.length === 0) {
31+
continue;
32+
}
33+
34+
const notificationMessage = this.generateNotificationMessage(proposal);
35+
36+
await this.sendNotificationsToSubscribers(
37+
subscribers,
38+
notificationMessage,
39+
eventId,
40+
proposal.daoId
41+
);
42+
}
43+
44+
return {
45+
messageId: message.triggerId,
46+
timestamp: new Date().toISOString()
47+
};
48+
}
49+
50+
/**
51+
* Generates a notification message for a finished proposal
52+
* @param proposal The finished proposal
53+
* @returns Formatted notification message
54+
*/
55+
private generateNotificationMessage(proposal: {
56+
description: string;
57+
daoId: string;
58+
}): string {
59+
const proposalTitle = proposal.description.split('\n')[0].replace(/^#+\s*/, '').trim();
60+
61+
if (proposalTitle) {
62+
return `The proposal "${proposalTitle}" has ended on dao ${proposal.daoId}.`;
63+
} else {
64+
return `A proposal has ended on dao ${proposal.daoId}.`;
65+
}
66+
}
67+
}

0 commit comments

Comments
 (0)