Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
c8daf36
feat: add NOTIFICATION_TYPES constant to messages package
LeonardoVieira1630 Mar 25, 2026
77f8447
feat: unique trigger IDs for voting reminder thresholds (30/60/90)
LeonardoVieira1630 Mar 25, 2026
63c2755
feat: add user_notification_preferences migration
LeonardoVieira1630 Mar 25, 2026
54e6f81
feat: add UserNotificationPreferences repository with filterActiveUsers
LeonardoVieira1630 Mar 25, 2026
0e992de
feat: add SettingsService for notification preferences
LeonardoVieira1630 Mar 25, 2026
4202043
feat: add notification preferences API endpoints
LeonardoVieira1630 Mar 25, 2026
172dc85
feat: extend getDaoSubscribers with triggerType filtering
LeonardoVieira1630 Mar 25, 2026
f072690
feat: extend getWalletOwners/batch with triggerType filtering
LeonardoVieira1630 Mar 25, 2026
4e4abe4
feat: propagate triggerType through dispatcher subscription client
LeonardoVieira1630 Mar 25, 2026
b577a8c
feat: add triggerType to getSubscribers, split voting-reminder regist…
LeonardoVieira1630 Mar 25, 2026
3b49587
feat: standard handlers pass triggerType to getSubscribers
LeonardoVieira1630 Mar 25, 2026
847aadd
feat: hybrid handlers pass triggerType to all subscription client calls
LeonardoVieira1630 Mar 26, 2026
ca08f12
feat: BatchNotificationService propagates triggerType, update NonVoti…
LeonardoVieira1630 Mar 26, 2026
6abf15d
feat: add notification preferences methods to SubscriptionAPIService
LeonardoVieira1630 Mar 26, 2026
33b15be
feat: add BaseSettingsService for notification preferences
LeonardoVieira1630 Mar 26, 2026
4a086b0
feat: add Telegram notification settings UI with toggle grid
LeonardoVieira1630 Mar 26, 2026
653bd57
feat: add Slack notification settings UI with checkbox list
LeonardoVieira1630 Mar 26, 2026
8f123e9
feat: add settings mentions to onboarding messages
LeonardoVieira1630 Mar 26, 2026
2d2a7c9
test: add unit tests for notification settings feature
LeonardoVieira1630 Mar 26, 2026
5a6826b
test: add integration tests for notification settings filtering
LeonardoVieira1630 Mar 26, 2026
32d690a
fix: wire settingsController into integration test setup
LeonardoVieira1630 Mar 26, 2026
c4d877e
remove: unnecessary test
LeonardoVieira1630 Mar 26, 2026
82659a0
remove: unnecessary test
LeonardoVieira1630 Mar 26, 2026
1f867a7
remove: unnecessary tests
LeonardoVieira1630 Mar 26, 2026
b69673c
remove: unnecessay tests
LeonardoVieira1630 Mar 26, 2026
3fe694f
refactor: create tests to follow better patterns
LeonardoVieira1630 Mar 26, 2026
93d46f2
refactor: dao confirm message
LeonardoVieira1630 Mar 26, 2026
ac87bda
chore: add .worktrees/ to gitignore
LeonardoVieira1630 Mar 26, 2026
48bf1a5
refactor: broken test
LeonardoVieira1630 Mar 26, 2026
93c527c
refactor: NotificationTypeId
LeonardoVieira1630 Mar 27, 2026
eae3e6f
refactor: messages to be lower
LeonardoVieira1630 Mar 27, 2026
b780957
Merge branch 'dev' into feat/settings_flow
LeonardoVieira1630 Mar 30, 2026
c5015da
fix: broken tests
LeonardoVieira1630 Mar 30, 2026
51ce62c
add: OffchainProposalFinished where it is needed
LeonardoVieira1630 Mar 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -178,3 +178,4 @@ out
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
.worktrees/
8 changes: 7 additions & 1 deletion apps/consumers/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import { WebhookController } from './services/webhook/webhook.controller';
import { WebhookServer } from './services/webhook/webhook-server';
import { SlackDAOService } from './services/dao/slack-dao.service';
import { SlackWalletService } from './services/wallet/slack-wallet.service';
import { SlackSettingsService } from './services/settings/slack-settings.service';
import { TelegramDAOService } from './services/dao/telegram-dao.service';
import { TelegramWalletService } from './services/wallet/telegram-wallet.service';
import { TelegramSettingsService } from './services/settings/telegram-settings.service';
import { ExplorerService } from '@notification-system/messages';
import { EnsResolverService } from './services/ens-resolver.service';
import { AnticaptureClient } from '@notification-system/anticapture-client';
Expand Down Expand Up @@ -42,23 +44,27 @@ export class App {
// Telegram services
const telegramDaoService = new TelegramDAOService(anticaptureClient, subscriptionApi);
const telegramWalletService = new TelegramWalletService(subscriptionApi, ensResolver);
const telegramSettingsService = new TelegramSettingsService(subscriptionApi);

this.telegramBotService = new TelegramBotService(
telegramClient,
telegramDaoService,
telegramWalletService,
telegramSettingsService,
explorerService,
ensResolver
);

const slackDaoService = new SlackDAOService(anticaptureClient, subscriptionApi);
const slackWalletService = new SlackWalletService(subscriptionApi, ensResolver);
const slackSettingsService = new SlackSettingsService(subscriptionApi);

this.slackBotService = new SlackBotService(
slackClient,
ensResolver,
slackDaoService,
slackWalletService
slackWalletService,
slackSettingsService
);

this.webhookService = new WebhookService(anticaptureClient, subscriptionApi);
Expand Down
3 changes: 2 additions & 1 deletion apps/consumers/src/config/knownCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@
export const knownCommands = [
{ command: '/start', description: 'Start the bot' },
{ command: '/daos', description: 'Manage your DAO notifications' },
{ command: '/learn_more', description: 'Learn more about Anticapture' }
{ command: '/learn_more', description: 'Learn more about Anticapture' },
{ command: '/settings', description: 'Manage your notification preferences' }
];
1 change: 1 addition & 0 deletions apps/consumers/src/interfaces/bot.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface ContextWithSession extends Context {
walletsToRemove?: Set<string>;
awaitingWalletInput?: boolean;
fromStart?: boolean;
notificationSelections?: Record<string, boolean>;
};
}

Expand Down
1 change: 1 addition & 0 deletions apps/consumers/src/interfaces/slack-context.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export interface SlackSession {
action: 'add' | 'remove';
};
fromStart?: boolean;
notificationSelections?: Record<string, boolean>;
}

/**
Expand Down
23 changes: 22 additions & 1 deletion apps/consumers/src/services/bot/slack-bot.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,28 @@ import { slackMessages, convertMarkdownToSlack, appendUtmParams } from '@notific
import { EnsResolverService } from '../ens-resolver.service';
import { SlackDAOService } from '../dao/slack-dao.service';
import { SlackWalletService } from '../wallet/slack-wallet.service';
import { SlackSettingsService } from '../settings/slack-settings.service';
import { SlackCommandContext } from '../../interfaces/slack-context.interface';

export class SlackBotService implements BotServiceInterface {
private slackClient: SlackClientInterface;
private ensResolver: EnsResolverService;
private daoService?: SlackDAOService;
private walletService?: SlackWalletService;
private settingsService?: SlackSettingsService;

constructor(
slackClient: SlackClientInterface,
ensResolver: EnsResolverService,
daoService?: SlackDAOService,
walletService?: SlackWalletService
walletService?: SlackWalletService,
settingsService?: SlackSettingsService
) {
this.slackClient = slackClient;
this.ensResolver = ensResolver;
this.daoService = daoService;
this.walletService = walletService;
this.settingsService = settingsService;

this.setupCommands();
}
Expand Down Expand Up @@ -124,6 +128,23 @@ export class SlackBotService implements BotServiceInterface {
await this.walletService.processWalletSubmission(ctx);
}
});

// Settings actions
handlers.action('settings_open', async (ctx) => {
if (this.settingsService) {
await this.settingsService.initialize(ctx);
}
});

handlers.action('settings_checkboxes', async (ctx) => {
await ctx.ack();
});

handlers.action('settings_confirm', async (ctx) => {
if (this.settingsService) {
await this.settingsService.confirm(ctx);
}
});
});
}

Expand Down
28 changes: 26 additions & 2 deletions apps/consumers/src/services/bot/telegram-bot.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Markup } from 'telegraf';
import { telegramMessages, uiMessages, ExplorerService, appendUtmParams } from '@notification-system/messages';
import { TelegramDAOService } from '../dao/telegram-dao.service';
import { TelegramWalletService } from '../wallet/telegram-wallet.service';
import { TelegramSettingsService } from '../settings/telegram-settings.service';
import { EnsResolverService } from '../ens-resolver.service';
import { ContextWithSession, MatchedContext } from '../../interfaces/bot.interface';
import { NotificationPayload } from '../../interfaces/notification.interface';
Expand All @@ -18,19 +19,22 @@ export class TelegramBotService implements BotServiceInterface {
private telegramClient: TelegramClientInterface;
private daoService: TelegramDAOService;
private walletService: TelegramWalletService;
private settingsService: TelegramSettingsService;
private explorerService: ExplorerService;
private ensResolver: EnsResolverService;

constructor(
telegramClient: TelegramClientInterface,
daoService: TelegramDAOService,
walletService: TelegramWalletService,
settingsService: TelegramSettingsService,
explorerService: ExplorerService,
ensResolver: EnsResolverService
) {
this.telegramClient = telegramClient;
this.daoService = daoService;
this.walletService = walletService;
this.settingsService = settingsService;
this.explorerService = explorerService;
this.ensResolver = ensResolver;
this.setupCommands();
Expand All @@ -42,7 +46,7 @@ export class TelegramBotService implements BotServiceInterface {
private createPersistentKeyboard() {
return Markup.keyboard([
[uiMessages.buttons.daos, uiMessages.buttons.myWallets],
[uiMessages.buttons.learnMore]
[uiMessages.buttons.settings, uiMessages.buttons.learnMore]
])
.resize()
.persistent();
Expand Down Expand Up @@ -78,6 +82,14 @@ export class TelegramBotService implements BotServiceInterface {
await this.replyLearnMore(ctx);
});

handlers.command(/^settings$/i, async (ctx) => {
await this.settingsService.initialize(ctx);
});

handlers.hears(uiMessages.buttons.settings, async (ctx) => {
await this.settingsService.initialize(ctx);
});

handlers.action(/^start$/, async (ctx) => {
await ctx.answerCbQuery();
if (ctx.session) ctx.session.fromStart = true;
Expand Down Expand Up @@ -145,8 +157,20 @@ export class TelegramBotService implements BotServiceInterface {
await this.walletService.initialize(ctx);
});

handlers.action(/^settings_toggle_(.+)$/, async (ctx) => {
await ctx.answerCbQuery();
const matchedCtx = ctx as MatchedContext;
await this.settingsService.toggle(ctx, matchedCtx.match[1]);
});

handlers.action(/^settings_confirm$/, async (ctx) => {
await ctx.answerCbQuery();
await this.settingsService.confirm(ctx);
});

handlers.action(/^learn_more_settings$/, async (ctx) => {
await ctx.answerCbQuery(uiMessages.buttons.settingsComingSoon);
await ctx.answerCbQuery();
await this.settingsService.initialize(ctx);
});

handlers.on('message', async (ctx, next) => {
Expand Down
41 changes: 41 additions & 0 deletions apps/consumers/src/services/settings/base-settings.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { NotificationTypeId } from '@notification-system/messages';
import { SubscriptionAPIService } from '../subscription-api.service';

export abstract class BaseSettingsService {
constructor(
protected subscriptionApi: SubscriptionAPIService,
protected platform: string
) {}

protected async loadPreferences(channelUserId: string): Promise<Record<NotificationTypeId, boolean>> {
const stored = await this.subscriptionApi.getNotificationPreferences(
this.platform,
channelUserId
);
const result = {} as Record<NotificationTypeId, boolean>;
for (const id of Object.values(NotificationTypeId)) {
result[id] = true; // default: enabled
}
for (const pref of stored) {
if (pref.trigger_type in result) {
result[pref.trigger_type] = pref.is_active;
}
}
return result;
}

protected async savePreferences(
channelUserId: string,
selections: Record<NotificationTypeId, boolean>
): Promise<void> {
const preferences = Object.values(NotificationTypeId).map(id => ({
trigger_type: id,
is_active: selections[id] ?? true,
}));
await this.subscriptionApi.saveNotificationPreferences(
this.platform,
channelUserId,
preferences
);
}
}
133 changes: 133 additions & 0 deletions apps/consumers/src/services/settings/slack-settings.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { NOTIFICATION_TYPES, NotificationTypeId } from '@notification-system/messages';
import { BaseSettingsService } from './base-settings.service';
import { SubscriptionAPIService } from '../subscription-api.service';
import { SlackActionContext } from '../../interfaces/slack-context.interface';

export class SlackSettingsService extends BaseSettingsService {
constructor(subscriptionApi: SubscriptionAPIService) {
super(subscriptionApi, 'slack');
}

async initialize(ctx: SlackActionContext): Promise<void> {
const channelId = ctx.body.channel?.id || ctx.body.channel_id;
const workspaceId = ctx.body.team?.id || ctx.body.team_id || ctx.body.user?.team_id;
const fullUserId = `${workspaceId}:${channelId}`;

try {
await ctx.ack();

const preferences = await this.loadPreferences(fullUserId);
ctx.session.notificationSelections = preferences;

const notificationTypeIds = Object.values(NotificationTypeId);
const options = notificationTypeIds.map(id => ({
text: { type: 'plain_text' as const, text: NOTIFICATION_TYPES[id] },
value: id,
}));

const initialOptions = notificationTypeIds
.filter(id => preferences[id])
.map(id => ({
text: { type: 'plain_text' as const, text: NOTIFICATION_TYPES[id] },
value: id,
}));

const blocks = [
{
type: 'header' as const,
text: { type: 'plain_text' as const, text: '⚙️ Notification Settings' }
},
{
type: 'section' as const,
text: { type: 'mrkdwn' as const, text: 'Choose which notifications you want to receive:' }
},
{
type: 'actions' as const,
block_id: 'settings_checkboxes_block',
elements: [{
type: 'checkboxes' as const,
action_id: 'settings_checkboxes',
options,
...(initialOptions.length > 0 ? { initial_options: initialOptions } : {})
}]
},
{
type: 'actions' as const,
elements: [{
type: 'button' as const,
text: { type: 'plain_text' as const, text: '✅ Save Settings' },
action_id: 'settings_confirm',
style: 'primary' as const
}]
}
];

if (ctx.respond) {
await ctx.respond({
blocks,
text: 'Notification Settings',
response_type: 'in_channel',
replace_original: false
});
}
} catch (error) {
console.error('Error loading notification settings:', error);
if (ctx.respond) {
await ctx.respond({
text: 'Sorry, there was an error loading your settings. Please try again later.',
response_type: 'ephemeral'
});
}
}
}

async confirm(ctx: SlackActionContext): Promise<void> {
const channelId = ctx.body.channel?.id || ctx.body.channel_id;
const workspaceId = ctx.body.team?.id || ctx.body.team_id || ctx.body.user?.team_id;
const fullUserId = `${workspaceId}:${channelId}`;

try {
await ctx.ack();

// Extract selected checkbox values from state
const stateValues = typeof ctx.body.state === 'object' ? ctx.body.state?.values : undefined;
const selectedValues = new Set<string>();

if (stateValues) {
const checkboxBlock = stateValues['settings_checkboxes_block'];
if (checkboxBlock?.['settings_checkboxes']) {
const selectedOptions = checkboxBlock['settings_checkboxes'].selected_options || [];
for (const option of selectedOptions) {
if (option.value) {
selectedValues.add(option.value);
}
}
}
}

// Build selections record: selected = true, unselected = false
const selections = {} as Record<NotificationTypeId, boolean>;
for (const id of Object.values(NotificationTypeId)) {
selections[id] = selectedValues.has(id);
}

await this.savePreferences(fullUserId, selections);

if (ctx.respond) {
await ctx.respond({
text: '✅ Your notification preferences have been saved!',
response_type: 'in_channel',
replace_original: false
});
}
} catch (error) {
console.error('Error saving notification settings:', error);
if (ctx.respond) {
await ctx.respond({
text: '❌ Failed to save your preferences. Please try again.',
response_type: 'ephemeral'
});
}
}
}
}
Loading
Loading