Skip to content

Commit

Permalink
Send notification only to subscribed accounts (safe-global#1152)
Browse files Browse the repository at this point in the history
With the introduction of subscriptions, we should send email notifications only to verified accounts which are subscribed to the `account_recovery` category.
  • Loading branch information
fmrsabino authored Feb 16, 2024
1 parent 3c9920b commit 4fa2d6d
Show file tree
Hide file tree
Showing 6 changed files with 194 additions and 25 deletions.
9 changes: 9 additions & 0 deletions src/domain/account/entities/__tests__/subscription.builder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Builder, IBuilder } from '@/__tests__/builder';
import { Subscription } from '@/domain/account/entities/subscription.entity';
import { faker } from '@faker-js/faker';

export function subscriptionBuilder(): IBuilder<Subscription> {
return new Builder<Subscription>()
.with('key', faker.word.sample())
.with('name', faker.word.words());
}
2 changes: 2 additions & 0 deletions src/domain/alerts/alerts.domain.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import { AlertsDecodersModule } from '@/domain/alerts/alerts-decoders.module';
import { EmailApiModule } from '@/datasources/email-api/email-api.module';
import { AccountDomainModule } from '@/domain/account/account.domain.module';
import { UrlGeneratorModule } from '@/domain/alerts/urls/url-generator.module';
import { SubscriptionDomainModule } from '@/domain/subscriptions/subscription.domain.module';

@Module({
imports: [
AccountDomainModule,
AlertsApiModule,
AlertsDecodersModule,
EmailApiModule,
SubscriptionDomainModule,
UrlGeneratorModule,
],
providers: [{ provide: IAlertsRepository, useClass: AlertsRepository }],
Expand Down
79 changes: 54 additions & 25 deletions src/domain/alerts/alerts.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ import { ISafeRepository } from '@/domain/safe/safe.repository.interface';
import { Safe } from '@/domain/safe/entities/safe.entity';
import { UrlGeneratorHelper } from '@/domain/alerts/urls/url-generator.helper';
import { IChainsRepository } from '@/domain/chains/chains.repository.interface';
import { ISubscriptionRepository } from '@/domain/subscriptions/subscription.repository.interface';
import { SubscriptionRepository } from '@/domain/subscriptions/subscription.repository';
import { Account } from '@/domain/account/entities/account.entity';

@Injectable()
export class AlertsRepository implements IAlertsRepository {
Expand All @@ -40,6 +43,8 @@ export class AlertsRepository implements IAlertsRepository {
private readonly safeRepository: ISafeRepository,
@Inject(IChainsRepository)
private readonly chainRepository: IChainsRepository,
@Inject(ISubscriptionRepository)
private readonly subscriptionRepository: ISubscriptionRepository,
) {}

async addContracts(contracts: Array<AlertsRegistration>): Promise<void> {
Expand All @@ -63,14 +68,11 @@ export class AlertsRepository implements IAlertsRepository {

// Recovery module is deployed per Safe so we can assume that it is only enabled on one
const safeAddress = safes[0];

const verifiedAccounts = await this.accountRepository.getAccounts({
const subscribedAccounts = await this._getSubscribedAccounts({
chainId,
safeAddress,
onlyVerified: true,
});

if (verifiedAccounts.length === 0) {
if (subscribedAccounts.length === 0) {
this.loggingService.debug(
`An alert for a Safe with no associated emails was received. moduleAddress=${moduleAddress}, safeAddress=${safeAddress}`,
);
Expand Down Expand Up @@ -99,19 +101,55 @@ export class AlertsRepository implements IAlertsRepository {
await this._notifySafeSetup({
chainId,
newSafeState,
accountsToNotify: subscribedAccounts,
});
} catch {
const emails = verifiedAccounts.map(
(account) => account.emailAddress.value,
);
await this._notifyUnknownTransaction({
chainId,
safeAddress,
emails,
accountsToNotify: subscribedAccounts,
});
}
}

/**
* Gets all the subscribed accounts to CATEGORY_ACCOUNT_RECOVERY for a given safe
*
* @param args.chainId - the chain id where the safe is deployed
* @param args.safeAddress - the safe address to which the accounts should be retrieved
*
* @private
*/
private async _getSubscribedAccounts(args: {
chainId: string;
safeAddress: string;
}): Promise<Account[]> {
const accounts = await this.accountRepository.getAccounts({
chainId: args.chainId,
safeAddress: args.safeAddress,
onlyVerified: true,
});

const subscribedAccounts = accounts.map(async (account) => {
const accountSubscriptions =
await this.subscriptionRepository.getSubscriptions({
chainId: account.chainId,
safeAddress: account.safeAddress,
signer: account.signer,
});
return accountSubscriptions.some(
(subscription) =>
subscription.key === SubscriptionRepository.CATEGORY_ACCOUNT_RECOVERY,
)
? account
: null;
});

return (await Promise.all(subscribedAccounts)).filter(
(account): account is Account => account !== null,
);
}

private _decodeTransactionAdded(
data: Hex,
): Array<ReturnType<typeof this._decodeRecoveryTransaction>> {
Expand Down Expand Up @@ -197,7 +235,7 @@ export class AlertsRepository implements IAlertsRepository {
private async _notifyUnknownTransaction(args: {
safeAddress: string;
chainId: string;
emails: string[];
accountsToNotify: Account[];
}): Promise<void> {
const chain = await this.chainRepository.getChain(args.chainId);

Expand All @@ -206,8 +244,11 @@ export class AlertsRepository implements IAlertsRepository {
safeAddress: args.safeAddress,
});

const emails = args.accountsToNotify.map(
(account) => account.emailAddress.value,
);
return this.emailApi.createMessage({
to: args.emails,
to: emails,
template: this.configurationService.getOrThrow<string>(
'email.templates.unknownRecoveryTx',
),
Expand All @@ -221,20 +262,8 @@ export class AlertsRepository implements IAlertsRepository {
private async _notifySafeSetup(args: {
chainId: string;
newSafeState: Safe;
accountsToNotify: Account[];
}): Promise<void> {
const verifiedAccounts = await this.accountRepository.getAccounts({
chainId: args.chainId,
safeAddress: args.newSafeState.address,
onlyVerified: true,
});

if (!verifiedAccounts.length) {
this.loggingService.debug(
`An alert log for an transaction with no verified emails associated was thrown for Safe ${args.newSafeState.address}`,
);
return;
}

const chain = await this.chainRepository.getChain(args.chainId);

const webAppUrl = this.urlGenerator.addressToSafeWebAppUrl({
Expand All @@ -251,7 +280,7 @@ export class AlertsRepository implements IAlertsRepository {
};
});

const emails = verifiedAccounts.map(
const emails = args.accountsToNotify.map(
(account) => account.emailAddress.value,
);
return this.emailApi.createMessage({
Expand Down
6 changes: 6 additions & 0 deletions src/domain/subscriptions/subscription.repository.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ import { Subscription } from '@/domain/account/entities/subscription.entity';
export const ISubscriptionRepository = Symbol('ISubscriptionRepository');

export interface ISubscriptionRepository {
getSubscriptions(args: {
chainId: string;
safeAddress: string;
signer: string;
}): Promise<Subscription[]>;

subscribe(args: {
chainId: string;
safeAddress: string;
Expand Down
8 changes: 8 additions & 0 deletions src/domain/subscriptions/subscription.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ export class SubscriptionRepository implements ISubscriptionRepository {
private readonly accountDataSource: IAccountDataSource,
) {}

getSubscriptions(args: {
chainId: string;
safeAddress: string;
signer: string;
}): Promise<Subscription[]> {
return this.accountDataSource.getSubscriptions(args);
}

subscribe(args: {
chainId: string;
safeAddress: string;
Expand Down
Loading

0 comments on commit 4fa2d6d

Please sign in to comment.