Skip to content

Commit

Permalink
Add domain logic for push notifications (#1778)
Browse files Browse the repository at this point in the history
Leverages the `NotificationsDatasource` to `getSubscribersWithTokensBySafe` for the relative `TransactionEventType` and `enqueueNotification`s accordingly:

- Add `INotificationsRepositoryV2` behind a feature flag:
  - `enqueueNotification`
  - `upsertSubscriptions`
  - `getSafeSubscription`
  - `getSubscribersWithTokensBySafe`
  - `deleteSubscription`
  - `deleteDevice`
- Extend `HooksRepository` with `onEventEnqueueNotifications` with respective entities.
- Add `transaction_hash` query to `getIncomingTransfers` and propagate changes/add tests.
- Locate previously domain-relative logic for notifications in domain, e.g. entities and builders.
- Add approriate test coverage.
  • Loading branch information
iamacook authored Aug 6, 2024
1 parent 7bfdc2a commit f1b3053
Show file tree
Hide file tree
Showing 38 changed files with 3,463 additions and 60 deletions.
10 changes: 8 additions & 2 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ import { CommunityModule } from '@/routes/community/community.module';
import { ContractsModule } from '@/routes/contracts/contracts.module';
import { DataDecodedModule } from '@/routes/data-decode/data-decoded.module';
import { DelegatesModule } from '@/routes/delegates/delegates.module';
import { HooksModule } from '@/routes/hooks/hooks.module';
import {
HooksModule,
HooksModuleWithNotifications,
} from '@/routes/hooks/hooks.module';
import { SafeAppsModule } from '@/routes/safe-apps/safe-apps.module';
import { HealthModule } from '@/routes/health/health.module';
import { OwnersModule } from '@/routes/owners/owners.module';
Expand Down Expand Up @@ -58,6 +61,7 @@ export class AppModule implements NestModule {
email: isEmailFeatureEnabled,
confirmationView: isConfirmationViewEnabled,
delegatesV2: isDelegatesV2Enabled,
pushNotifications: isPushNotificationsEnabled,
} = configFactory()['features'];

return {
Expand All @@ -82,7 +86,9 @@ export class AppModule implements NestModule {
: []),
EstimationsModule,
HealthModule,
HooksModule,
...(isPushNotificationsEnabled
? [HooksModuleWithNotifications]
: [HooksModule]),
MessagesModule,
NotificationsModule,
OwnersModule,
Expand Down
1 change: 1 addition & 0 deletions src/config/entities/__tests__/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export default (): ReturnType<typeof configuration> => ({
delegatesV2: false,
counterfactualBalances: false,
accounts: false,
pushNotifications: false,
},
httpClient: { requestTimeout: faker.number.int() },
locking: {
Expand Down
2 changes: 2 additions & 0 deletions src/config/entities/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,8 @@ export default () => ({
counterfactualBalances:
process.env.FF_COUNTERFACTUAL_BALANCES?.toLowerCase() === 'true',
accounts: process.env.FF_ACCOUNTS?.toLowerCase() === 'true',
pushNotifications:
process.env.FF_PUSH_NOTIFICATIONS?.toLowerCase() === 'true',
},
httpClient: {
// Timeout in milliseconds to be used for the HTTP client.
Expand Down
3 changes: 2 additions & 1 deletion src/datasources/cache/cache.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,12 +266,13 @@ export class CacheRouter {
to?: string;
value?: string;
tokenAddress?: string;
txHash?: string;
limit?: number;
offset?: number;
}): CacheDir {
return new CacheDir(
CacheRouter.getIncomingTransfersCacheKey(args),
`${args.executionDateGte}_${args.executionDateLte}_${args.to}_${args.value}_${args.tokenAddress}_${args.limit}_${args.offset}`,
`${args.executionDateGte}_${args.executionDateLte}_${args.to}_${args.value}_${args.tokenAddress}_${args.txHash}_${args.limit}_${args.offset}`,
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { INotificationsDatasource } from '@/domain/interfaces/notifications.datasource.interface';
import { Module } from '@nestjs/common';

const accountsDatasource: INotificationsDatasource = {
deleteDevice: jest.fn(),
deleteSubscription: jest.fn(),
getSafeSubscription: jest.fn(),
getSubscribersBySafe: jest.fn(),
upsertSubscriptions: jest.fn(),
};

@Module({
providers: [
{
provide: INotificationsDatasource,
useFactory: (): jest.MockedObjectDeep<INotificationsDatasource> => {
return jest.mocked(accountsDatasource);
},
},
],
exports: [INotificationsDatasource],
})
export class TestNotificationsDatasourceModule {}
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { faker } from '@faker-js/faker';
import { Builder, IBuilder } from '@/__tests__/builder';
import { getAddress } from 'viem';
import { UpsertSubscriptionsDto } from '@/datasources/notifications/entities/upsert-subscriptions.dto.entity';
import { UpsertSubscriptionsDto } from '@/domain/notifications/entities-v2/upsert-subscriptions.dto.entity';
import { DeviceType } from '@/domain/notifications/entities-v2/device-type.entity';
import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity';
import { NotificationType } from '@/domain/notifications/entities-v2/notification-type.entity';

// TODO: Move to domain
export function upsertSubscriptionsDtoBuilder(): IBuilder<UpsertSubscriptionsDto> {
return new Builder<UpsertSubscriptionsDto>()
.with('cloudMessagingToken', faker.string.alphanumeric({ length: 10 }))
Expand Down
14 changes: 14 additions & 0 deletions src/datasources/notifications/notifications.datasource.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { AccountsDatasourceModule } from '@/datasources/accounts/accounts.datasource.module';
import { NotificationsDatasource } from '@/datasources/notifications/notifications.datasource';
import { PostgresDatabaseModule } from '@/datasources/db/postgres-database.module';
import { INotificationsDatasource } from '@/domain/interfaces/notifications.datasource.interface';
import { Module } from '@nestjs/common';

@Module({
imports: [PostgresDatabaseModule, AccountsDatasourceModule],
providers: [
{ provide: INotificationsDatasource, useClass: NotificationsDatasource },
],
exports: [INotificationsDatasource],
})
export class NotificationsDatasourceModule {}
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,7 @@ describe('NotificationsDatasource', () => {
});
});

describe('getSubscribersWithTokensBySafe', () => {
describe('getSubscribersBySafe', () => {
it('should return a list of subscribers with tokens for a Safe', async () => {
const signerAddress = getAddress(faker.finance.ethereumAddress());
const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build();
Expand All @@ -451,17 +451,19 @@ describe('NotificationsDatasource', () => {

const safe = upsertSubscriptionsDto.safes[0];
await expect(
target.getSubscribersWithTokensBySafe({
target.getSubscribersBySafe({
chainId: safe.chainId,
safeAddress: safe.address,
}),
).resolves.toStrictEqual([
{
subscriber: signerAddress,
deviceUuid: upsertSubscriptionsDto.deviceUuid!,
cloudMessagingToken: upsertSubscriptionsDto.cloudMessagingToken,
},
{
subscriber: secondSignerAddress,
deviceUuid: secondUpsertSubscriptionsDto.deviceUuid!,
cloudMessagingToken: secondUpsertSubscriptionsDto.cloudMessagingToken,
},
]);
Expand Down
18 changes: 13 additions & 5 deletions src/datasources/notifications/notifications.datasource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import {
UnprocessableEntityException,
} from '@nestjs/common';
import postgres from 'postgres';
import { UpsertSubscriptionsDto } from '@/datasources/notifications/entities/upsert-subscriptions.dto.entity';
import { UpsertSubscriptionsDto } from '@/domain/notifications/entities-v2/upsert-subscriptions.dto.entity';
import { UUID } from 'crypto';

@Injectable()
export class NotificationsDatasource implements INotificationsDatasource {
Expand Down Expand Up @@ -130,28 +131,34 @@ export class NotificationsDatasource implements INotificationsDatasource {
}

/**
* Gets subscribers and their cloud messaging tokens for the given Safe.
* Gets subscribers and their device UUID/cloud messaging tokens for the given Safe.
*
* @param args.chainId Chain ID
* @param args.safeAddress Safe address
*
* @returns List of subscribers/tokens for given Safe
*/
async getSubscribersWithTokensBySafe(args: {
async getSubscribersBySafe(args: {
chainId: string;
safeAddress: string;
}): Promise<
Array<{
subscriber: `0x${string}`;
deviceUuid: Uuid;
cloudMessagingToken: string;
}>
> {
const subscribers = await this.sql<
Array<{ signer_address: `0x${string}`; cloud_messaging_token: string }>
Array<{
signer_address: `0x${string}`;
cloud_messaging_token: string;
device_uuid: UUID;
}>
>`
SELECT
pd.cloud_messaging_token,
ns.signer_address
ns.signer_address,
pd.device_uuid
FROM
push_notification_devices pd
JOIN
Expand All @@ -168,6 +175,7 @@ export class NotificationsDatasource implements INotificationsDatasource {
return subscribers.map((subscriber) => {
return {
subscriber: subscriber.signer_address,
deviceUuid: subscriber.device_uuid,
cloudMessagingToken: subscriber.cloud_messaging_token,
};
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Global, Module } from '@nestjs/common';
import { IPushNotificationsApi } from '@/domain/interfaces/push-notifications-api.interface';

const mockPushNotificationsApi: IPushNotificationsApi = {
enqueueNotification: jest.fn(),
};

@Global()
@Module({
providers: [
{
provide: IPushNotificationsApi,
useFactory: (): jest.MockedObjectDeep<IPushNotificationsApi> => {
return jest.mocked(mockPushNotificationsApi);
},
},
],
exports: [IPushNotificationsApi],
})
export class TestPushNotificationsApiModule {}
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export class FirebaseCloudMessagingApiService implements IPushNotificationsApi {
*
* @returns - OAuth2 token
*/
// TODO: Use CacheFirstDataSource
private async getOauth2Token(): Promise<string> {
const cacheDir = CacheRouter.getFirebaseOAuth2TokenCacheDir();
const cachedToken = await this.cacheService.get(cacheDir);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { CacheModule } from '@/datasources/cache/cache.module';
import { HttpErrorFactory } from '@/datasources/errors/http-error-factory';
import { JwtModule } from '@/datasources/jwt/jwt.module';
import { FirebaseCloudMessagingApiService } from '@/datasources/push-notifications-api/firebase-cloud-messaging-api.service';
import { IPushNotificationsApi } from '@/domain/interfaces/push-notifications-api.interface';
import { Module } from '@nestjs/common';

@Module({
imports: [CacheModule],
imports: [CacheModule, JwtModule],
providers: [
HttpErrorFactory,
{
Expand Down
10 changes: 8 additions & 2 deletions src/datasources/transaction-api/transaction-api.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -986,6 +986,7 @@ describe('TransactionApi', () => {
const to = faker.finance.ethereumAddress();
const value = faker.string.numeric();
const tokenAddress = faker.finance.ethereumAddress();
const txHash = faker.string.hexadecimal();
const limit = faker.number.int();
const offset = faker.number.int();
const incomingTransfer = erc20TransferBuilder()
Expand All @@ -997,7 +998,7 @@ describe('TransactionApi', () => {
const getIncomingTransfersUrl = `${baseUrl}/api/v1/safes/${safeAddress}/incoming-transfers/`;
const cacheDir = new CacheDir(
`${chainId}_incoming_transfers_${safeAddress}`,
`${executionDateGte}_${executionDateLte}_${to}_${value}_${tokenAddress}_${limit}_${offset}`,
`${executionDateGte}_${executionDateLte}_${to}_${value}_${tokenAddress}_${txHash}_${limit}_${offset}`,
);
networkService.get.mockResolvedValueOnce({
status: 200,
Expand All @@ -1013,6 +1014,7 @@ describe('TransactionApi', () => {
tokenAddress,
limit,
offset,
txHash,
});

expect(actual).toBe(actual);
Expand All @@ -1031,6 +1033,7 @@ describe('TransactionApi', () => {
token_address: tokenAddress,
limit,
offset,
transaction_hash: txHash,
},
},
});
Expand All @@ -1047,6 +1050,7 @@ describe('TransactionApi', () => {
const to = faker.finance.ethereumAddress();
const value = faker.string.numeric();
const tokenAddress = faker.finance.ethereumAddress();
const txHash = faker.string.hexadecimal();
const limit = faker.number.int();
const offset = faker.number.int();
const getIncomingTransfersUrl = `${baseUrl}/api/v1/safes/${safeAddress}/incoming-transfers/`;
Expand All @@ -1056,7 +1060,7 @@ describe('TransactionApi', () => {
const expected = new DataSourceError(errorMessage, statusCode);
const cacheDir = new CacheDir(
`${chainId}_incoming_transfers_${safeAddress}`,
`${executionDateGte}_${executionDateLte}_${to}_${value}_${tokenAddress}_${limit}_${offset}`,
`${executionDateGte}_${executionDateLte}_${to}_${value}_${tokenAddress}_${txHash}_${limit}_${offset}`,
);
mockDataSource.get.mockRejectedValueOnce(
new NetworkResponseError(
Expand All @@ -1076,6 +1080,7 @@ describe('TransactionApi', () => {
to,
value,
tokenAddress,
txHash,
limit,
offset,
}),
Expand All @@ -1094,6 +1099,7 @@ describe('TransactionApi', () => {
to,
value,
token_address: tokenAddress,
transaction_hash: txHash,
limit,
offset,
},
Expand Down
2 changes: 2 additions & 0 deletions src/datasources/transaction-api/transaction-api.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,7 @@ export class TransactionApi implements ITransactionApi {
to?: string;
value?: string;
tokenAddress?: string;
txHash?: string;
limit?: number;
offset?: number;
}): Promise<Page<Transfer>> {
Expand All @@ -498,6 +499,7 @@ export class TransactionApi implements ITransactionApi {
token_address: args.tokenAddress,
limit: args.limit,
offset: args.offset,
transaction_hash: args.txHash,
},
},
expireTimeSeconds: this.defaultExpirationTimeInSeconds,
Expand Down
Loading

0 comments on commit f1b3053

Please sign in to comment.