diff --git a/src/datasources/accounts/address-books/__tests__/test.address-books.datasource.module.ts b/src/datasources/accounts/address-books/__tests__/test.address-books.datasource.module.ts new file mode 100644 index 0000000000..bb646ac08f --- /dev/null +++ b/src/datasources/accounts/address-books/__tests__/test.address-books.datasource.module.ts @@ -0,0 +1,23 @@ +import { IAddressBooksDataSource } from '@/domain/interfaces/address-books.datasource.interface'; +import { Module } from '@nestjs/common'; + +const addressBooksDatasource = { + createAddressBookItem: jest.fn(), + getAddressBook: jest.fn(), + updateAddressBookItem: jest.fn(), + deleteAddressBook: jest.fn(), + deleteAddressBookItem: jest.fn(), +} as jest.MockedObjectDeep; + +@Module({ + providers: [ + { + provide: IAddressBooksDataSource, + useFactory: (): jest.MockedObjectDeep => { + return jest.mocked(addressBooksDatasource); + }, + }, + ], + exports: [IAddressBooksDataSource], +}) +export class TestAddressBooksDataSourceModule {} diff --git a/src/datasources/accounts/address-books/address-books.datasource.module.ts b/src/datasources/accounts/address-books/address-books.datasource.module.ts index 2c04b3b0b0..16d7c111ee 100644 --- a/src/datasources/accounts/address-books/address-books.datasource.module.ts +++ b/src/datasources/accounts/address-books/address-books.datasource.module.ts @@ -1,18 +1,18 @@ import { AddressBooksDatasource } from '@/datasources/accounts/address-books/address-books.datasource'; import { AddressBookDbMapper } from '@/datasources/accounts/address-books/entities/address-book.db.mapper'; +import { EncryptionApiManager } from '@/datasources/accounts/encryption/encryption-api.manager'; import { PostgresDatabaseModule } from '@/datasources/db/v1/postgres-database.module'; import { IAddressBooksDataSource } from '@/domain/interfaces/address-books.datasource.interface'; +import { IEncryptionApiManager } from '@/domain/interfaces/encryption-api.manager.interface'; import { Module } from '@nestjs/common'; @Module({ imports: [PostgresDatabaseModule], providers: [ AddressBookDbMapper, - { - provide: IAddressBooksDataSource, - useClass: AddressBooksDatasource, - }, + { provide: IAddressBooksDataSource, useClass: AddressBooksDatasource }, + { provide: IEncryptionApiManager, useClass: EncryptionApiManager }, ], - exports: [IAddressBooksDataSource], + exports: [IAddressBooksDataSource, IEncryptionApiManager], }) export class AddressBooksDatasourceModule {} diff --git a/src/domain/accounts/address-books/address-books.repository.interface.ts b/src/domain/accounts/address-books/address-books.repository.interface.ts new file mode 100644 index 0000000000..840ab97cdf --- /dev/null +++ b/src/domain/accounts/address-books/address-books.repository.interface.ts @@ -0,0 +1,39 @@ +import { AddressBooksDatasourceModule } from '@/datasources/accounts/address-books/address-books.datasource.module'; +import { AccountsRepositoryModule } from '@/domain/accounts/accounts.repository.interface'; +import { AddressBooksRepository } from '@/domain/accounts/address-books/address-books.repository'; +import type { + AddressBook, + AddressBookItem, +} from '@/domain/accounts/address-books/entities/address-book.entity'; +import { CreateAddressBookItemDto } from '@/domain/accounts/address-books/entities/create-address-book-item.dto.entity'; +import { AuthPayload } from '@/domain/auth/entities/auth-payload.entity'; +import { Module } from '@nestjs/common'; + +export const IAddressBooksRepository = Symbol.for('IAddressBooksRepository'); + +export interface IAddressBooksRepository { + getAddressBook(args: { + authPayload: AuthPayload; + address: `0x${string}`; + chainId: string; + }): Promise; + + createAddressBookItem(args: { + authPayload: AuthPayload; + address: `0x${string}`; + chainId: string; + createAddressBookItemDto: CreateAddressBookItemDto; + }): Promise; +} + +@Module({ + imports: [AccountsRepositoryModule, AddressBooksDatasourceModule], + providers: [ + { + provide: IAddressBooksRepository, + useClass: AddressBooksRepository, + }, + ], + exports: [IAddressBooksRepository], +}) +export class AddressBooksRepositoryModule {} diff --git a/src/domain/accounts/address-books/address-books.repository.ts b/src/domain/accounts/address-books/address-books.repository.ts new file mode 100644 index 0000000000..3fba98ad6d --- /dev/null +++ b/src/domain/accounts/address-books/address-books.repository.ts @@ -0,0 +1,124 @@ +import { IAccountsRepository } from '@/domain/accounts/accounts.repository.interface'; +import type { IAddressBooksRepository } from '@/domain/accounts/address-books/address-books.repository.interface'; +import type { + AddressBook, + AddressBookItem, +} from '@/domain/accounts/address-books/entities/address-book.entity'; +import { CreateAddressBookItemDto } from '@/domain/accounts/address-books/entities/create-address-book-item.dto.entity'; +import { + AccountDataType, + AccountDataTypeNames, +} from '@/domain/accounts/entities/account-data-type.entity'; +import { AuthPayload } from '@/domain/auth/entities/auth-payload.entity'; +import { IAddressBooksDataSource } from '@/domain/interfaces/address-books.datasource.interface'; +import { ILoggingService, LoggingService } from '@/logging/logging.interface'; +import { + GoneException, + Inject, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; + +@Injectable() +export class AddressBooksRepository implements IAddressBooksRepository { + constructor( + @Inject(IAddressBooksDataSource) + private readonly dataSource: IAddressBooksDataSource, + @Inject(IAccountsRepository) + private readonly accountsRepository: IAccountsRepository, + @Inject(LoggingService) private readonly loggingService: ILoggingService, + ) {} + + /** + * Gets an AddressBook. + * Checks that the account has the AddressBooks Data Setting enabled. + */ + async getAddressBook(args: { + authPayload: AuthPayload; + address: `0x${string}`; + chainId: string; + }): Promise { + if (!args.authPayload.isForSigner(args.address)) { + throw new UnauthorizedException(); + } + await this.checkAddressBooksIsEnabled({ + authPayload: args.authPayload, + address: args.address, + }); + const account = await this.accountsRepository.getAccount({ + authPayload: args.authPayload, + address: args.address, + }); + return await this.dataSource.getAddressBook({ + account, + chainId: args.chainId, + }); + } + + /** + * Creates an AddressBookItem. + * Checks that the account has the AddressBooks Data Setting enabled. + * + * If an AddressBook for the Account and chainId does not exist, it's created. + */ + async createAddressBookItem(args: { + authPayload: AuthPayload; + address: `0x${string}`; + chainId: string; + createAddressBookItemDto: CreateAddressBookItemDto; + }): Promise { + if (!args.authPayload.isForSigner(args.address)) { + throw new UnauthorizedException(); + } + await this.checkAddressBooksIsEnabled({ + authPayload: args.authPayload, + address: args.address, + }); + const account = await this.accountsRepository.getAccount({ + authPayload: args.authPayload, + address: args.address, + }); + return this.dataSource.createAddressBookItem({ + account, + chainId: args.chainId, + createAddressBookItemDto: args.createAddressBookItemDto, + }); + } + + // TODO: Extract this functionality in AccountsRepository['checkIsEnabled(DataType, Account)'] + private async checkAddressBooksIsEnabled(args: { + authPayload: AuthPayload; + address: `0x${string}`; + }): Promise { + const addressBookDataType = await this.checkAddressBookDataTypeIsActive(); + const accountDataSettings = + await this.accountsRepository.getAccountDataSettings({ + authPayload: args.authPayload, + address: args.address, + }); + const addressBookSetting = accountDataSettings.find( + (setting) => setting.account_data_type_id === addressBookDataType.id, + ); + if (!addressBookSetting?.enabled) { + this.loggingService.warn({ + message: `Account ${args.address} does not have AddressBooks enabled`, + }); + throw new GoneException(); + } + } + + // TODO: Extract this functionality in AccountsRepository['checkIsActive(DataType)'] + private async checkAddressBookDataTypeIsActive(): Promise { + const dataTypes = await this.accountsRepository.getDataTypes(); + const addressBookDataType = dataTypes.find( + (dataType) => dataType.name === AccountDataTypeNames.AddressBook, + ); + if (!addressBookDataType?.is_active) { + this.loggingService.warn({ + message: `${AccountDataTypeNames.AddressBook} data type is not active`, + }); + throw new GoneException(); + } + return addressBookDataType; + } +} diff --git a/src/domain/accounts/address-books/entities/address-book.entity.ts b/src/domain/accounts/address-books/entities/address-book.entity.ts index f92610c547..bd412c4ecf 100644 --- a/src/domain/accounts/address-books/entities/address-book.entity.ts +++ b/src/domain/accounts/address-books/entities/address-book.entity.ts @@ -1,11 +1,12 @@ import { RowSchema } from '@/datasources/db/v1/entities/row.entity'; import { AccountSchema } from '@/domain/accounts/entities/account.entity'; +import { AddressSchema } from '@/validation/entities/schemas/address.schema'; import { z } from 'zod'; export const AddressBookItemSchema = z.object({ id: z.number(), name: z.string(), - address: z.string(), + address: AddressSchema, }); export const AddressBookSchema = RowSchema.extend({ diff --git a/src/domain/accounts/counterfactual-safes/counterfactual-safes.repository.ts b/src/domain/accounts/counterfactual-safes/counterfactual-safes.repository.ts index 6a96873099..25e2bd1174 100644 --- a/src/domain/accounts/counterfactual-safes/counterfactual-safes.repository.ts +++ b/src/domain/accounts/counterfactual-safes/counterfactual-safes.repository.ts @@ -142,6 +142,7 @@ export class CounterfactualSafesRepository return this.datasource.deleteCounterfactualSafesForAccount(account); } + // TODO: Extract this functionality in AccountsRepository['checkIsEnabled(DataType, Account)'] private async checkCounterfactualSafesIsEnabled(args: { authPayload: AuthPayload; address: `0x${string}`; @@ -165,6 +166,7 @@ export class CounterfactualSafesRepository } } + // TODO: Extract this functionality in AccountsRepository['checkIsActive(DataType)'] private async checkCounterfactualSafeDataTypeIsActive(): Promise { const dataTypes = await this.accountsRepository.getDataTypes(); const counterfactualSafeDataType = dataTypes.find( diff --git a/src/routes/accounts/accounts.controller.spec.ts b/src/routes/accounts/accounts.controller.spec.ts index 892cc454a6..621f34c634 100644 --- a/src/routes/accounts/accounts.controller.spec.ts +++ b/src/routes/accounts/accounts.controller.spec.ts @@ -4,6 +4,8 @@ import { AppModule } from '@/app.module'; import configuration from '@/config/entities/__tests__/configuration'; import { TestAccountsDataSourceModule } from '@/datasources/accounts/__tests__/test.accounts.datasource.module'; import { AccountsDatasourceModule } from '@/datasources/accounts/accounts.datasource.module'; +import { TestAddressBooksDataSourceModule } from '@/datasources/accounts/address-books/__tests__/test.address-books.datasource.module'; +import { AddressBooksDatasourceModule } from '@/datasources/accounts/address-books/address-books.datasource.module'; import { TestCounterfactualSafesDataSourceModule } from '@/datasources/accounts/counterfactual-safes/__tests__/test.counterfactual-safes.datasource.module'; import { CounterfactualSafesDatasourceModule } from '@/datasources/accounts/counterfactual-safes/counterfactual-safes.datasource.module'; import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; @@ -70,6 +72,8 @@ describe('AccountsController', () => { .useModule(TestPostgresDatabaseModule) .overrideModule(AccountsDatasourceModule) .useModule(TestAccountsDataSourceModule) + .overrideModule(AddressBooksDatasourceModule) + .useModule(TestAddressBooksDataSourceModule) .overrideModule(CounterfactualSafesDatasourceModule) .useModule(TestCounterfactualSafesDataSourceModule) .overrideModule(TargetedMessagingDatasourceModule) diff --git a/src/routes/accounts/accounts.module.ts b/src/routes/accounts/accounts.module.ts index 192b9aae04..6a0cdc91e6 100644 --- a/src/routes/accounts/accounts.module.ts +++ b/src/routes/accounts/accounts.module.ts @@ -1,4 +1,5 @@ import { AccountsRepositoryModule } from '@/domain/accounts/accounts.repository.interface'; +import { AddressBooksRepositoryModule } from '@/domain/accounts/address-books/address-books.repository.interface'; import { CounterfactualSafesRepositoryModule } from '@/domain/accounts/counterfactual-safes/counterfactual-safes.repository.interface'; import { AuthRepositoryModule } from '@/domain/auth/auth.repository.interface'; import { AccountsController } from '@/routes/accounts/accounts.controller'; @@ -12,6 +13,7 @@ import { Module } from '@nestjs/common'; @Module({ imports: [ AccountsRepositoryModule, + AddressBooksRepositoryModule, AuthRepositoryModule, CounterfactualSafesRepositoryModule, ], diff --git a/src/routes/accounts/address-books/address-books.controller.ts b/src/routes/accounts/address-books/address-books.controller.ts index e55a872251..c25fcccd75 100644 --- a/src/routes/accounts/address-books/address-books.controller.ts +++ b/src/routes/accounts/address-books/address-books.controller.ts @@ -1,9 +1,19 @@ +import { + CreateAddressBookItemDto, + CreateAddressBookItemDtoSchema, +} from '@/domain/accounts/address-books/entities/create-address-book-item.dto.entity'; +import { AuthPayload } from '@/domain/auth/entities/auth-payload.entity'; import { AddressBooksService } from '@/routes/accounts/address-books/address-books.service'; -import { AddressBook } from '@/routes/accounts/address-books/entities/address-book.entity'; +import { + AddressBook, + AddressBookItem, +} from '@/routes/accounts/address-books/entities/address-book.entity'; +import { Auth } from '@/routes/auth/decorators/auth.decorator'; +import { AuthGuard } from '@/routes/auth/guards/auth.guard'; import { AddressSchema } from '@/validation/entities/schemas/address.schema'; import { NumericStringSchema } from '@/validation/entities/schemas/numeric-string.schema'; import { ValidationPipe } from '@/validation/pipes/validation.pipe'; -import { Controller, Get, Param } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; @ApiTags('accounts') @@ -13,13 +23,34 @@ export class AddressBooksController { @ApiOkResponse({ type: AddressBook }) @Get(':address/address-books/:chainId') + @UseGuards(AuthGuard) async getAddressBook( + @Auth() authPayload: AuthPayload, @Param('address', new ValidationPipe(AddressSchema)) address: `0x${string}`, @Param('chainId', new ValidationPipe(NumericStringSchema)) chainId: string, ): Promise { return this.service.getAddressBook({ + authPayload, address, chainId, }); } + + @ApiOkResponse({ type: AddressBookItem }) + @Post(':address/address-books/:chainId') + @UseGuards(AuthGuard) + async createAddressBookItem( + @Auth() authPayload: AuthPayload, + @Param('address', new ValidationPipe(AddressSchema)) address: `0x${string}`, + @Param('chainId', new ValidationPipe(NumericStringSchema)) chainId: string, + @Body(new ValidationPipe(CreateAddressBookItemDtoSchema)) + createAddressBookItemDto: CreateAddressBookItemDto, + ): Promise { + return this.service.createAddressBookItem({ + authPayload, + address, + chainId, + createAddressBookItemDto, + }); + } } diff --git a/src/routes/accounts/address-books/address-books.service.ts b/src/routes/accounts/address-books/address-books.service.ts index c5ed97f682..d1610d1570 100644 --- a/src/routes/accounts/address-books/address-books.service.ts +++ b/src/routes/accounts/address-books/address-books.service.ts @@ -2,17 +2,59 @@ import { AddressBook, AddressBookItem, } from '@/routes/accounts/address-books/entities/address-book.entity'; -import { Injectable } from '@nestjs/common'; +import { AddressBook as DomainAddressBook } from '@/domain/accounts/address-books/entities/address-book.entity'; +import { AddressBookItem as DomainAddressBookItem } from '@/domain/accounts/address-books/entities/address-book.entity'; +import { Inject, Injectable } from '@nestjs/common'; +import { IAddressBooksRepository } from '@/domain/accounts/address-books/address-books.repository.interface'; +import { AuthPayload } from '@/domain/auth/entities/auth-payload.entity'; +import { CreateAddressBookItemDto } from '@/domain/accounts/address-books/entities/create-address-book-item.dto.entity'; @Injectable() export class AddressBooksService { + constructor( + @Inject(IAddressBooksRepository) + private readonly repository: IAddressBooksRepository, + ) {} + async getAddressBook(args: { + authPayload: AuthPayload; address: `0x${string}`; chainId: string; }): Promise { - await new Promise((resolve) => setTimeout(resolve, 1000)); - return new AddressBook(args.address, [ - new AddressBookItem('1', 'name', '0x01'), - ]); + const domainAddressBook = await this.repository.getAddressBook(args); + return this.mapAddressBook(domainAddressBook); + } + + async createAddressBookItem(args: { + authPayload: AuthPayload; + address: `0x${string}`; + chainId: string; + createAddressBookItemDto: CreateAddressBookItemDto; + }): Promise { + const domainAddressBookItem = + await this.repository.createAddressBookItem(args); + return this.mapAddressBookItem(domainAddressBookItem); + } + + private mapAddressBook(domainAddressBook: DomainAddressBook): AddressBook { + return { + accountId: domainAddressBook.accountId.toString(), + chainId: domainAddressBook.chainId, + data: domainAddressBook.data.map((item) => ({ + id: item.id.toString(), + name: item.name, + address: item.address, + })), + }; + } + + private mapAddressBookItem( + domainAddressBookItem: DomainAddressBookItem, + ): AddressBookItem { + return { + id: domainAddressBookItem.id.toString(), + name: domainAddressBookItem.name, + address: domainAddressBookItem.address, + }; } } diff --git a/src/routes/accounts/address-books/entities/address-book.entity.ts b/src/routes/accounts/address-books/entities/address-book.entity.ts index dab6c4b63f..88528d3b37 100644 --- a/src/routes/accounts/address-books/entities/address-book.entity.ts +++ b/src/routes/accounts/address-books/entities/address-book.entity.ts @@ -19,10 +19,13 @@ export class AddressBook { @ApiProperty() accountId: string; @ApiProperty() + chainId: string; + @ApiProperty() data: AddressBookItem[]; - constructor(accountId: string, data: AddressBookItem[]) { + constructor(accountId: string, chainId: string, data: AddressBookItem[]) { this.accountId = accountId; + this.chainId = chainId; this.data = data; } } diff --git a/src/routes/accounts/counterfactual-safes/counterfactual-safes.controller.spec.ts b/src/routes/accounts/counterfactual-safes/counterfactual-safes.controller.spec.ts index 4f79a1a16c..6854b5af58 100644 --- a/src/routes/accounts/counterfactual-safes/counterfactual-safes.controller.spec.ts +++ b/src/routes/accounts/counterfactual-safes/counterfactual-safes.controller.spec.ts @@ -4,6 +4,8 @@ import { AppModule } from '@/app.module'; import configuration from '@/config/entities/__tests__/configuration'; import { TestAccountsDataSourceModule } from '@/datasources/accounts/__tests__/test.accounts.datasource.module'; import { AccountsDatasourceModule } from '@/datasources/accounts/accounts.datasource.module'; +import { TestAddressBooksDataSourceModule } from '@/datasources/accounts/address-books/__tests__/test.address-books.datasource.module'; +import { AddressBooksDatasourceModule } from '@/datasources/accounts/address-books/address-books.datasource.module'; import { TestCounterfactualSafesDataSourceModule } from '@/datasources/accounts/counterfactual-safes/__tests__/test.counterfactual-safes.datasource.module'; import { CounterfactualSafesDatasourceModule } from '@/datasources/accounts/counterfactual-safes/counterfactual-safes.datasource.module'; import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; @@ -66,6 +68,8 @@ describe('CounterfactualSafesController', () => { }) .overrideModule(AccountsDatasourceModule) .useModule(TestAccountsDataSourceModule) + .overrideModule(AddressBooksDatasourceModule) + .useModule(TestAddressBooksDataSourceModule) .overrideModule(CounterfactualSafesDatasourceModule) .useModule(TestCounterfactualSafesDataSourceModule) .overrideModule(TargetedMessagingDatasourceModule) diff --git a/src/routes/notifications/v2/notifications.controller.spec.ts b/src/routes/notifications/v2/notifications.controller.spec.ts index 89cfc8b757..74743bc831 100644 --- a/src/routes/notifications/v2/notifications.controller.spec.ts +++ b/src/routes/notifications/v2/notifications.controller.spec.ts @@ -45,6 +45,8 @@ import { TestPostgresDatabaseModuleV2 } from '@/datasources/db/v2/test.postgres- import { PostgresDatabaseModuleV2 } from '@/datasources/db/v2/postgres-database.module'; import { TestTargetedMessagingDatasourceModule } from '@/datasources/targeted-messaging/__tests__/test.targeted-messaging.datasource.module'; import { TargetedMessagingDatasourceModule } from '@/datasources/targeted-messaging/targeted-messaging.datasource.module'; +import { TestAddressBooksDataSourceModule } from '@/datasources/accounts/address-books/__tests__/test.address-books.datasource.module'; +import { AddressBooksDatasourceModule } from '@/datasources/accounts/address-books/address-books.datasource.module'; describe('Notifications Controller V2 (Unit)', () => { let app: INestApplication; @@ -74,6 +76,8 @@ describe('Notifications Controller V2 (Unit)', () => { .useModule(TestPostgresDatabaseModule) .overrideModule(AccountsDatasourceModule) .useModule(TestAccountsDataSourceModule) + .overrideModule(AddressBooksDatasourceModule) + .useModule(TestAddressBooksDataSourceModule) .overrideModule(CounterfactualSafesDatasourceModule) .useModule(TestCounterfactualSafesDataSourceModule) .overrideModule(TargetedMessagingDatasourceModule)