diff --git a/migration/1768341824012-AddCustodyAccountTables.js b/migration/1768341824012-AddCustodyAccountTables.js new file mode 100644 index 0000000000..81eebf2414 --- /dev/null +++ b/migration/1768341824012-AddCustodyAccountTables.js @@ -0,0 +1,52 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * @class + * @implements {MigrationInterface} + */ +module.exports = class AddCustodyAccountTables1768341824012 { + name = 'AddCustodyAccountTables1768341824012' + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "custody_account_access" ("id" int NOT NULL IDENTITY(1,1), "updated" datetime2 NOT NULL CONSTRAINT "DF_7de1867f044392358470c9ade75" DEFAULT getdate(), "created" datetime2 NOT NULL CONSTRAINT "DF_8b3a56557a24d8c9157c4867435" DEFAULT getdate(), "accessLevel" nvarchar(255) NOT NULL, "accountId" int NOT NULL, "userDataId" int NOT NULL, CONSTRAINT "PK_1657b7fd1d5a0ee0b01f657e508" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_380e225bfd7707fff0e4f98035" ON "custody_account_access" ("accountId", "userDataId") `); + await queryRunner.query(`CREATE TABLE "custody_account" ("id" int NOT NULL IDENTITY(1,1), "updated" datetime2 NOT NULL CONSTRAINT "DF_91a0617046b1f9ca14218362617" DEFAULT getdate(), "created" datetime2 NOT NULL CONSTRAINT "DF_281789479a65769e9189e24f7d9" DEFAULT getdate(), "title" nvarchar(256) NOT NULL, "description" nvarchar(MAX), "requiredSignatures" int NOT NULL CONSTRAINT "DF_980d2b28fe8284b5060c70a36fd" DEFAULT 1, "status" nvarchar(255) NOT NULL CONSTRAINT "DF_aaeefb3ab36f3b7e02b5f4c67fc" DEFAULT 'Active', "ownerId" int NOT NULL, CONSTRAINT "PK_89fae3a990abaa76d843242fc6d" PRIMARY KEY ("id"))`); + await queryRunner.query(`ALTER TABLE "custody_order" ADD "accountId" int`); + await queryRunner.query(`ALTER TABLE "custody_order" ADD "initiatedById" int`); + await queryRunner.query(`ALTER TABLE "custody_balance" ADD "accountId" int`); + await queryRunner.query(`ALTER TABLE "user" ADD "custodyAccountId" int`); + await queryRunner.query(`ALTER TABLE "custody_account_access" ADD CONSTRAINT "FK_45213c9c7521d41be00fa5ead93" FOREIGN KEY ("accountId") REFERENCES "custody_account"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "custody_account_access" ADD CONSTRAINT "FK_8a4612269b283bf40950ddb8485" FOREIGN KEY ("userDataId") REFERENCES "user_data"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "custody_account" ADD CONSTRAINT "FK_b89a7cbab6c121f5a092815fce3" FOREIGN KEY ("ownerId") REFERENCES "user_data"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "custody_order" ADD CONSTRAINT "FK_6a769cd0d90bc68cafdd533f03e" FOREIGN KEY ("accountId") REFERENCES "custody_account"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "custody_order" ADD CONSTRAINT "FK_67425e623d89efe4ae1a48dbad6" FOREIGN KEY ("initiatedById") REFERENCES "user_data"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "custody_balance" ADD CONSTRAINT "FK_b141d5e0d74c87aef92eae2847a" FOREIGN KEY ("accountId") REFERENCES "custody_account"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "user" ADD CONSTRAINT "FK_bf8ce326ec41adc02940bccf91a" FOREIGN KEY ("custodyAccountId") REFERENCES "custody_account"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + /** + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" DROP CONSTRAINT "FK_bf8ce326ec41adc02940bccf91a"`); + await queryRunner.query(`ALTER TABLE "custody_balance" DROP CONSTRAINT "FK_b141d5e0d74c87aef92eae2847a"`); + await queryRunner.query(`ALTER TABLE "custody_order" DROP CONSTRAINT "FK_67425e623d89efe4ae1a48dbad6"`); + await queryRunner.query(`ALTER TABLE "custody_order" DROP CONSTRAINT "FK_6a769cd0d90bc68cafdd533f03e"`); + await queryRunner.query(`ALTER TABLE "custody_account" DROP CONSTRAINT "FK_b89a7cbab6c121f5a092815fce3"`); + await queryRunner.query(`ALTER TABLE "custody_account_access" DROP CONSTRAINT "FK_8a4612269b283bf40950ddb8485"`); + await queryRunner.query(`ALTER TABLE "custody_account_access" DROP CONSTRAINT "FK_45213c9c7521d41be00fa5ead93"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "custodyAccountId"`); + await queryRunner.query(`ALTER TABLE "custody_balance" DROP COLUMN "accountId"`); + await queryRunner.query(`ALTER TABLE "custody_order" DROP COLUMN "initiatedById"`); + await queryRunner.query(`ALTER TABLE "custody_order" DROP COLUMN "accountId"`); + await queryRunner.query(`DROP TABLE "custody_account"`); + await queryRunner.query(`DROP INDEX "IDX_380e225bfd7707fff0e4f98035" ON "custody_account_access"`); + await queryRunner.query(`DROP TABLE "custody_account_access"`); + } +} diff --git a/src/subdomains/core/custody/controllers/custody-account.controller.ts b/src/subdomains/core/custody/controllers/custody-account.controller.ts new file mode 100644 index 0000000000..d15699d560 --- /dev/null +++ b/src/subdomains/core/custody/controllers/custody-account.controller.ts @@ -0,0 +1,93 @@ +import { Body, Controller, Get, NotFoundException, Param, Post, Put, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiBearerAuth, ApiCreatedResponse, ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { GetJwt } from 'src/shared/auth/get-jwt.decorator'; +import { JwtPayload } from 'src/shared/auth/jwt-payload.interface'; +import { RoleGuard } from 'src/shared/auth/role.guard'; +import { UserActiveGuard } from 'src/shared/auth/user-active.guard'; +import { UserRole } from 'src/shared/auth/user-role.enum'; +import { CreateCustodyAccountDto } from '../dto/input/create-custody-account.dto'; +import { UpdateCustodyAccountDto } from '../dto/input/update-custody-account.dto'; +import { CustodyAccountAccessDto, CustodyAccountDto } from '../dto/output/custody-account.dto'; +import { CustodyAccessLevel } from '../enums/custody'; +import { CustodyAccountReadGuard, CustodyAccountWriteGuard } from '../guards/custody-account-access.guard'; +import { CustodyAccountDtoMapper } from '../mappers/custody-account-dto.mapper'; +import { CustodyAccountService, LegacyAccountId } from '../services/custody-account.service'; + +@ApiTags('Custody') +@Controller('custody/account') +export class CustodyAccountController { + constructor(private readonly custodyAccountService: CustodyAccountService) {} + + @Get() + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.ACCOUNT), UserActiveGuard()) + @ApiOkResponse({ type: [CustodyAccountDto], description: 'List of custody accounts for the user' }) + async getCustodyAccounts(@GetJwt() jwt: JwtPayload): Promise { + return this.custodyAccountService.getCustodyAccountsForUser(jwt.account); + } + + @Get(':id') + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.ACCOUNT), UserActiveGuard(), CustodyAccountReadGuard) + @ApiOkResponse({ type: CustodyAccountDto, description: 'Custody account details' }) + async getCustodyAccount(@GetJwt() jwt: JwtPayload, @Param('id') id: string): Promise { + const custodyAccounts = await this.custodyAccountService.getCustodyAccountsForUser(jwt.account); + + const isLegacy = id === LegacyAccountId; + const account = isLegacy ? custodyAccounts.find((ca) => ca.isLegacy) : custodyAccounts.find((ca) => ca.id === +id); + if (!account) throw new NotFoundException(`${isLegacy ? 'Legacy' : 'Custody'} account not found`); + + return account; + } + + @Post() + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.ACCOUNT), UserActiveGuard()) + @ApiCreatedResponse({ type: CustodyAccountDto, description: 'Create a new custody account' }) + async createCustodyAccount( + @GetJwt() jwt: JwtPayload, + @Body() dto: CreateCustodyAccountDto, + ): Promise { + const custodyAccount = await this.custodyAccountService.createCustodyAccount( + jwt.account, + dto.title, + dto.description, + ); + + return CustodyAccountDtoMapper.toDto(custodyAccount, CustodyAccessLevel.WRITE); + } + + @Put(':id') + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.ACCOUNT), UserActiveGuard(), CustodyAccountWriteGuard) + @ApiOkResponse({ type: CustodyAccountDto, description: 'Update custody account' }) + async updateCustodyAccount( + @GetJwt() jwt: JwtPayload, + @Param('id') id: string, + @Body() dto: UpdateCustodyAccountDto, + ): Promise { + const custodyAccount = await this.custodyAccountService.updateCustodyAccount( + +id, + jwt.account, + dto.title, + dto.description, + ); + + return CustodyAccountDtoMapper.toDto(custodyAccount, CustodyAccessLevel.WRITE); + } + + @Get(':id/access') + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.ACCOUNT), UserActiveGuard(), CustodyAccountReadGuard) + @ApiOkResponse({ type: [CustodyAccountAccessDto], description: 'List of users with access' }) + async getAccessList(@GetJwt() jwt: JwtPayload, @Param('id') id: string): Promise { + const accessList = await this.custodyAccountService.getAccessList(+id, jwt.account); + + return accessList.map((access) => ({ + id: access.id, + user: { id: access.userData.id }, + accessLevel: access.accessLevel, + })); + } +} diff --git a/src/subdomains/core/custody/controllers/custody.controller.ts b/src/subdomains/core/custody/controllers/custody.controller.ts index b9512363d7..419c56f2e4 100644 --- a/src/subdomains/core/custody/controllers/custody.controller.ts +++ b/src/subdomains/core/custody/controllers/custody.controller.ts @@ -10,7 +10,7 @@ import { UserRole } from 'src/shared/auth/user-role.enum'; import { AssetService } from 'src/shared/models/asset/asset.service'; import { UserService } from 'src/subdomains/generic/user/models/user/user.service'; import { PdfDto } from 'src/subdomains/core/buy-crypto/routes/buy/dto/pdf.dto'; -import { CreateCustodyAccountDto } from '../dto/input/create-custody-account.dto'; +import { CustodySignupDto } from '../dto/input/custody-signup.dto'; import { GetCustodyInfoDto } from '../dto/input/get-custody-info.dto'; import { GetCustodyPdfDto } from '../dto/input/get-custody-pdf.dto'; import { CustodyAuthDto } from '../dto/output/custody-auth.dto'; @@ -60,7 +60,7 @@ export class CustodyController { @ApiCreatedResponse({ type: CustodyAuthDto }) async createCustodyAccount( @GetJwt() jwt: JwtPayload, - @Body() dto: CreateCustodyAccountDto, + @Body() dto: CustodySignupDto, @RealIP() ip: string, ): Promise { return this.service.createCustodyAccount(jwt.account, dto, ip); diff --git a/src/subdomains/core/custody/custody.module.ts b/src/subdomains/core/custody/custody.module.ts index 6ba580bff5..0c60b05e0d 100644 --- a/src/subdomains/core/custody/custody.module.ts +++ b/src/subdomains/core/custody/custody.module.ts @@ -10,20 +10,27 @@ import { ReferralModule } from '../referral/referral.module'; import { SellCryptoModule } from '../sell-crypto/sell-crypto.module'; import { DfxOrderStepAdapter } from './adapter/dfx-order-step.adapter'; import { CustodyAdminController, CustodyController } from './controllers/custody.controller'; +import { CustodyAccountController } from './controllers/custody-account.controller'; import { CustodyBalance } from './entities/custody-balance.entity'; import { CustodyOrderStep } from './entities/custody-order-step.entity'; import { CustodyOrder } from './entities/custody-order.entity'; +import { CustodyAccountAccess } from './entities/custody-account-access.entity'; +import { CustodyAccount } from './entities/custody-account.entity'; import { CustodyBalanceRepository } from './repositories/custody-balance.repository'; import { CustodyOrderStepRepository } from './repositories/custody-order-step.repository'; import { CustodyOrderRepository } from './repositories/custody-order.repository'; +import { CustodyAccountAccessRepository } from './repositories/custody-account-access.repository'; +import { CustodyAccountRepository } from './repositories/custody-account.repository'; import { CustodyJobService } from './services/custody-job.service'; import { CustodyOrderService } from './services/custody-order.service'; import { CustodyPdfService } from './services/custody-pdf.service'; import { CustodyService } from './services/custody.service'; +import { CustodyAccountService } from './services/custody-account.service'; +import { CustodyAccountReadGuard, CustodyAccountWriteGuard } from './guards/custody-account-access.guard'; @Module({ imports: [ - TypeOrmModule.forFeature([CustodyOrder, CustodyOrderStep]), + TypeOrmModule.forFeature([CustodyOrder, CustodyOrderStep, CustodyAccount, CustodyAccountAccess]), forwardRef(() => UserModule), forwardRef(() => ReferralModule), SharedModule, @@ -33,7 +40,7 @@ import { CustodyService } from './services/custody.service'; PricingModule, PayoutModule, ], - controllers: [CustodyController, CustodyAdminController], + controllers: [CustodyController, CustodyAdminController, CustodyAccountController], providers: [ CustodyService, CustodyOrderRepository, @@ -44,7 +51,12 @@ import { CustodyService } from './services/custody.service'; CustodyPdfService, CustodyBalance, CustodyBalanceRepository, + CustodyAccountRepository, + CustodyAccountAccessRepository, + CustodyAccountService, + CustodyAccountReadGuard, + CustodyAccountWriteGuard, ], - exports: [CustodyService, CustodyOrderService], + exports: [CustodyService, CustodyOrderService, CustodyAccountService], }) export class CustodyModule {} diff --git a/src/subdomains/core/custody/dto/input/create-custody-account.dto.ts b/src/subdomains/core/custody/dto/input/create-custody-account.dto.ts index 95f990968b..fb53a54b59 100644 --- a/src/subdomains/core/custody/dto/input/create-custody-account.dto.ts +++ b/src/subdomains/core/custody/dto/input/create-custody-account.dto.ts @@ -1,32 +1,14 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsEnum, IsOptional, IsString, Matches } from 'class-validator'; -import { GetConfig } from 'src/config/config'; -import { Moderator } from 'src/subdomains/generic/user/models/user-data/user-data.enum'; -import { CustodyAddressType } from '../../enums/custody'; +import { IsOptional, IsString, MaxLength } from 'class-validator'; export class CreateCustodyAccountDto { - @ApiProperty({ enum: CustodyAddressType }) - @IsEnum(CustodyAddressType) - addressType: CustodyAddressType; - - @ApiPropertyOptional() - @IsOptional() + @ApiProperty({ description: 'Title of the custody account' }) @IsString() - wallet?: string; + @MaxLength(256) + title: string; - @ApiPropertyOptional() + @ApiPropertyOptional({ description: 'Description of the custody account' }) @IsOptional() @IsString() - @Matches(GetConfig().formats.ref) - usedRef?: string; - - @ApiPropertyOptional({ description: 'Special code' }) - @IsOptional() - @IsString() - specialCode?: string; - - @ApiPropertyOptional({ description: 'Moderator' }) - @IsOptional() - @IsEnum(Moderator) - moderator?: Moderator; + description?: string; } diff --git a/src/subdomains/core/custody/dto/input/custody-signup.dto.ts b/src/subdomains/core/custody/dto/input/custody-signup.dto.ts new file mode 100644 index 0000000000..3c9249b84c --- /dev/null +++ b/src/subdomains/core/custody/dto/input/custody-signup.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsOptional, IsString, Matches } from 'class-validator'; +import { GetConfig } from 'src/config/config'; +import { Moderator } from 'src/subdomains/generic/user/models/user-data/user-data.enum'; +import { CustodyAddressType } from '../../enums/custody'; + +export class CustodySignupDto { + @ApiProperty({ enum: CustodyAddressType }) + @IsEnum(CustodyAddressType) + addressType: CustodyAddressType; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + wallet?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @Matches(GetConfig().formats.ref) + usedRef?: string; + + @ApiPropertyOptional({ description: 'Special code' }) + @IsOptional() + @IsString() + specialCode?: string; + + @ApiPropertyOptional({ description: 'Moderator' }) + @IsOptional() + @IsEnum(Moderator) + moderator?: Moderator; +} diff --git a/src/subdomains/core/custody/dto/input/update-custody-account.dto.ts b/src/subdomains/core/custody/dto/input/update-custody-account.dto.ts new file mode 100644 index 0000000000..7bd55bfc70 --- /dev/null +++ b/src/subdomains/core/custody/dto/input/update-custody-account.dto.ts @@ -0,0 +1,15 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, MaxLength } from 'class-validator'; + +export class UpdateCustodyAccountDto { + @ApiPropertyOptional({ description: 'Title of the custody account' }) + @IsOptional() + @IsString() + @MaxLength(256) + title?: string; + + @ApiPropertyOptional({ description: 'Description of the custody account' }) + @IsOptional() + @IsString() + description?: string; +} diff --git a/src/subdomains/core/custody/dto/output/custody-account.dto.ts b/src/subdomains/core/custody/dto/output/custody-account.dto.ts new file mode 100644 index 0000000000..5f35f0dc31 --- /dev/null +++ b/src/subdomains/core/custody/dto/output/custody-account.dto.ts @@ -0,0 +1,38 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { CustodyAccessLevel } from '../../enums/custody'; + +export class CustodyUserDto { + @ApiProperty() + id: number; +} + +export class CustodyAccountDto { + @ApiPropertyOptional({ description: 'ID of the custody account (null for legacy)' }) + id: number | null; + + @ApiProperty({ description: 'Title of the custody account' }) + title: string; + + @ApiPropertyOptional({ description: 'Description of the custody account' }) + description?: string; + + @ApiProperty({ description: 'Whether this is a legacy account (aggregated custody users)' }) + isLegacy: boolean; + + @ApiProperty({ enum: CustodyAccessLevel, description: 'Access level for current user' }) + accessLevel: CustodyAccessLevel; + + @ApiPropertyOptional({ type: CustodyUserDto }) + owner?: CustodyUserDto; +} + +export class CustodyAccountAccessDto { + @ApiProperty() + id: number; + + @ApiProperty({ type: CustodyUserDto }) + user: CustodyUserDto; + + @ApiProperty({ enum: CustodyAccessLevel }) + accessLevel: CustodyAccessLevel; +} diff --git a/src/subdomains/core/custody/entities/custody-account-access.entity.ts b/src/subdomains/core/custody/entities/custody-account-access.entity.ts new file mode 100644 index 0000000000..4652bdcebd --- /dev/null +++ b/src/subdomains/core/custody/entities/custody-account-access.entity.ts @@ -0,0 +1,18 @@ +import { IEntity } from 'src/shared/models/entity'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { Column, Entity, Index, ManyToOne } from 'typeorm'; +import { CustodyAccessLevel } from '../enums/custody'; +import { CustodyAccount } from './custody-account.entity'; + +@Entity() +@Index((a: CustodyAccountAccess) => [a.account, a.userData], { unique: true }) +export class CustodyAccountAccess extends IEntity { + @ManyToOne(() => CustodyAccount, (custodyAccount) => custodyAccount.accessGrants, { nullable: false }) + account: CustodyAccount; + + @ManyToOne(() => UserData, { nullable: false }) + userData: UserData; + + @Column() + accessLevel: CustodyAccessLevel; +} diff --git a/src/subdomains/core/custody/entities/custody-account.entity.ts b/src/subdomains/core/custody/entities/custody-account.entity.ts new file mode 100644 index 0000000000..e2b2db0a95 --- /dev/null +++ b/src/subdomains/core/custody/entities/custody-account.entity.ts @@ -0,0 +1,26 @@ +import { IEntity } from 'src/shared/models/entity'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { Column, Entity, ManyToOne, OneToMany } from 'typeorm'; +import { CustodyAccountStatus } from '../enums/custody'; +import { CustodyAccountAccess } from './custody-account-access.entity'; + +@Entity() +export class CustodyAccount extends IEntity { + @Column({ length: 256 }) + title: string; + + @Column({ length: 'MAX', nullable: true }) + description?: string; + + @ManyToOne(() => UserData, { nullable: false }) + owner: UserData; + + @Column({ type: 'int', default: 1 }) + requiredSignatures: number; + + @Column({ default: CustodyAccountStatus.ACTIVE }) + status: CustodyAccountStatus; + + @OneToMany(() => CustodyAccountAccess, (access) => access.account) + accessGrants: CustodyAccountAccess[]; +} diff --git a/src/subdomains/core/custody/entities/custody-balance.entity.ts b/src/subdomains/core/custody/entities/custody-balance.entity.ts index 98323960c9..55f6540123 100644 --- a/src/subdomains/core/custody/entities/custody-balance.entity.ts +++ b/src/subdomains/core/custody/entities/custody-balance.entity.ts @@ -2,9 +2,10 @@ import { Asset } from 'src/shared/models/asset/asset.entity'; import { IEntity } from 'src/shared/models/entity'; import { User } from 'src/subdomains/generic/user/models/user/user.entity'; import { Column, Entity, Index, ManyToOne } from 'typeorm'; +import { CustodyAccount } from './custody-account.entity'; @Entity() -@Index((custodyBalance: CustodyBalance) => [custodyBalance.user, custodyBalance.asset], { unique: true }) +@Index((cb: CustodyBalance) => [cb.user, cb.asset], { unique: true }) export class CustodyBalance extends IEntity { @Column({ type: 'float', default: 0 }) balance: number; @@ -14,4 +15,7 @@ export class CustodyBalance extends IEntity { @ManyToOne(() => Asset, { nullable: false, eager: true }) asset: Asset; + + @ManyToOne(() => CustodyAccount, { nullable: true }) + account?: CustodyAccount; } diff --git a/src/subdomains/core/custody/entities/custody-order.entity.ts b/src/subdomains/core/custody/entities/custody-order.entity.ts index f3d992688e..86b942bc81 100644 --- a/src/subdomains/core/custody/entities/custody-order.entity.ts +++ b/src/subdomains/core/custody/entities/custody-order.entity.ts @@ -1,6 +1,7 @@ import { Asset } from 'src/shared/models/asset/asset.entity'; import { IEntity, UpdateResult } from 'src/shared/models/entity'; import { Util } from 'src/shared/utils/util'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; import { User } from 'src/subdomains/generic/user/models/user/user.entity'; import { TransactionRequest } from 'src/subdomains/supporting/payment/entities/transaction-request.entity'; import { Transaction } from 'src/subdomains/supporting/payment/entities/transaction.entity'; @@ -9,6 +10,7 @@ import { Buy } from '../../buy-crypto/routes/buy/buy.entity'; import { Swap } from '../../buy-crypto/routes/swap/swap.entity'; import { Sell } from '../../sell-crypto/route/sell.entity'; import { CustodyOrderStatus, CustodyOrderType } from '../enums/custody'; +import { CustodyAccount } from './custody-account.entity'; import { CustodyOrderStep } from './custody-order-step.entity'; @Entity() @@ -37,6 +39,12 @@ export class CustodyOrder extends IEntity { @ManyToOne(() => User, (user) => user.custodyOrders, { nullable: false }) user: User; + @ManyToOne(() => CustodyAccount, { nullable: true }) + account?: CustodyAccount; + + @ManyToOne(() => UserData, { nullable: true }) + initiatedBy?: UserData; + @ManyToOne(() => Sell, { nullable: true }) sell?: Sell; diff --git a/src/subdomains/core/custody/enums/custody.ts b/src/subdomains/core/custody/enums/custody.ts index 124b7d5e45..5baef3a174 100644 --- a/src/subdomains/core/custody/enums/custody.ts +++ b/src/subdomains/core/custody/enums/custody.ts @@ -2,6 +2,7 @@ export enum CustodyAddressType { EVM = 'EVM', } +// orders export enum CustodyOrderType { DEPOSIT = 'Deposit', WITHDRAWAL = 'Withdrawal', @@ -38,3 +39,15 @@ export enum CustodyOrderStepCommand { CHARGE_ROUTE = 'ChargeRoute', SEND_TO_ROUTE = 'SendToRoute', } + +// accounts +export enum CustodyAccountStatus { + ACTIVE = 'Active', + BLOCKED = 'Blocked', + CLOSED = 'Closed', +} + +export enum CustodyAccessLevel { + READ = 'Read', + WRITE = 'Write', +} diff --git a/src/subdomains/core/custody/guards/custody-account-access.guard.ts b/src/subdomains/core/custody/guards/custody-account-access.guard.ts new file mode 100644 index 0000000000..d8ead9b53f --- /dev/null +++ b/src/subdomains/core/custody/guards/custody-account-access.guard.ts @@ -0,0 +1,48 @@ +import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; +import { CustodyAccessLevel } from '../enums/custody'; +import { CustodyAccountId, CustodyAccountService, LegacyAccountId } from '../services/custody-account.service'; + +abstract class CustodyAccountAccessGuard implements CanActivate { + protected abstract readonly requiredLevel: CustodyAccessLevel; + + constructor(protected readonly custodyAccountService: CustodyAccountService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + + const accountId = request.user?.account; + if (!accountId) { + throw new ForbiddenException('User not authenticated'); + } + + try { + const custodyAccountId = this.getCustodyAccountId(request); + await this.custodyAccountService.checkAccess(custodyAccountId, accountId, this.requiredLevel); + return true; + } catch (error) { + throw new ForbiddenException(error.message || 'Access denied'); + } + } + + private getCustodyAccountId(request: any): CustodyAccountId { + const id = request.params?.custodyAccountId || request.params?.id; + if (id == null) throw new ForbiddenException('Custody account ID required'); + + if (id === LegacyAccountId) return id; + + const parsed = +id; + if (isNaN(parsed)) throw new ForbiddenException('Invalid custody account ID'); + + return parsed; + } +} + +@Injectable() +export class CustodyAccountReadGuard extends CustodyAccountAccessGuard { + protected readonly requiredLevel = CustodyAccessLevel.READ; +} + +@Injectable() +export class CustodyAccountWriteGuard extends CustodyAccountAccessGuard { + protected readonly requiredLevel = CustodyAccessLevel.WRITE; +} diff --git a/src/subdomains/core/custody/mappers/custody-account-dto.mapper.ts b/src/subdomains/core/custody/mappers/custody-account-dto.mapper.ts new file mode 100644 index 0000000000..092f3a8042 --- /dev/null +++ b/src/subdomains/core/custody/mappers/custody-account-dto.mapper.ts @@ -0,0 +1,28 @@ +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { CustodyAccountDto } from '../dto/output/custody-account.dto'; +import { CustodyAccount } from '../entities/custody-account.entity'; +import { CustodyAccessLevel } from '../enums/custody'; + +export class CustodyAccountDtoMapper { + static toDto(custodyAccount: CustodyAccount, accessLevel: CustodyAccessLevel): CustodyAccountDto { + return { + id: custodyAccount.id, + title: custodyAccount.title, + description: custodyAccount.description, + isLegacy: false, + accessLevel, + owner: custodyAccount.owner ? { id: custodyAccount.owner.id } : undefined, + }; + } + + static toLegacyDto(userData: UserData): CustodyAccountDto { + return { + id: null, + title: 'Custody', + description: undefined, + isLegacy: true, + accessLevel: CustodyAccessLevel.WRITE, + owner: { id: userData.id }, + }; + } +} diff --git a/src/subdomains/core/custody/repositories/custody-account-access.repository.ts b/src/subdomains/core/custody/repositories/custody-account-access.repository.ts new file mode 100644 index 0000000000..0019430f0b --- /dev/null +++ b/src/subdomains/core/custody/repositories/custody-account-access.repository.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { BaseRepository } from 'src/shared/repositories/base.repository'; +import { EntityManager } from 'typeorm'; +import { CustodyAccountAccess } from '../entities/custody-account-access.entity'; + +@Injectable() +export class CustodyAccountAccessRepository extends BaseRepository { + constructor(manager: EntityManager) { + super(CustodyAccountAccess, manager); + } +} diff --git a/src/subdomains/core/custody/repositories/custody-account.repository.ts b/src/subdomains/core/custody/repositories/custody-account.repository.ts new file mode 100644 index 0000000000..7497dfdf6a --- /dev/null +++ b/src/subdomains/core/custody/repositories/custody-account.repository.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { BaseRepository } from 'src/shared/repositories/base.repository'; +import { EntityManager } from 'typeorm'; +import { CustodyAccount } from '../entities/custody-account.entity'; + +@Injectable() +export class CustodyAccountRepository extends BaseRepository { + constructor(manager: EntityManager) { + super(CustodyAccount, manager); + } +} diff --git a/src/subdomains/core/custody/services/custody-account.service.ts b/src/subdomains/core/custody/services/custody-account.service.ts new file mode 100644 index 0000000000..2c984329af --- /dev/null +++ b/src/subdomains/core/custody/services/custody-account.service.ts @@ -0,0 +1,153 @@ +import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { UserRole } from 'src/shared/auth/user-role.enum'; +import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service'; +import { CustodyAccountDto } from '../dto/output/custody-account.dto'; +import { CustodyAccountAccess } from '../entities/custody-account-access.entity'; +import { CustodyAccount } from '../entities/custody-account.entity'; +import { CustodyAccessLevel, CustodyAccountStatus } from '../enums/custody'; +import { CustodyAccountDtoMapper } from '../mappers/custody-account-dto.mapper'; +import { CustodyAccountAccessRepository } from '../repositories/custody-account-access.repository'; +import { CustodyAccountRepository } from '../repositories/custody-account.repository'; + +export const LegacyAccountId = 'legacy'; +export type CustodyAccountId = number | typeof LegacyAccountId; + +@Injectable() +export class CustodyAccountService { + constructor( + private readonly custodyAccountRepo: CustodyAccountRepository, + private readonly custodyAccountAccessRepo: CustodyAccountAccessRepository, + private readonly userDataService: UserDataService, + ) {} + + // --- GET CUSTODY ACCOUNTS --- // + async getCustodyAccountsForUser(accountId: number): Promise { + const account = await this.userDataService.getUserData(accountId, { + users: true, + custodyAccounts: true, + custodyAccountAccesses: { account: { owner: true } }, + }); + if (!account) throw new NotFoundException('User not found'); + + // owned accounts + const ownedAccounts = (account.custodyAccounts ?? []).filter((ca) => ca.status === CustodyAccountStatus.ACTIVE); + + // shared accounts (via access grants, excluding owned) + const sharedAccounts = (account.custodyAccountAccesses ?? []) + .filter((a) => a.account.status === CustodyAccountStatus.ACTIVE) + .filter((a) => a.account.owner.id !== accountId); + + const custodyAccounts: CustodyAccountDto[] = [ + ...ownedAccounts.map((ca) => CustodyAccountDtoMapper.toDto(ca, CustodyAccessLevel.WRITE)), + ...sharedAccounts.map((a) => CustodyAccountDtoMapper.toDto(a.account, a.accessLevel)), + ]; + + if (custodyAccounts.length > 0) { + return custodyAccounts; + } + + // fallback to legacy custody account + const hasCustody = account.users.some((u) => u.role === UserRole.CUSTODY); + if (hasCustody) { + return [CustodyAccountDtoMapper.toLegacyDto(account)]; + } + + return []; + } + + async getCustodyAccountById(custodyAccountId: number): Promise { + const custodyAccount = await this.custodyAccountRepo.findOne({ + where: { id: custodyAccountId }, + relations: { owner: true, accessGrants: { userData: true } }, + }); + + if (!custodyAccount) throw new NotFoundException('Custody account not found'); + + return custodyAccount; + } + + // --- ACCESS CHECK --- // + async checkAccess( + custodyAccountId: CustodyAccountId, + accountId: number, + requiredLevel: CustodyAccessLevel, + ): Promise<{ custodyAccount: CustodyAccount | null; isLegacy: boolean }> { + // Legacy mode + if (custodyAccountId === LegacyAccountId) { + if (requiredLevel === CustodyAccessLevel.WRITE) { + throw new ForbiddenException('Cannot modify legacy account'); + } + return { custodyAccount: null, isLegacy: true }; + } + + const custodyAccount = await this.getCustodyAccountById(custodyAccountId); + + // Owner has WRITE access + if (custodyAccount.owner.id === accountId) { + return { custodyAccount, isLegacy: false }; + } + + // Check access grants + const access = custodyAccount.accessGrants.find((a) => a.userData.id === accountId); + if (!access) { + throw new ForbiddenException('No access to this custody account'); + } + + // Check if access level is sufficient + if (requiredLevel === CustodyAccessLevel.WRITE && access.accessLevel === CustodyAccessLevel.READ) { + throw new ForbiddenException('Write access required'); + } + + return { custodyAccount, isLegacy: false }; + } + + // --- CREATE --- // + async createCustodyAccount(accountId: number, title: string, description?: string): Promise { + const owner = await this.userDataService.getUserData(accountId); + if (!owner) throw new NotFoundException('User not found'); + + const custodyAccount = this.custodyAccountRepo.create({ + title, + description, + owner, + status: CustodyAccountStatus.ACTIVE, + requiredSignatures: 1, + }); + + const saved = await this.custodyAccountRepo.save(custodyAccount); + + // Create WRITE access for owner + const ownerAccess = this.custodyAccountAccessRepo.create({ + account: saved, + userData: owner, + accessLevel: CustodyAccessLevel.WRITE, + }); + await this.custodyAccountAccessRepo.save(ownerAccess); + + return saved; + } + + // --- UPDATE --- // + async updateCustodyAccount( + custodyAccountId: number, + accountId: number, + title?: string, + description?: string, + ): Promise { + const { custodyAccount } = await this.checkAccess(custodyAccountId, accountId, CustodyAccessLevel.WRITE); + + Object.assign(custodyAccount, { title, description }); + + return this.custodyAccountRepo.save(custodyAccount); + } + + // --- GET ACCESS LIST --- // + async getAccessList(custodyAccountId: number, accountId: number): Promise { + await this.checkAccess(custodyAccountId, accountId, CustodyAccessLevel.READ); + + return this.custodyAccountAccessRepo.find({ + where: { account: { id: custodyAccountId } }, + relations: { userData: true }, + }); + } +} diff --git a/src/subdomains/core/custody/services/custody.service.ts b/src/subdomains/core/custody/services/custody.service.ts index 5b4d73eb7a..7f785cd0c8 100644 --- a/src/subdomains/core/custody/services/custody.service.ts +++ b/src/subdomains/core/custody/services/custody.service.ts @@ -12,7 +12,7 @@ import { WalletService } from 'src/subdomains/generic/user/models/wallet/wallet. import { AssetPricesService } from 'src/subdomains/supporting/pricing/services/asset-prices.service'; import { In } from 'typeorm'; import { RefService } from '../../referral/process/ref.service'; -import { CreateCustodyAccountDto } from '../dto/input/create-custody-account.dto'; +import { CustodySignupDto } from '../dto/input/custody-signup.dto'; import { CustodyAuthDto } from '../dto/output/custody-auth.dto'; import { CustodyBalanceDto, CustodyHistoryDto, CustodyHistoryEntryDto } from '../dto/output/custody-balance.dto'; import { CustodyBalance } from '../entities/custody-balance.entity'; @@ -41,7 +41,7 @@ export class CustodyService { ) {} // --- ACCOUNT --- // - async createCustodyAccount(accountId: number, dto: CreateCustodyAccountDto, userIp: string): Promise { + async createCustodyAccount(accountId: number, dto: CustodySignupDto, userIp: string): Promise { const ref = await this.refService.get(userIp); if (ref) dto.usedRef ??= ref.ref; diff --git a/src/subdomains/generic/user/models/user-data/user-data.entity.ts b/src/subdomains/generic/user/models/user-data/user-data.entity.ts index 7f5b423fe9..58dd36249f 100644 --- a/src/subdomains/generic/user/models/user-data/user-data.entity.ts +++ b/src/subdomains/generic/user/models/user-data/user-data.entity.ts @@ -7,6 +7,8 @@ import { Language } from 'src/shared/models/language/language.entity'; import { Util } from 'src/shared/utils/util'; import { AmlListStatus } from 'src/subdomains/core/aml/enums/aml-list-status.enum'; import { CheckStatus } from 'src/subdomains/core/aml/enums/check-status.enum'; +import { CustodyAccountAccess } from 'src/subdomains/core/custody/entities/custody-account-access.entity'; +import { CustodyAccount } from 'src/subdomains/core/custody/entities/custody-account.entity'; import { FaucetRequest } from 'src/subdomains/core/faucet-request/entities/faucet-request.entity'; import { DefaultPaymentLinkConfig, @@ -377,6 +379,12 @@ export class UserData extends IEntity { @OneToMany(() => User, (user) => user.userData) users?: User[]; + @OneToMany(() => CustodyAccount, (account) => account.owner) + custodyAccounts?: CustodyAccount[]; + + @OneToMany(() => CustodyAccountAccess, (access) => access.userData) + custodyAccountAccesses?: CustodyAccountAccess[]; + // --- ENTITY METHODS --- // sendMail(): UpdateResult { this.blackSquadRecipientMail = this.mail; diff --git a/src/subdomains/generic/user/models/user/user.entity.ts b/src/subdomains/generic/user/models/user/user.entity.ts index 8b150f0d7a..30b546e46d 100644 --- a/src/subdomains/generic/user/models/user/user.entity.ts +++ b/src/subdomains/generic/user/models/user/user.entity.ts @@ -7,6 +7,7 @@ import { Buy } from 'src/subdomains/core/buy-crypto/routes/buy/buy.entity'; import { Swap } from 'src/subdomains/core/buy-crypto/routes/swap/swap.entity'; import { CustodyBalance } from 'src/subdomains/core/custody/entities/custody-balance.entity'; import { CustodyOrder } from 'src/subdomains/core/custody/entities/custody-order.entity'; +import { CustodyAccount } from 'src/subdomains/core/custody/entities/custody-account.entity'; import { CustodyAddressType } from 'src/subdomains/core/custody/enums/custody'; import { RefReward } from 'src/subdomains/core/referral/reward/ref-reward.entity'; import { Sell } from 'src/subdomains/core/sell-crypto/route/sell.entity'; @@ -154,6 +155,9 @@ export class User extends IEntity { @Column({ nullable: true }) custodyAddressType: CustodyAddressType; + @ManyToOne(() => CustodyAccount, { nullable: true }) + custodyAccount?: CustodyAccount; + @OneToMany(() => CustodyOrder, (custodyOrder) => custodyOrder.user) custodyOrders: CustodyOrder[];