From 615cbcd4222e3e416756f2aca71af0b206709336 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 9 Dec 2025 13:41:03 +0100 Subject: [PATCH 1/7] Add SafeAccount multi-account system - Add SafeAccount and SafeAccountAccess entities - Add SafeAccountService with legacy mode fallback - Add SafeAccountController with CRUD endpoints - Add SafeAccountReadGuard and SafeAccountWriteGuard - Extend User, CustodyBalance, CustodyOrder with safeAccount relations - Add database migration for new tables --- .../1765283294000-AddSafeAccountTables.js | 143 +++++++++++++ .../controllers/safe-account.controller.ts | 113 +++++++++++ src/subdomains/core/custody/custody.module.ts | 18 +- .../dto/input/create-safe-account.dto.ts | 14 ++ .../dto/input/update-safe-account.dto.ts | 15 ++ .../custody/dto/output/safe-account.dto.ts | 38 ++++ .../entities/custody-balance.entity.ts | 4 + .../custody/entities/custody-order.entity.ts | 8 + .../entities/safe-account-access.entity.ts | 18 ++ .../custody/entities/safe-account.entity.ts | 29 +++ src/subdomains/core/custody/enums/custody.ts | 12 ++ .../guards/safe-account-access.guard.ts | 63 ++++++ .../safe-account-access.repository.ts | 11 + .../repositories/safe-account.repository.ts | 11 + .../custody/services/safe-account.service.ts | 191 ++++++++++++++++++ .../generic/user/models/user/user.entity.ts | 4 + 16 files changed, 689 insertions(+), 3 deletions(-) create mode 100644 migration/1765283294000-AddSafeAccountTables.js create mode 100644 src/subdomains/core/custody/controllers/safe-account.controller.ts create mode 100644 src/subdomains/core/custody/dto/input/create-safe-account.dto.ts create mode 100644 src/subdomains/core/custody/dto/input/update-safe-account.dto.ts create mode 100644 src/subdomains/core/custody/dto/output/safe-account.dto.ts create mode 100644 src/subdomains/core/custody/entities/safe-account-access.entity.ts create mode 100644 src/subdomains/core/custody/entities/safe-account.entity.ts create mode 100644 src/subdomains/core/custody/guards/safe-account-access.guard.ts create mode 100644 src/subdomains/core/custody/repositories/safe-account-access.repository.ts create mode 100644 src/subdomains/core/custody/repositories/safe-account.repository.ts create mode 100644 src/subdomains/core/custody/services/safe-account.service.ts diff --git a/migration/1765283294000-AddSafeAccountTables.js b/migration/1765283294000-AddSafeAccountTables.js new file mode 100644 index 0000000000..b40753997a --- /dev/null +++ b/migration/1765283294000-AddSafeAccountTables.js @@ -0,0 +1,143 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + */ + +/** + * @class + * @implements {MigrationInterface} + */ +module.exports = class AddSafeAccountTables1765283294000 { + name = 'AddSafeAccountTables1765283294000' + + async up(queryRunner) { + // Create safe_account table + await queryRunner.query(` + CREATE TABLE "safe_account" ( + "id" int NOT NULL IDENTITY(1,1), + "updated" datetime2 NOT NULL CONSTRAINT "DF_safe_account_updated" DEFAULT getdate(), + "created" datetime2 NOT NULL CONSTRAINT "DF_safe_account_created" DEFAULT getdate(), + "title" nvarchar(256) NOT NULL, + "description" nvarchar(MAX), + "requiredSignatures" int NOT NULL CONSTRAINT "DF_safe_account_requiredSignatures" DEFAULT 1, + "status" nvarchar(256) NOT NULL CONSTRAINT "DF_safe_account_status" DEFAULT 'Active', + "ownerId" int NOT NULL, + CONSTRAINT "PK_safe_account" PRIMARY KEY ("id") + ) + `); + + // Create safe_account_access table + await queryRunner.query(` + CREATE TABLE "safe_account_access" ( + "id" int NOT NULL IDENTITY(1,1), + "updated" datetime2 NOT NULL CONSTRAINT "DF_safe_account_access_updated" DEFAULT getdate(), + "created" datetime2 NOT NULL CONSTRAINT "DF_safe_account_access_created" DEFAULT getdate(), + "accessLevel" nvarchar(256) NOT NULL, + "safeAccountId" int NOT NULL, + "userDataId" int NOT NULL, + CONSTRAINT "PK_safe_account_access" PRIMARY KEY ("id") + ) + `); + + // Create unique index on safe_account_access + await queryRunner.query(` + CREATE UNIQUE INDEX "IDX_safe_account_access_unique" + ON "safe_account_access" ("safeAccountId", "userDataId") + `); + + // Add foreign keys for safe_account + await queryRunner.query(` + ALTER TABLE "safe_account" + ADD CONSTRAINT "FK_safe_account_owner" + FOREIGN KEY ("ownerId") REFERENCES "user_data"("id") + ON DELETE NO ACTION ON UPDATE NO ACTION + `); + + // Add foreign keys for safe_account_access + await queryRunner.query(` + ALTER TABLE "safe_account_access" + ADD CONSTRAINT "FK_safe_account_access_safeAccount" + FOREIGN KEY ("safeAccountId") REFERENCES "safe_account"("id") + ON DELETE NO ACTION ON UPDATE NO ACTION + `); + + await queryRunner.query(` + ALTER TABLE "safe_account_access" + ADD CONSTRAINT "FK_safe_account_access_userData" + FOREIGN KEY ("userDataId") REFERENCES "user_data"("id") + ON DELETE NO ACTION ON UPDATE NO ACTION + `); + + // Add safeAccountId to user table (nullable for legacy compatibility) + await queryRunner.query(` + ALTER TABLE "user" ADD "safeAccountId" int + `); + + await queryRunner.query(` + ALTER TABLE "user" + ADD CONSTRAINT "FK_user_safeAccount" + FOREIGN KEY ("safeAccountId") REFERENCES "safe_account"("id") + ON DELETE NO ACTION ON UPDATE NO ACTION + `); + + // Add safeAccountId to custody_balance table (nullable for legacy compatibility) + await queryRunner.query(` + ALTER TABLE "custody_balance" ADD "safeAccountId" int + `); + + await queryRunner.query(` + ALTER TABLE "custody_balance" + ADD CONSTRAINT "FK_custody_balance_safeAccount" + FOREIGN KEY ("safeAccountId") REFERENCES "safe_account"("id") + ON DELETE NO ACTION ON UPDATE NO ACTION + `); + + // Add safeAccountId and initiatedById to custody_order table (nullable for legacy compatibility) + await queryRunner.query(` + ALTER TABLE "custody_order" ADD "safeAccountId" int + `); + + await queryRunner.query(` + ALTER TABLE "custody_order" ADD "initiatedById" int + `); + + await queryRunner.query(` + ALTER TABLE "custody_order" + ADD CONSTRAINT "FK_custody_order_safeAccount" + FOREIGN KEY ("safeAccountId") REFERENCES "safe_account"("id") + ON DELETE NO ACTION ON UPDATE NO ACTION + `); + + await queryRunner.query(` + ALTER TABLE "custody_order" + ADD CONSTRAINT "FK_custody_order_initiatedBy" + FOREIGN KEY ("initiatedById") REFERENCES "user_data"("id") + ON DELETE NO ACTION ON UPDATE NO ACTION + `); + } + + async down(queryRunner) { + // Remove foreign keys from custody_order + await queryRunner.query(`ALTER TABLE "custody_order" DROP CONSTRAINT "FK_custody_order_initiatedBy"`); + await queryRunner.query(`ALTER TABLE "custody_order" DROP CONSTRAINT "FK_custody_order_safeAccount"`); + await queryRunner.query(`ALTER TABLE "custody_order" DROP COLUMN "initiatedById"`); + await queryRunner.query(`ALTER TABLE "custody_order" DROP COLUMN "safeAccountId"`); + + // Remove foreign key and column from custody_balance + await queryRunner.query(`ALTER TABLE "custody_balance" DROP CONSTRAINT "FK_custody_balance_safeAccount"`); + await queryRunner.query(`ALTER TABLE "custody_balance" DROP COLUMN "safeAccountId"`); + + // Remove foreign key and column from user + await queryRunner.query(`ALTER TABLE "user" DROP CONSTRAINT "FK_user_safeAccount"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "safeAccountId"`); + + // Drop safe_account_access table + await queryRunner.query(`ALTER TABLE "safe_account_access" DROP CONSTRAINT "FK_safe_account_access_userData"`); + await queryRunner.query(`ALTER TABLE "safe_account_access" DROP CONSTRAINT "FK_safe_account_access_safeAccount"`); + await queryRunner.query(`DROP INDEX "IDX_safe_account_access_unique" ON "safe_account_access"`); + await queryRunner.query(`DROP TABLE "safe_account_access"`); + + // Drop safe_account table + await queryRunner.query(`ALTER TABLE "safe_account" DROP CONSTRAINT "FK_safe_account_owner"`); + await queryRunner.query(`DROP TABLE "safe_account"`); + } +} diff --git a/src/subdomains/core/custody/controllers/safe-account.controller.ts b/src/subdomains/core/custody/controllers/safe-account.controller.ts new file mode 100644 index 0000000000..36acf45cdf --- /dev/null +++ b/src/subdomains/core/custody/controllers/safe-account.controller.ts @@ -0,0 +1,113 @@ +import { Body, Controller, Get, 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 { CreateSafeAccountDto } from '../dto/input/create-safe-account.dto'; +import { UpdateSafeAccountDto } from '../dto/input/update-safe-account.dto'; +import { SafeAccountAccessDto, SafeAccountDto } from '../dto/output/safe-account.dto'; +import { SafeAccountReadGuard, SafeAccountWriteGuard } from '../guards/safe-account-access.guard'; +import { SafeAccountService } from '../services/safe-account.service'; + +@ApiTags('SafeAccount') +@Controller('safe-account') +export class SafeAccountController { + constructor(private readonly safeAccountService: SafeAccountService) {} + + @Get() + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.ACCOUNT), UserActiveGuard()) + @ApiOkResponse({ type: [SafeAccountDto], description: 'List of SafeAccounts for the user' }) + async getSafeAccounts(@GetJwt() jwt: JwtPayload): Promise { + return this.safeAccountService.getSafeAccountsForUser(jwt.account); + } + + @Get(':id') + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.ACCOUNT), UserActiveGuard(), SafeAccountReadGuard) + @ApiOkResponse({ type: SafeAccountDto, description: 'SafeAccount details' }) + async getSafeAccount(@GetJwt() jwt: JwtPayload, @Param('id') id: string): Promise { + const safeAccountId = id === 'legacy' ? null : +id; + const safeAccounts = await this.safeAccountService.getSafeAccountsForUser(jwt.account); + + if (safeAccountId === null) { + const legacy = safeAccounts.find((sa) => sa.isLegacy); + if (!legacy) throw new Error('No legacy safe account found'); + return legacy; + } + + const safeAccount = safeAccounts.find((sa) => sa.id === safeAccountId); + if (!safeAccount) throw new Error('SafeAccount not found'); + return safeAccount; + } + + @Post() + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.ACCOUNT), UserActiveGuard()) + @ApiCreatedResponse({ type: SafeAccountDto, description: 'Create a new SafeAccount' }) + async createSafeAccount( + @GetJwt() jwt: JwtPayload, + @Body() dto: CreateSafeAccountDto, + ): Promise { + const safeAccount = await this.safeAccountService.createSafeAccount( + jwt.account, + dto.title, + dto.description, + ); + + return { + id: safeAccount.id, + title: safeAccount.title, + description: safeAccount.description, + isLegacy: false, + accessLevel: 'Write' as any, + owner: { id: jwt.account }, + }; + } + + @Put(':id') + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.ACCOUNT), UserActiveGuard(), SafeAccountWriteGuard) + @ApiOkResponse({ type: SafeAccountDto, description: 'Update SafeAccount' }) + async updateSafeAccount( + @GetJwt() jwt: JwtPayload, + @Param('id') id: string, + @Body() dto: UpdateSafeAccountDto, + ): Promise { + const safeAccount = await this.safeAccountService.updateSafeAccount( + +id, + jwt.account, + dto.title, + dto.description, + ); + + return { + id: safeAccount.id, + title: safeAccount.title, + description: safeAccount.description, + isLegacy: false, + accessLevel: 'Write' as any, + owner: safeAccount.owner ? { id: safeAccount.owner.id } : undefined, + }; + } + + @Get(':id/access') + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.ACCOUNT), UserActiveGuard(), SafeAccountReadGuard) + @ApiOkResponse({ type: [SafeAccountAccessDto], description: 'List of users with access' }) + async getAccessList( + @GetJwt() jwt: JwtPayload, + @Param('id') id: string, + ): Promise { + const accessList = await this.safeAccountService.getAccessList(+id, jwt.account); + + return accessList.map((access) => ({ + id: access.id, + userDataId: access.userData.id, + accessLevel: access.accessLevel, + })); + } +} diff --git a/src/subdomains/core/custody/custody.module.ts b/src/subdomains/core/custody/custody.module.ts index 158a7cfef3..7ce6e68507 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 { SafeAccountController } from './controllers/safe-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 { SafeAccountAccess } from './entities/safe-account-access.entity'; +import { SafeAccount } from './entities/safe-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 { SafeAccountAccessRepository } from './repositories/safe-account-access.repository'; +import { SafeAccountRepository } from './repositories/safe-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 { SafeAccountService } from './services/safe-account.service'; +import { SafeAccountReadGuard, SafeAccountWriteGuard } from './guards/safe-account-access.guard'; @Module({ imports: [ - TypeOrmModule.forFeature([CustodyOrder, CustodyOrderStep]), + TypeOrmModule.forFeature([CustodyOrder, CustodyOrderStep, SafeAccount, SafeAccountAccess]), forwardRef(() => UserModule), ReferralModule, SharedModule, @@ -33,7 +40,7 @@ import { CustodyService } from './services/custody.service'; PricingModule, PayoutModule, ], - controllers: [CustodyController, CustodyAdminController], + controllers: [CustodyController, CustodyAdminController, SafeAccountController], providers: [ CustodyService, CustodyOrderRepository, @@ -44,7 +51,12 @@ import { CustodyService } from './services/custody.service'; CustodyPdfService, CustodyBalance, CustodyBalanceRepository, + SafeAccountRepository, + SafeAccountAccessRepository, + SafeAccountService, + SafeAccountReadGuard, + SafeAccountWriteGuard, ], - exports: [CustodyService, CustodyOrderService], + exports: [CustodyService, CustodyOrderService, SafeAccountService], }) export class CustodyModule {} diff --git a/src/subdomains/core/custody/dto/input/create-safe-account.dto.ts b/src/subdomains/core/custody/dto/input/create-safe-account.dto.ts new file mode 100644 index 0000000000..80531f2ba4 --- /dev/null +++ b/src/subdomains/core/custody/dto/input/create-safe-account.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, MaxLength } from 'class-validator'; + +export class CreateSafeAccountDto { + @ApiProperty({ description: 'Title of the SafeAccount' }) + @IsString() + @MaxLength(256) + title: string; + + @ApiPropertyOptional({ description: 'Description of the SafeAccount' }) + @IsOptional() + @IsString() + description?: string; +} diff --git a/src/subdomains/core/custody/dto/input/update-safe-account.dto.ts b/src/subdomains/core/custody/dto/input/update-safe-account.dto.ts new file mode 100644 index 0000000000..22e2ec5597 --- /dev/null +++ b/src/subdomains/core/custody/dto/input/update-safe-account.dto.ts @@ -0,0 +1,15 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, MaxLength } from 'class-validator'; + +export class UpdateSafeAccountDto { + @ApiPropertyOptional({ description: 'Title of the SafeAccount' }) + @IsOptional() + @IsString() + @MaxLength(256) + title?: string; + + @ApiPropertyOptional({ description: 'Description of the SafeAccount' }) + @IsOptional() + @IsString() + description?: string; +} diff --git a/src/subdomains/core/custody/dto/output/safe-account.dto.ts b/src/subdomains/core/custody/dto/output/safe-account.dto.ts new file mode 100644 index 0000000000..aad0767c56 --- /dev/null +++ b/src/subdomains/core/custody/dto/output/safe-account.dto.ts @@ -0,0 +1,38 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { SafeAccessLevel } from '../../enums/custody'; + +export class SafeAccountOwnerDto { + @ApiProperty() + id: number; +} + +export class SafeAccountDto { + @ApiPropertyOptional({ description: 'ID of the SafeAccount (null for legacy)' }) + id: number | null; + + @ApiProperty({ description: 'Title of the SafeAccount' }) + title: string; + + @ApiPropertyOptional({ description: 'Description of the SafeAccount' }) + description?: string; + + @ApiProperty({ description: 'Whether this is a legacy account (aggregated custody users)' }) + isLegacy: boolean; + + @ApiProperty({ enum: SafeAccessLevel, description: 'Access level for current user' }) + accessLevel: SafeAccessLevel; + + @ApiPropertyOptional({ type: SafeAccountOwnerDto }) + owner?: SafeAccountOwnerDto; +} + +export class SafeAccountAccessDto { + @ApiProperty() + id: number; + + @ApiProperty() + userDataId: number; + + @ApiProperty({ enum: SafeAccessLevel }) + accessLevel: SafeAccessLevel; +} diff --git a/src/subdomains/core/custody/entities/custody-balance.entity.ts b/src/subdomains/core/custody/entities/custody-balance.entity.ts index 98323960c9..2f9435d905 100644 --- a/src/subdomains/core/custody/entities/custody-balance.entity.ts +++ b/src/subdomains/core/custody/entities/custody-balance.entity.ts @@ -2,6 +2,7 @@ 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 { SafeAccount } from './safe-account.entity'; @Entity() @Index((custodyBalance: CustodyBalance) => [custodyBalance.user, custodyBalance.asset], { unique: true }) @@ -14,4 +15,7 @@ export class CustodyBalance extends IEntity { @ManyToOne(() => Asset, { nullable: false, eager: true }) asset: Asset; + + @ManyToOne(() => SafeAccount, { nullable: true }) + safeAccount?: SafeAccount; } diff --git a/src/subdomains/core/custody/entities/custody-order.entity.ts b/src/subdomains/core/custody/entities/custody-order.entity.ts index f3d992688e..12e4f802a5 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'; @@ -10,6 +11,7 @@ import { Swap } from '../../buy-crypto/routes/swap/swap.entity'; import { Sell } from '../../sell-crypto/route/sell.entity'; import { CustodyOrderStatus, CustodyOrderType } from '../enums/custody'; import { CustodyOrderStep } from './custody-order-step.entity'; +import { SafeAccount } from './safe-account.entity'; @Entity() export class CustodyOrder extends IEntity { @@ -37,6 +39,12 @@ export class CustodyOrder extends IEntity { @ManyToOne(() => User, (user) => user.custodyOrders, { nullable: false }) user: User; + @ManyToOne(() => SafeAccount, { nullable: true }) + safeAccount?: SafeAccount; + + @ManyToOne(() => UserData, { nullable: true }) + initiatedBy?: UserData; + @ManyToOne(() => Sell, { nullable: true }) sell?: Sell; diff --git a/src/subdomains/core/custody/entities/safe-account-access.entity.ts b/src/subdomains/core/custody/entities/safe-account-access.entity.ts new file mode 100644 index 0000000000..a0eda44be4 --- /dev/null +++ b/src/subdomains/core/custody/entities/safe-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 { SafeAccessLevel } from '../enums/custody'; +import { SafeAccount } from './safe-account.entity'; + +@Entity() +@Index(['safeAccount', 'userData'], { unique: true }) +export class SafeAccountAccess extends IEntity { + @ManyToOne(() => SafeAccount, (safeAccount) => safeAccount.accessGrants, { nullable: false }) + safeAccount: SafeAccount; + + @ManyToOne(() => UserData, { nullable: false }) + userData: UserData; + + @Column() + accessLevel: SafeAccessLevel; +} diff --git a/src/subdomains/core/custody/entities/safe-account.entity.ts b/src/subdomains/core/custody/entities/safe-account.entity.ts new file mode 100644 index 0000000000..125e39a5b1 --- /dev/null +++ b/src/subdomains/core/custody/entities/safe-account.entity.ts @@ -0,0 +1,29 @@ +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 { SafeAccountStatus } from '../enums/custody'; +import { SafeAccountAccess } from './safe-account-access.entity'; + +@Entity() +export class SafeAccount 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: SafeAccountStatus.ACTIVE }) + status: SafeAccountStatus; + + @OneToMany(() => SafeAccountAccess, (access) => access.safeAccount) + accessGrants: SafeAccountAccess[]; + + // Relationships to User, CustodyBalance, CustodyOrder will be added + // when those entities are updated +} diff --git a/src/subdomains/core/custody/enums/custody.ts b/src/subdomains/core/custody/enums/custody.ts index 124b7d5e45..378bd078b7 100644 --- a/src/subdomains/core/custody/enums/custody.ts +++ b/src/subdomains/core/custody/enums/custody.ts @@ -38,3 +38,15 @@ export enum CustodyOrderStepCommand { CHARGE_ROUTE = 'ChargeRoute', SEND_TO_ROUTE = 'SendToRoute', } + +// SafeAccount Enums +export enum SafeAccountStatus { + ACTIVE = 'Active', + BLOCKED = 'Blocked', + CLOSED = 'Closed', +} + +export enum SafeAccessLevel { + READ = 'Read', + WRITE = 'Write', +} diff --git a/src/subdomains/core/custody/guards/safe-account-access.guard.ts b/src/subdomains/core/custody/guards/safe-account-access.guard.ts new file mode 100644 index 0000000000..5e63957a47 --- /dev/null +++ b/src/subdomains/core/custody/guards/safe-account-access.guard.ts @@ -0,0 +1,63 @@ +import { CanActivate, ExecutionContext, Injectable, ForbiddenException } from '@nestjs/common'; +import { SafeAccessLevel } from '../enums/custody'; +import { SafeAccountService } from '../services/safe-account.service'; + +@Injectable() +export class SafeAccountReadGuard implements CanActivate { + constructor(private readonly safeAccountService: SafeAccountService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const accountId = request.user?.account; + const safeAccountId = this.getSafeAccountId(request); + + if (!accountId) { + throw new ForbiddenException('User not authenticated'); + } + + try { + await this.safeAccountService.checkAccess(safeAccountId, accountId, SafeAccessLevel.READ); + return true; + } catch (error) { + throw new ForbiddenException(error.message || 'Access denied'); + } + } + + private getSafeAccountId(request: any): number | null { + const id = request.params?.safeAccountId || request.params?.id; + if (id === 'legacy' || id === null || id === undefined) { + return null; + } + return parseInt(id, 10); + } +} + +@Injectable() +export class SafeAccountWriteGuard implements CanActivate { + constructor(private readonly safeAccountService: SafeAccountService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const accountId = request.user?.account; + const safeAccountId = this.getSafeAccountId(request); + + if (!accountId) { + throw new ForbiddenException('User not authenticated'); + } + + try { + await this.safeAccountService.checkAccess(safeAccountId, accountId, SafeAccessLevel.WRITE); + return true; + } catch (error) { + throw new ForbiddenException(error.message || 'Access denied'); + } + } + + private getSafeAccountId(request: any): number | null { + const id = request.params?.safeAccountId || request.params?.id; + if (id === 'legacy' || id === null || id === undefined) { + return null; + } + return parseInt(id, 10); + } +} diff --git a/src/subdomains/core/custody/repositories/safe-account-access.repository.ts b/src/subdomains/core/custody/repositories/safe-account-access.repository.ts new file mode 100644 index 0000000000..0ba256ea52 --- /dev/null +++ b/src/subdomains/core/custody/repositories/safe-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 { SafeAccountAccess } from '../entities/safe-account-access.entity'; + +@Injectable() +export class SafeAccountAccessRepository extends BaseRepository { + constructor(manager: EntityManager) { + super(SafeAccountAccess, manager); + } +} diff --git a/src/subdomains/core/custody/repositories/safe-account.repository.ts b/src/subdomains/core/custody/repositories/safe-account.repository.ts new file mode 100644 index 0000000000..4d6cd708d2 --- /dev/null +++ b/src/subdomains/core/custody/repositories/safe-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 { SafeAccount } from '../entities/safe-account.entity'; + +@Injectable() +export class SafeAccountRepository extends BaseRepository { + constructor(manager: EntityManager) { + super(SafeAccount, manager); + } +} diff --git a/src/subdomains/core/custody/services/safe-account.service.ts b/src/subdomains/core/custody/services/safe-account.service.ts new file mode 100644 index 0000000000..2fa8efa2da --- /dev/null +++ b/src/subdomains/core/custody/services/safe-account.service.ts @@ -0,0 +1,191 @@ +import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { UserRole } from 'src/shared/auth/user-role.enum'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service'; +import { User } from 'src/subdomains/generic/user/models/user/user.entity'; +import { In } from 'typeorm'; +import { SafeAccountAccess } from '../entities/safe-account-access.entity'; +import { SafeAccount } from '../entities/safe-account.entity'; +import { SafeAccessLevel, SafeAccountStatus } from '../enums/custody'; +import { SafeAccountAccessRepository } from '../repositories/safe-account-access.repository'; +import { SafeAccountRepository } from '../repositories/safe-account.repository'; +import { CustodyBalanceRepository } from '../repositories/custody-balance.repository'; + +export interface SafeAccountDto { + id: number | null; // null for legacy mode + title: string; + description?: string; + isLegacy: boolean; + accessLevel: SafeAccessLevel; + owner?: { id: number }; +} + +@Injectable() +export class SafeAccountService { + constructor( + private readonly safeAccountRepo: SafeAccountRepository, + private readonly safeAccountAccessRepo: SafeAccountAccessRepository, + private readonly userDataService: UserDataService, + private readonly custodyBalanceRepo: CustodyBalanceRepository, + ) {} + + // --- GET SAFE ACCOUNTS --- // + async getSafeAccountsForUser(accountId: number): Promise { + const account = await this.userDataService.getUserData(accountId, { users: true }); + if (!account) throw new NotFoundException('User not found'); + + // 1. Check for explicit SafeAccounts (owned or shared) + const ownedAccounts = await this.safeAccountRepo.find({ + where: { owner: { id: accountId }, status: SafeAccountStatus.ACTIVE }, + relations: ['owner'], + }); + + const accessGrants = await this.safeAccountAccessRepo.find({ + where: { userData: { id: accountId } }, + relations: ['safeAccount', 'safeAccount.owner'], + }); + + const sharedAccounts = accessGrants + .filter((a) => a.safeAccount.status === SafeAccountStatus.ACTIVE) + .filter((a) => a.safeAccount.owner.id !== accountId); // exclude owned + + const safeAccounts: SafeAccountDto[] = [ + ...ownedAccounts.map((sa) => this.mapToDto(sa, SafeAccessLevel.WRITE, false)), + ...sharedAccounts.map((a) => this.mapToDto(a.safeAccount, a.accessLevel, false)), + ]; + + if (safeAccounts.length > 0) { + return safeAccounts; + } + + // 2. Fallback: Aggregate all CUSTODY users as one "Legacy Safe" + const custodyUsers = account.users.filter((u) => u.role === UserRole.CUSTODY); + if (custodyUsers.length > 0) { + return [this.createLegacySafeDto(account)]; + } + + return []; + } + + async getSafeAccountById(safeAccountId: number): Promise { + const safeAccount = await this.safeAccountRepo.findOne({ + where: { id: safeAccountId }, + relations: ['owner', 'accessGrants', 'accessGrants.userData'], + }); + + if (!safeAccount) throw new NotFoundException('SafeAccount not found'); + + return safeAccount; + } + + // --- ACCESS CHECK --- // + async checkAccess( + safeAccountId: number | null, + accountId: number, + requiredLevel: SafeAccessLevel, + ): Promise<{ safeAccount: SafeAccount | null; isLegacy: boolean }> { + // Legacy mode + if (safeAccountId === null) { + return { safeAccount: null, isLegacy: true }; + } + + const safeAccount = await this.getSafeAccountById(safeAccountId); + + // Owner has WRITE access + if (safeAccount.owner.id === accountId) { + return { safeAccount, isLegacy: false }; + } + + // Check access grants + const access = safeAccount.accessGrants.find((a) => a.userData.id === accountId); + if (!access) { + throw new ForbiddenException('No access to this SafeAccount'); + } + + // Check if access level is sufficient + if (requiredLevel === SafeAccessLevel.WRITE && access.accessLevel === SafeAccessLevel.READ) { + throw new ForbiddenException('Write access required'); + } + + return { safeAccount, isLegacy: false }; + } + + // --- CREATE --- // + async createSafeAccount( + accountId: number, + title: string, + description?: string, + ): Promise { + const owner = await this.userDataService.getUserData(accountId); + if (!owner) throw new NotFoundException('User not found'); + + const safeAccount = this.safeAccountRepo.create({ + title, + description, + owner, + status: SafeAccountStatus.ACTIVE, + requiredSignatures: 1, + }); + + const saved = await this.safeAccountRepo.save(safeAccount); + + // Create WRITE access for owner + const ownerAccess = this.safeAccountAccessRepo.create({ + safeAccount: saved, + userData: owner, + accessLevel: SafeAccessLevel.WRITE, + }); + await this.safeAccountAccessRepo.save(ownerAccess); + + return saved; + } + + // --- UPDATE --- // + async updateSafeAccount( + safeAccountId: number, + accountId: number, + title?: string, + description?: string, + ): Promise { + const { safeAccount } = await this.checkAccess(safeAccountId, accountId, SafeAccessLevel.WRITE); + if (!safeAccount) throw new ForbiddenException('Cannot update legacy safe'); + + if (title) safeAccount.title = title; + if (description !== undefined) safeAccount.description = description; + + return this.safeAccountRepo.save(safeAccount); + } + + // --- HELPERS --- // + private mapToDto(safeAccount: SafeAccount, accessLevel: SafeAccessLevel, isLegacy: boolean): SafeAccountDto { + return { + id: safeAccount.id, + title: safeAccount.title, + description: safeAccount.description, + isLegacy, + accessLevel, + owner: safeAccount.owner ? { id: safeAccount.owner.id } : undefined, + }; + } + + private createLegacySafeDto(userData: UserData): SafeAccountDto { + return { + id: null, + title: 'Safe', // Default title for legacy + description: undefined, + isLegacy: true, + accessLevel: SafeAccessLevel.WRITE, // Owner has full access + owner: { id: userData.id }, + }; + } + + // --- GET ACCESS LIST --- // + async getAccessList(safeAccountId: number, accountId: number): Promise { + await this.checkAccess(safeAccountId, accountId, SafeAccessLevel.READ); + + return this.safeAccountAccessRepo.find({ + where: { safeAccount: { id: safeAccountId } }, + relations: ['userData'], + }); + } +} diff --git a/src/subdomains/generic/user/models/user/user.entity.ts b/src/subdomains/generic/user/models/user/user.entity.ts index 8ddea97a29..ebf4b16e38 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 { SafeAccount } from 'src/subdomains/core/custody/entities/safe-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'; @@ -145,6 +146,9 @@ export class User extends IEntity { @Column({ nullable: true }) custodyAddressType: CustodyAddressType; + @ManyToOne(() => SafeAccount, { nullable: true }) + safeAccount?: SafeAccount; + @OneToMany(() => CustodyOrder, (custodyOrder) => custodyOrder.user) custodyOrders: CustodyOrder[]; From 244f6a891079eeacaa73a0bfeb1efffa0343dc9c Mon Sep 17 00:00:00 2001 From: David May Date: Tue, 13 Jan 2026 22:09:48 +0100 Subject: [PATCH 2/7] feat: renaming --- .../1765283294000-AddSafeAccountTables.js | 143 ------------- .../1768338479982-AddCustodyAccountTables.js | 52 +++++ .../controllers/custody-account.controller.ts | 98 +++++++++ .../custody/controllers/custody.controller.ts | 4 +- .../controllers/safe-account.controller.ts | 113 ----------- src/subdomains/core/custody/custody.module.ts | 30 +-- .../dto/input/create-custody-account.dto.ts | 30 +-- .../dto/input/create-safe-account.dto.ts | 14 -- .../custody/dto/input/custody-signup.dto.ts | 32 +++ ...t.dto.ts => update-custody-account.dto.ts} | 6 +- .../custody/dto/output/custody-account.dto.ts | 38 ++++ .../custody/dto/output/safe-account.dto.ts | 38 ---- .../entities/custody-account-access.entity.ts | 18 ++ ...nt.entity.ts => custody-account.entity.ts} | 17 +- .../entities/custody-balance.entity.ts | 6 +- .../custody/entities/custody-order.entity.ts | 6 +- .../entities/safe-account-access.entity.ts | 18 -- src/subdomains/core/custody/enums/custody.ts | 6 +- ...ard.ts => custody-account-access.guard.ts} | 28 +-- ...s => custody-account-access.repository.ts} | 6 +- ...itory.ts => custody-account.repository.ts} | 6 +- .../services/custody-account.service.ts | 189 +++++++++++++++++ .../core/custody/services/custody.service.ts | 4 +- .../custody/services/safe-account.service.ts | 191 ------------------ .../generic/user/models/user/user.entity.ts | 6 +- 25 files changed, 494 insertions(+), 605 deletions(-) delete mode 100644 migration/1765283294000-AddSafeAccountTables.js create mode 100644 migration/1768338479982-AddCustodyAccountTables.js create mode 100644 src/subdomains/core/custody/controllers/custody-account.controller.ts delete mode 100644 src/subdomains/core/custody/controllers/safe-account.controller.ts delete mode 100644 src/subdomains/core/custody/dto/input/create-safe-account.dto.ts create mode 100644 src/subdomains/core/custody/dto/input/custody-signup.dto.ts rename src/subdomains/core/custody/dto/input/{update-safe-account.dto.ts => update-custody-account.dto.ts} (56%) create mode 100644 src/subdomains/core/custody/dto/output/custody-account.dto.ts delete mode 100644 src/subdomains/core/custody/dto/output/safe-account.dto.ts create mode 100644 src/subdomains/core/custody/entities/custody-account-access.entity.ts rename src/subdomains/core/custody/entities/{safe-account.entity.ts => custody-account.entity.ts} (50%) delete mode 100644 src/subdomains/core/custody/entities/safe-account-access.entity.ts rename src/subdomains/core/custody/guards/{safe-account-access.guard.ts => custody-account-access.guard.ts} (53%) rename src/subdomains/core/custody/repositories/{safe-account-access.repository.ts => custody-account-access.repository.ts} (50%) rename src/subdomains/core/custody/repositories/{safe-account.repository.ts => custody-account.repository.ts} (54%) create mode 100644 src/subdomains/core/custody/services/custody-account.service.ts delete mode 100644 src/subdomains/core/custody/services/safe-account.service.ts diff --git a/migration/1765283294000-AddSafeAccountTables.js b/migration/1765283294000-AddSafeAccountTables.js deleted file mode 100644 index b40753997a..0000000000 --- a/migration/1765283294000-AddSafeAccountTables.js +++ /dev/null @@ -1,143 +0,0 @@ -/** - * @typedef {import('typeorm').MigrationInterface} MigrationInterface - */ - -/** - * @class - * @implements {MigrationInterface} - */ -module.exports = class AddSafeAccountTables1765283294000 { - name = 'AddSafeAccountTables1765283294000' - - async up(queryRunner) { - // Create safe_account table - await queryRunner.query(` - CREATE TABLE "safe_account" ( - "id" int NOT NULL IDENTITY(1,1), - "updated" datetime2 NOT NULL CONSTRAINT "DF_safe_account_updated" DEFAULT getdate(), - "created" datetime2 NOT NULL CONSTRAINT "DF_safe_account_created" DEFAULT getdate(), - "title" nvarchar(256) NOT NULL, - "description" nvarchar(MAX), - "requiredSignatures" int NOT NULL CONSTRAINT "DF_safe_account_requiredSignatures" DEFAULT 1, - "status" nvarchar(256) NOT NULL CONSTRAINT "DF_safe_account_status" DEFAULT 'Active', - "ownerId" int NOT NULL, - CONSTRAINT "PK_safe_account" PRIMARY KEY ("id") - ) - `); - - // Create safe_account_access table - await queryRunner.query(` - CREATE TABLE "safe_account_access" ( - "id" int NOT NULL IDENTITY(1,1), - "updated" datetime2 NOT NULL CONSTRAINT "DF_safe_account_access_updated" DEFAULT getdate(), - "created" datetime2 NOT NULL CONSTRAINT "DF_safe_account_access_created" DEFAULT getdate(), - "accessLevel" nvarchar(256) NOT NULL, - "safeAccountId" int NOT NULL, - "userDataId" int NOT NULL, - CONSTRAINT "PK_safe_account_access" PRIMARY KEY ("id") - ) - `); - - // Create unique index on safe_account_access - await queryRunner.query(` - CREATE UNIQUE INDEX "IDX_safe_account_access_unique" - ON "safe_account_access" ("safeAccountId", "userDataId") - `); - - // Add foreign keys for safe_account - await queryRunner.query(` - ALTER TABLE "safe_account" - ADD CONSTRAINT "FK_safe_account_owner" - FOREIGN KEY ("ownerId") REFERENCES "user_data"("id") - ON DELETE NO ACTION ON UPDATE NO ACTION - `); - - // Add foreign keys for safe_account_access - await queryRunner.query(` - ALTER TABLE "safe_account_access" - ADD CONSTRAINT "FK_safe_account_access_safeAccount" - FOREIGN KEY ("safeAccountId") REFERENCES "safe_account"("id") - ON DELETE NO ACTION ON UPDATE NO ACTION - `); - - await queryRunner.query(` - ALTER TABLE "safe_account_access" - ADD CONSTRAINT "FK_safe_account_access_userData" - FOREIGN KEY ("userDataId") REFERENCES "user_data"("id") - ON DELETE NO ACTION ON UPDATE NO ACTION - `); - - // Add safeAccountId to user table (nullable for legacy compatibility) - await queryRunner.query(` - ALTER TABLE "user" ADD "safeAccountId" int - `); - - await queryRunner.query(` - ALTER TABLE "user" - ADD CONSTRAINT "FK_user_safeAccount" - FOREIGN KEY ("safeAccountId") REFERENCES "safe_account"("id") - ON DELETE NO ACTION ON UPDATE NO ACTION - `); - - // Add safeAccountId to custody_balance table (nullable for legacy compatibility) - await queryRunner.query(` - ALTER TABLE "custody_balance" ADD "safeAccountId" int - `); - - await queryRunner.query(` - ALTER TABLE "custody_balance" - ADD CONSTRAINT "FK_custody_balance_safeAccount" - FOREIGN KEY ("safeAccountId") REFERENCES "safe_account"("id") - ON DELETE NO ACTION ON UPDATE NO ACTION - `); - - // Add safeAccountId and initiatedById to custody_order table (nullable for legacy compatibility) - await queryRunner.query(` - ALTER TABLE "custody_order" ADD "safeAccountId" int - `); - - await queryRunner.query(` - ALTER TABLE "custody_order" ADD "initiatedById" int - `); - - await queryRunner.query(` - ALTER TABLE "custody_order" - ADD CONSTRAINT "FK_custody_order_safeAccount" - FOREIGN KEY ("safeAccountId") REFERENCES "safe_account"("id") - ON DELETE NO ACTION ON UPDATE NO ACTION - `); - - await queryRunner.query(` - ALTER TABLE "custody_order" - ADD CONSTRAINT "FK_custody_order_initiatedBy" - FOREIGN KEY ("initiatedById") REFERENCES "user_data"("id") - ON DELETE NO ACTION ON UPDATE NO ACTION - `); - } - - async down(queryRunner) { - // Remove foreign keys from custody_order - await queryRunner.query(`ALTER TABLE "custody_order" DROP CONSTRAINT "FK_custody_order_initiatedBy"`); - await queryRunner.query(`ALTER TABLE "custody_order" DROP CONSTRAINT "FK_custody_order_safeAccount"`); - await queryRunner.query(`ALTER TABLE "custody_order" DROP COLUMN "initiatedById"`); - await queryRunner.query(`ALTER TABLE "custody_order" DROP COLUMN "safeAccountId"`); - - // Remove foreign key and column from custody_balance - await queryRunner.query(`ALTER TABLE "custody_balance" DROP CONSTRAINT "FK_custody_balance_safeAccount"`); - await queryRunner.query(`ALTER TABLE "custody_balance" DROP COLUMN "safeAccountId"`); - - // Remove foreign key and column from user - await queryRunner.query(`ALTER TABLE "user" DROP CONSTRAINT "FK_user_safeAccount"`); - await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "safeAccountId"`); - - // Drop safe_account_access table - await queryRunner.query(`ALTER TABLE "safe_account_access" DROP CONSTRAINT "FK_safe_account_access_userData"`); - await queryRunner.query(`ALTER TABLE "safe_account_access" DROP CONSTRAINT "FK_safe_account_access_safeAccount"`); - await queryRunner.query(`DROP INDEX "IDX_safe_account_access_unique" ON "safe_account_access"`); - await queryRunner.query(`DROP TABLE "safe_account_access"`); - - // Drop safe_account table - await queryRunner.query(`ALTER TABLE "safe_account" DROP CONSTRAINT "FK_safe_account_owner"`); - await queryRunner.query(`DROP TABLE "safe_account"`); - } -} diff --git a/migration/1768338479982-AddCustodyAccountTables.js b/migration/1768338479982-AddCustodyAccountTables.js new file mode 100644 index 0000000000..e8c62c75df --- /dev/null +++ b/migration/1768338479982-AddCustodyAccountTables.js @@ -0,0 +1,52 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * @class + * @implements {MigrationInterface} + */ +module.exports = class AddCustodyAccountTables1768338479982 { + name = 'AddCustodyAccountTables1768338479982' + + /** + * @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, "custodyAccountId" int NOT NULL, "userDataId" int NOT NULL, CONSTRAINT "PK_1657b7fd1d5a0ee0b01f657e508" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_8a2ec36ebff5b786fd4d7e0142" ON "custody_account_access" ("custodyAccountId", "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 "custodyAccountId" int`); + await queryRunner.query(`ALTER TABLE "custody_order" ADD "initiatedById" int`); + await queryRunner.query(`ALTER TABLE "custody_balance" ADD "custodyAccountId" int`); + await queryRunner.query(`ALTER TABLE "user" ADD "custodyAccountId" int`); + await queryRunner.query(`ALTER TABLE "custody_account_access" ADD CONSTRAINT "FK_c678151aaee25061ce6ce5ba2f5" FOREIGN KEY ("custodyAccountId") 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_e6a0e5cbc91e9bdca945101c67f" FOREIGN KEY ("custodyAccountId") 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_9cd8ac552741c57822426335f70" FOREIGN KEY ("custodyAccountId") 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_9cd8ac552741c57822426335f70"`); + await queryRunner.query(`ALTER TABLE "custody_order" DROP CONSTRAINT "FK_67425e623d89efe4ae1a48dbad6"`); + await queryRunner.query(`ALTER TABLE "custody_order" DROP CONSTRAINT "FK_e6a0e5cbc91e9bdca945101c67f"`); + 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_c678151aaee25061ce6ce5ba2f5"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "custodyAccountId"`); + await queryRunner.query(`ALTER TABLE "custody_balance" DROP COLUMN "custodyAccountId"`); + await queryRunner.query(`ALTER TABLE "custody_order" DROP COLUMN "initiatedById"`); + await queryRunner.query(`ALTER TABLE "custody_order" DROP COLUMN "custodyAccountId"`); + await queryRunner.query(`DROP TABLE "custody_account"`); + await queryRunner.query(`DROP INDEX "IDX_8a2ec36ebff5b786fd4d7e0142" 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..090f56e2e1 --- /dev/null +++ b/src/subdomains/core/custody/controllers/custody-account.controller.ts @@ -0,0 +1,98 @@ +import { Body, Controller, Get, 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 { CustodyAccountReadGuard, CustodyAccountWriteGuard } from '../guards/custody-account-access.guard'; +import { CustodyAccountService } 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 CustodyAccounts 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: 'CustodyAccount details' }) + async getCustodyAccount(@GetJwt() jwt: JwtPayload, @Param('id') id: string): Promise { + const custodyAccountId = id === 'legacy' ? null : +id; + const custodyAccounts = await this.custodyAccountService.getCustodyAccountsForUser(jwt.account); + + if (custodyAccountId === null) { + const legacy = custodyAccounts.find((ca) => ca.isLegacy); + if (!legacy) throw new Error('No legacy custody account found'); + return legacy; + } + + const custodyAccount = custodyAccounts.find((ca) => ca.id === custodyAccountId); + if (!custodyAccount) throw new Error('CustodyAccount not found'); + return custodyAccount; + } + + @Post() + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.ACCOUNT), UserActiveGuard()) + @ApiCreatedResponse({ type: CustodyAccountDto, description: 'Create a new CustodyAccount' }) + async createCustodyAccount(@GetJwt() jwt: JwtPayload, @Body() dto: CreateCustodyAccountDto): Promise { + const custodyAccount = await this.custodyAccountService.createCustodyAccount(jwt.account, dto.title, dto.description); + + return { + id: custodyAccount.id, + title: custodyAccount.title, + description: custodyAccount.description, + isLegacy: false, + accessLevel: 'Write' as any, + owner: { id: jwt.account }, + }; + } + + @Put(':id') + @ApiBearerAuth() + @UseGuards(AuthGuard(), RoleGuard(UserRole.ACCOUNT), UserActiveGuard(), CustodyAccountWriteGuard) + @ApiOkResponse({ type: CustodyAccountDto, description: 'Update CustodyAccount' }) + 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 { + id: custodyAccount.id, + title: custodyAccount.title, + description: custodyAccount.description, + isLegacy: false, + accessLevel: 'Write' as any, + owner: custodyAccount.owner ? { id: custodyAccount.owner.id } : undefined, + }; + } + + @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, + userDataId: 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/controllers/safe-account.controller.ts b/src/subdomains/core/custody/controllers/safe-account.controller.ts deleted file mode 100644 index 36acf45cdf..0000000000 --- a/src/subdomains/core/custody/controllers/safe-account.controller.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { Body, Controller, Get, 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 { CreateSafeAccountDto } from '../dto/input/create-safe-account.dto'; -import { UpdateSafeAccountDto } from '../dto/input/update-safe-account.dto'; -import { SafeAccountAccessDto, SafeAccountDto } from '../dto/output/safe-account.dto'; -import { SafeAccountReadGuard, SafeAccountWriteGuard } from '../guards/safe-account-access.guard'; -import { SafeAccountService } from '../services/safe-account.service'; - -@ApiTags('SafeAccount') -@Controller('safe-account') -export class SafeAccountController { - constructor(private readonly safeAccountService: SafeAccountService) {} - - @Get() - @ApiBearerAuth() - @UseGuards(AuthGuard(), RoleGuard(UserRole.ACCOUNT), UserActiveGuard()) - @ApiOkResponse({ type: [SafeAccountDto], description: 'List of SafeAccounts for the user' }) - async getSafeAccounts(@GetJwt() jwt: JwtPayload): Promise { - return this.safeAccountService.getSafeAccountsForUser(jwt.account); - } - - @Get(':id') - @ApiBearerAuth() - @UseGuards(AuthGuard(), RoleGuard(UserRole.ACCOUNT), UserActiveGuard(), SafeAccountReadGuard) - @ApiOkResponse({ type: SafeAccountDto, description: 'SafeAccount details' }) - async getSafeAccount(@GetJwt() jwt: JwtPayload, @Param('id') id: string): Promise { - const safeAccountId = id === 'legacy' ? null : +id; - const safeAccounts = await this.safeAccountService.getSafeAccountsForUser(jwt.account); - - if (safeAccountId === null) { - const legacy = safeAccounts.find((sa) => sa.isLegacy); - if (!legacy) throw new Error('No legacy safe account found'); - return legacy; - } - - const safeAccount = safeAccounts.find((sa) => sa.id === safeAccountId); - if (!safeAccount) throw new Error('SafeAccount not found'); - return safeAccount; - } - - @Post() - @ApiBearerAuth() - @UseGuards(AuthGuard(), RoleGuard(UserRole.ACCOUNT), UserActiveGuard()) - @ApiCreatedResponse({ type: SafeAccountDto, description: 'Create a new SafeAccount' }) - async createSafeAccount( - @GetJwt() jwt: JwtPayload, - @Body() dto: CreateSafeAccountDto, - ): Promise { - const safeAccount = await this.safeAccountService.createSafeAccount( - jwt.account, - dto.title, - dto.description, - ); - - return { - id: safeAccount.id, - title: safeAccount.title, - description: safeAccount.description, - isLegacy: false, - accessLevel: 'Write' as any, - owner: { id: jwt.account }, - }; - } - - @Put(':id') - @ApiBearerAuth() - @UseGuards(AuthGuard(), RoleGuard(UserRole.ACCOUNT), UserActiveGuard(), SafeAccountWriteGuard) - @ApiOkResponse({ type: SafeAccountDto, description: 'Update SafeAccount' }) - async updateSafeAccount( - @GetJwt() jwt: JwtPayload, - @Param('id') id: string, - @Body() dto: UpdateSafeAccountDto, - ): Promise { - const safeAccount = await this.safeAccountService.updateSafeAccount( - +id, - jwt.account, - dto.title, - dto.description, - ); - - return { - id: safeAccount.id, - title: safeAccount.title, - description: safeAccount.description, - isLegacy: false, - accessLevel: 'Write' as any, - owner: safeAccount.owner ? { id: safeAccount.owner.id } : undefined, - }; - } - - @Get(':id/access') - @ApiBearerAuth() - @UseGuards(AuthGuard(), RoleGuard(UserRole.ACCOUNT), UserActiveGuard(), SafeAccountReadGuard) - @ApiOkResponse({ type: [SafeAccountAccessDto], description: 'List of users with access' }) - async getAccessList( - @GetJwt() jwt: JwtPayload, - @Param('id') id: string, - ): Promise { - const accessList = await this.safeAccountService.getAccessList(+id, jwt.account); - - return accessList.map((access) => ({ - id: access.id, - userDataId: access.userData.id, - accessLevel: access.accessLevel, - })); - } -} diff --git a/src/subdomains/core/custody/custody.module.ts b/src/subdomains/core/custody/custody.module.ts index a98a2e997f..0c60b05e0d 100644 --- a/src/subdomains/core/custody/custody.module.ts +++ b/src/subdomains/core/custody/custody.module.ts @@ -10,27 +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 { SafeAccountController } from './controllers/safe-account.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 { SafeAccountAccess } from './entities/safe-account-access.entity'; -import { SafeAccount } from './entities/safe-account.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 { SafeAccountAccessRepository } from './repositories/safe-account-access.repository'; -import { SafeAccountRepository } from './repositories/safe-account.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 { SafeAccountService } from './services/safe-account.service'; -import { SafeAccountReadGuard, SafeAccountWriteGuard } from './guards/safe-account-access.guard'; +import { CustodyAccountService } from './services/custody-account.service'; +import { CustodyAccountReadGuard, CustodyAccountWriteGuard } from './guards/custody-account-access.guard'; @Module({ imports: [ - TypeOrmModule.forFeature([CustodyOrder, CustodyOrderStep, SafeAccount, SafeAccountAccess]), + TypeOrmModule.forFeature([CustodyOrder, CustodyOrderStep, CustodyAccount, CustodyAccountAccess]), forwardRef(() => UserModule), forwardRef(() => ReferralModule), SharedModule, @@ -40,7 +40,7 @@ import { SafeAccountReadGuard, SafeAccountWriteGuard } from './guards/safe-accou PricingModule, PayoutModule, ], - controllers: [CustodyController, CustodyAdminController, SafeAccountController], + controllers: [CustodyController, CustodyAdminController, CustodyAccountController], providers: [ CustodyService, CustodyOrderRepository, @@ -51,12 +51,12 @@ import { SafeAccountReadGuard, SafeAccountWriteGuard } from './guards/safe-accou CustodyPdfService, CustodyBalance, CustodyBalanceRepository, - SafeAccountRepository, - SafeAccountAccessRepository, - SafeAccountService, - SafeAccountReadGuard, - SafeAccountWriteGuard, + CustodyAccountRepository, + CustodyAccountAccessRepository, + CustodyAccountService, + CustodyAccountReadGuard, + CustodyAccountWriteGuard, ], - exports: [CustodyService, CustodyOrderService, SafeAccountService], + 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..783eec09bf 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 CustodyAccount' }) @IsString() - wallet?: string; + @MaxLength(256) + title: string; - @ApiPropertyOptional() + @ApiPropertyOptional({ description: 'Description of the CustodyAccount' }) @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/create-safe-account.dto.ts b/src/subdomains/core/custody/dto/input/create-safe-account.dto.ts deleted file mode 100644 index 80531f2ba4..0000000000 --- a/src/subdomains/core/custody/dto/input/create-safe-account.dto.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsOptional, IsString, MaxLength } from 'class-validator'; - -export class CreateSafeAccountDto { - @ApiProperty({ description: 'Title of the SafeAccount' }) - @IsString() - @MaxLength(256) - title: string; - - @ApiPropertyOptional({ description: 'Description of the SafeAccount' }) - @IsOptional() - @IsString() - 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-safe-account.dto.ts b/src/subdomains/core/custody/dto/input/update-custody-account.dto.ts similarity index 56% rename from src/subdomains/core/custody/dto/input/update-safe-account.dto.ts rename to src/subdomains/core/custody/dto/input/update-custody-account.dto.ts index 22e2ec5597..8ef4124f89 100644 --- a/src/subdomains/core/custody/dto/input/update-safe-account.dto.ts +++ b/src/subdomains/core/custody/dto/input/update-custody-account.dto.ts @@ -1,14 +1,14 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { IsOptional, IsString, MaxLength } from 'class-validator'; -export class UpdateSafeAccountDto { - @ApiPropertyOptional({ description: 'Title of the SafeAccount' }) +export class UpdateCustodyAccountDto { + @ApiPropertyOptional({ description: 'Title of the CustodyAccount' }) @IsOptional() @IsString() @MaxLength(256) title?: string; - @ApiPropertyOptional({ description: 'Description of the SafeAccount' }) + @ApiPropertyOptional({ description: 'Description of the CustodyAccount' }) @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..7c62cc14df --- /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 CustodyAccountOwnerDto { + @ApiProperty() + id: number; +} + +export class CustodyAccountDto { + @ApiPropertyOptional({ description: 'ID of the CustodyAccount (null for legacy)' }) + id: number | null; + + @ApiProperty({ description: 'Title of the CustodyAccount' }) + title: string; + + @ApiPropertyOptional({ description: 'Description of the CustodyAccount' }) + 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: CustodyAccountOwnerDto }) + owner?: CustodyAccountOwnerDto; +} + +export class CustodyAccountAccessDto { + @ApiProperty() + id: number; + + @ApiProperty() + userDataId: number; + + @ApiProperty({ enum: CustodyAccessLevel }) + accessLevel: CustodyAccessLevel; +} diff --git a/src/subdomains/core/custody/dto/output/safe-account.dto.ts b/src/subdomains/core/custody/dto/output/safe-account.dto.ts deleted file mode 100644 index aad0767c56..0000000000 --- a/src/subdomains/core/custody/dto/output/safe-account.dto.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { SafeAccessLevel } from '../../enums/custody'; - -export class SafeAccountOwnerDto { - @ApiProperty() - id: number; -} - -export class SafeAccountDto { - @ApiPropertyOptional({ description: 'ID of the SafeAccount (null for legacy)' }) - id: number | null; - - @ApiProperty({ description: 'Title of the SafeAccount' }) - title: string; - - @ApiPropertyOptional({ description: 'Description of the SafeAccount' }) - description?: string; - - @ApiProperty({ description: 'Whether this is a legacy account (aggregated custody users)' }) - isLegacy: boolean; - - @ApiProperty({ enum: SafeAccessLevel, description: 'Access level for current user' }) - accessLevel: SafeAccessLevel; - - @ApiPropertyOptional({ type: SafeAccountOwnerDto }) - owner?: SafeAccountOwnerDto; -} - -export class SafeAccountAccessDto { - @ApiProperty() - id: number; - - @ApiProperty() - userDataId: number; - - @ApiProperty({ enum: SafeAccessLevel }) - accessLevel: SafeAccessLevel; -} 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..c61098ae5a --- /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.custodyAccount, a.userData], { unique: true }) +export class CustodyAccountAccess extends IEntity { + @ManyToOne(() => CustodyAccount, (custodyAccount) => custodyAccount.accessGrants, { nullable: false }) + custodyAccount: CustodyAccount; + + @ManyToOne(() => UserData, { nullable: false }) + userData: UserData; + + @Column() + accessLevel: CustodyAccessLevel; +} diff --git a/src/subdomains/core/custody/entities/safe-account.entity.ts b/src/subdomains/core/custody/entities/custody-account.entity.ts similarity index 50% rename from src/subdomains/core/custody/entities/safe-account.entity.ts rename to src/subdomains/core/custody/entities/custody-account.entity.ts index 125e39a5b1..80c2837353 100644 --- a/src/subdomains/core/custody/entities/safe-account.entity.ts +++ b/src/subdomains/core/custody/entities/custody-account.entity.ts @@ -1,11 +1,11 @@ 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 { SafeAccountStatus } from '../enums/custody'; -import { SafeAccountAccess } from './safe-account-access.entity'; +import { CustodyAccountStatus } from '../enums/custody'; +import { CustodyAccountAccess } from './custody-account-access.entity'; @Entity() -export class SafeAccount extends IEntity { +export class CustodyAccount extends IEntity { @Column({ length: 256 }) title: string; @@ -18,12 +18,9 @@ export class SafeAccount extends IEntity { @Column({ type: 'int', default: 1 }) requiredSignatures: number; - @Column({ default: SafeAccountStatus.ACTIVE }) - status: SafeAccountStatus; + @Column({ default: CustodyAccountStatus.ACTIVE }) + status: CustodyAccountStatus; - @OneToMany(() => SafeAccountAccess, (access) => access.safeAccount) - accessGrants: SafeAccountAccess[]; - - // Relationships to User, CustodyBalance, CustodyOrder will be added - // when those entities are updated + @OneToMany(() => CustodyAccountAccess, (access) => access.custodyAccount) + 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 2f9435d905..394e731a76 100644 --- a/src/subdomains/core/custody/entities/custody-balance.entity.ts +++ b/src/subdomains/core/custody/entities/custody-balance.entity.ts @@ -2,7 +2,7 @@ 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 { SafeAccount } from './safe-account.entity'; +import { CustodyAccount } from './custody-account.entity'; @Entity() @Index((custodyBalance: CustodyBalance) => [custodyBalance.user, custodyBalance.asset], { unique: true }) @@ -16,6 +16,6 @@ export class CustodyBalance extends IEntity { @ManyToOne(() => Asset, { nullable: false, eager: true }) asset: Asset; - @ManyToOne(() => SafeAccount, { nullable: true }) - safeAccount?: SafeAccount; + @ManyToOne(() => CustodyAccount, { nullable: true }) + custodyAccount?: CustodyAccount; } diff --git a/src/subdomains/core/custody/entities/custody-order.entity.ts b/src/subdomains/core/custody/entities/custody-order.entity.ts index 12e4f802a5..34aea4398f 100644 --- a/src/subdomains/core/custody/entities/custody-order.entity.ts +++ b/src/subdomains/core/custody/entities/custody-order.entity.ts @@ -11,7 +11,7 @@ import { Swap } from '../../buy-crypto/routes/swap/swap.entity'; import { Sell } from '../../sell-crypto/route/sell.entity'; import { CustodyOrderStatus, CustodyOrderType } from '../enums/custody'; import { CustodyOrderStep } from './custody-order-step.entity'; -import { SafeAccount } from './safe-account.entity'; +import { CustodyAccount } from './custody-account.entity'; @Entity() export class CustodyOrder extends IEntity { @@ -39,8 +39,8 @@ export class CustodyOrder extends IEntity { @ManyToOne(() => User, (user) => user.custodyOrders, { nullable: false }) user: User; - @ManyToOne(() => SafeAccount, { nullable: true }) - safeAccount?: SafeAccount; + @ManyToOne(() => CustodyAccount, { nullable: true }) + custodyAccount?: CustodyAccount; @ManyToOne(() => UserData, { nullable: true }) initiatedBy?: UserData; diff --git a/src/subdomains/core/custody/entities/safe-account-access.entity.ts b/src/subdomains/core/custody/entities/safe-account-access.entity.ts deleted file mode 100644 index a0eda44be4..0000000000 --- a/src/subdomains/core/custody/entities/safe-account-access.entity.ts +++ /dev/null @@ -1,18 +0,0 @@ -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 { SafeAccessLevel } from '../enums/custody'; -import { SafeAccount } from './safe-account.entity'; - -@Entity() -@Index(['safeAccount', 'userData'], { unique: true }) -export class SafeAccountAccess extends IEntity { - @ManyToOne(() => SafeAccount, (safeAccount) => safeAccount.accessGrants, { nullable: false }) - safeAccount: SafeAccount; - - @ManyToOne(() => UserData, { nullable: false }) - userData: UserData; - - @Column() - accessLevel: SafeAccessLevel; -} diff --git a/src/subdomains/core/custody/enums/custody.ts b/src/subdomains/core/custody/enums/custody.ts index 378bd078b7..d0ed4546b6 100644 --- a/src/subdomains/core/custody/enums/custody.ts +++ b/src/subdomains/core/custody/enums/custody.ts @@ -39,14 +39,14 @@ export enum CustodyOrderStepCommand { SEND_TO_ROUTE = 'SendToRoute', } -// SafeAccount Enums -export enum SafeAccountStatus { +// CustodyAccount Enums +export enum CustodyAccountStatus { ACTIVE = 'Active', BLOCKED = 'Blocked', CLOSED = 'Closed', } -export enum SafeAccessLevel { +export enum CustodyAccessLevel { READ = 'Read', WRITE = 'Write', } diff --git a/src/subdomains/core/custody/guards/safe-account-access.guard.ts b/src/subdomains/core/custody/guards/custody-account-access.guard.ts similarity index 53% rename from src/subdomains/core/custody/guards/safe-account-access.guard.ts rename to src/subdomains/core/custody/guards/custody-account-access.guard.ts index 5e63957a47..95685a8694 100644 --- a/src/subdomains/core/custody/guards/safe-account-access.guard.ts +++ b/src/subdomains/core/custody/guards/custody-account-access.guard.ts @@ -1,30 +1,30 @@ import { CanActivate, ExecutionContext, Injectable, ForbiddenException } from '@nestjs/common'; -import { SafeAccessLevel } from '../enums/custody'; -import { SafeAccountService } from '../services/safe-account.service'; +import { CustodyAccessLevel } from '../enums/custody'; +import { CustodyAccountService } from '../services/custody-account.service'; @Injectable() -export class SafeAccountReadGuard implements CanActivate { - constructor(private readonly safeAccountService: SafeAccountService) {} +export class CustodyAccountReadGuard implements CanActivate { + constructor(private readonly custodyAccountService: CustodyAccountService) {} async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); const accountId = request.user?.account; - const safeAccountId = this.getSafeAccountId(request); + const custodyAccountId = this.getCustodyAccountId(request); if (!accountId) { throw new ForbiddenException('User not authenticated'); } try { - await this.safeAccountService.checkAccess(safeAccountId, accountId, SafeAccessLevel.READ); + await this.custodyAccountService.checkAccess(custodyAccountId, accountId, CustodyAccessLevel.READ); return true; } catch (error) { throw new ForbiddenException(error.message || 'Access denied'); } } - private getSafeAccountId(request: any): number | null { - const id = request.params?.safeAccountId || request.params?.id; + private getCustodyAccountId(request: any): number | null { + const id = request.params?.custodyAccountId || request.params?.id; if (id === 'legacy' || id === null || id === undefined) { return null; } @@ -33,28 +33,28 @@ export class SafeAccountReadGuard implements CanActivate { } @Injectable() -export class SafeAccountWriteGuard implements CanActivate { - constructor(private readonly safeAccountService: SafeAccountService) {} +export class CustodyAccountWriteGuard implements CanActivate { + constructor(private readonly custodyAccountService: CustodyAccountService) {} async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); const accountId = request.user?.account; - const safeAccountId = this.getSafeAccountId(request); + const custodyAccountId = this.getCustodyAccountId(request); if (!accountId) { throw new ForbiddenException('User not authenticated'); } try { - await this.safeAccountService.checkAccess(safeAccountId, accountId, SafeAccessLevel.WRITE); + await this.custodyAccountService.checkAccess(custodyAccountId, accountId, CustodyAccessLevel.WRITE); return true; } catch (error) { throw new ForbiddenException(error.message || 'Access denied'); } } - private getSafeAccountId(request: any): number | null { - const id = request.params?.safeAccountId || request.params?.id; + private getCustodyAccountId(request: any): number | null { + const id = request.params?.custodyAccountId || request.params?.id; if (id === 'legacy' || id === null || id === undefined) { return null; } diff --git a/src/subdomains/core/custody/repositories/safe-account-access.repository.ts b/src/subdomains/core/custody/repositories/custody-account-access.repository.ts similarity index 50% rename from src/subdomains/core/custody/repositories/safe-account-access.repository.ts rename to src/subdomains/core/custody/repositories/custody-account-access.repository.ts index 0ba256ea52..0019430f0b 100644 --- a/src/subdomains/core/custody/repositories/safe-account-access.repository.ts +++ b/src/subdomains/core/custody/repositories/custody-account-access.repository.ts @@ -1,11 +1,11 @@ import { Injectable } from '@nestjs/common'; import { BaseRepository } from 'src/shared/repositories/base.repository'; import { EntityManager } from 'typeorm'; -import { SafeAccountAccess } from '../entities/safe-account-access.entity'; +import { CustodyAccountAccess } from '../entities/custody-account-access.entity'; @Injectable() -export class SafeAccountAccessRepository extends BaseRepository { +export class CustodyAccountAccessRepository extends BaseRepository { constructor(manager: EntityManager) { - super(SafeAccountAccess, manager); + super(CustodyAccountAccess, manager); } } diff --git a/src/subdomains/core/custody/repositories/safe-account.repository.ts b/src/subdomains/core/custody/repositories/custody-account.repository.ts similarity index 54% rename from src/subdomains/core/custody/repositories/safe-account.repository.ts rename to src/subdomains/core/custody/repositories/custody-account.repository.ts index 4d6cd708d2..7497dfdf6a 100644 --- a/src/subdomains/core/custody/repositories/safe-account.repository.ts +++ b/src/subdomains/core/custody/repositories/custody-account.repository.ts @@ -1,11 +1,11 @@ import { Injectable } from '@nestjs/common'; import { BaseRepository } from 'src/shared/repositories/base.repository'; import { EntityManager } from 'typeorm'; -import { SafeAccount } from '../entities/safe-account.entity'; +import { CustodyAccount } from '../entities/custody-account.entity'; @Injectable() -export class SafeAccountRepository extends BaseRepository { +export class CustodyAccountRepository extends BaseRepository { constructor(manager: EntityManager) { - super(SafeAccount, manager); + 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..2e822262f2 --- /dev/null +++ b/src/subdomains/core/custody/services/custody-account.service.ts @@ -0,0 +1,189 @@ +import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { UserRole } from 'src/shared/auth/user-role.enum'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service'; +import { CustodyAccountAccess } from '../entities/custody-account-access.entity'; +import { CustodyAccount } from '../entities/custody-account.entity'; +import { CustodyAccessLevel, CustodyAccountStatus } from '../enums/custody'; +import { CustodyAccountAccessRepository } from '../repositories/custody-account-access.repository'; +import { CustodyAccountRepository } from '../repositories/custody-account.repository'; +import { CustodyBalanceRepository } from '../repositories/custody-balance.repository'; + +export interface CustodyAccountDto { + id: number | null; // null for legacy mode + title: string; + description?: string; + isLegacy: boolean; + accessLevel: CustodyAccessLevel; + owner?: { id: number }; +} + +@Injectable() +export class CustodyAccountService { + constructor( + private readonly custodyAccountRepo: CustodyAccountRepository, + private readonly custodyAccountAccessRepo: CustodyAccountAccessRepository, + private readonly userDataService: UserDataService, + private readonly custodyBalanceRepo: CustodyBalanceRepository, + ) {} + + // --- GET CUSTODY ACCOUNTS --- // + async getCustodyAccountsForUser(accountId: number): Promise { + const account = await this.userDataService.getUserData(accountId, { users: true }); + if (!account) throw new NotFoundException('User not found'); + + // 1. Check for explicit CustodyAccounts (owned or shared) + const ownedAccounts = await this.custodyAccountRepo.find({ + where: { owner: { id: accountId }, status: CustodyAccountStatus.ACTIVE }, + relations: ['owner'], + }); + + const accessGrants = await this.custodyAccountAccessRepo.find({ + where: { userData: { id: accountId } }, + relations: ['custodyAccount', 'custodyAccount.owner'], + }); + + const sharedAccounts = accessGrants + .filter((a) => a.custodyAccount.status === CustodyAccountStatus.ACTIVE) + .filter((a) => a.custodyAccount.owner.id !== accountId); // exclude owned + + const custodyAccounts: CustodyAccountDto[] = [ + ...ownedAccounts.map((ca) => this.mapToDto(ca, CustodyAccessLevel.WRITE, false)), + ...sharedAccounts.map((a) => this.mapToDto(a.custodyAccount, a.accessLevel, false)), + ]; + + if (custodyAccounts.length > 0) { + return custodyAccounts; + } + + // 2. Fallback: Aggregate all CUSTODY users as one "Legacy" + const custodyUsers = account.users.filter((u) => u.role === UserRole.CUSTODY); + if (custodyUsers.length > 0) { + return [this.createLegacyDto(account)]; + } + + return []; + } + + async getCustodyAccountById(custodyAccountId: number): Promise { + const custodyAccount = await this.custodyAccountRepo.findOne({ + where: { id: custodyAccountId }, + relations: ['owner', 'accessGrants', 'accessGrants.userData'], + }); + + if (!custodyAccount) throw new NotFoundException('CustodyAccount not found'); + + return custodyAccount; + } + + // --- ACCESS CHECK --- // + async checkAccess( + custodyAccountId: number | null, + accountId: number, + requiredLevel: CustodyAccessLevel, + ): Promise<{ custodyAccount: CustodyAccount | null; isLegacy: boolean }> { + // Legacy mode + if (custodyAccountId === null) { + 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 CustodyAccount'); + } + + // 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({ + custodyAccount: 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); + if (!custodyAccount) throw new ForbiddenException('Cannot update legacy account'); + + if (title) custodyAccount.title = title; + if (description !== undefined) custodyAccount.description = description; + + return this.custodyAccountRepo.save(custodyAccount); + } + + // --- HELPERS --- // + private mapToDto(custodyAccount: CustodyAccount, accessLevel: CustodyAccessLevel, isLegacy: boolean): CustodyAccountDto { + return { + id: custodyAccount.id, + title: custodyAccount.title, + description: custodyAccount.description, + isLegacy, + accessLevel, + owner: custodyAccount.owner ? { id: custodyAccount.owner.id } : undefined, + }; + } + + private createLegacyDto(userData: UserData): CustodyAccountDto { + return { + id: null, + title: 'Custody', // Default title for legacy + description: undefined, + isLegacy: true, + accessLevel: CustodyAccessLevel.WRITE, // Owner has full access + owner: { id: userData.id }, + }; + } + + // --- GET ACCESS LIST --- // + async getAccessList(custodyAccountId: number, accountId: number): Promise { + await this.checkAccess(custodyAccountId, accountId, CustodyAccessLevel.READ); + + return this.custodyAccountAccessRepo.find({ + where: { custodyAccount: { id: custodyAccountId } }, + relations: ['userData'], + }); + } +} 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/core/custody/services/safe-account.service.ts b/src/subdomains/core/custody/services/safe-account.service.ts deleted file mode 100644 index 2fa8efa2da..0000000000 --- a/src/subdomains/core/custody/services/safe-account.service.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; -import { UserRole } from 'src/shared/auth/user-role.enum'; -import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; -import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service'; -import { User } from 'src/subdomains/generic/user/models/user/user.entity'; -import { In } from 'typeorm'; -import { SafeAccountAccess } from '../entities/safe-account-access.entity'; -import { SafeAccount } from '../entities/safe-account.entity'; -import { SafeAccessLevel, SafeAccountStatus } from '../enums/custody'; -import { SafeAccountAccessRepository } from '../repositories/safe-account-access.repository'; -import { SafeAccountRepository } from '../repositories/safe-account.repository'; -import { CustodyBalanceRepository } from '../repositories/custody-balance.repository'; - -export interface SafeAccountDto { - id: number | null; // null for legacy mode - title: string; - description?: string; - isLegacy: boolean; - accessLevel: SafeAccessLevel; - owner?: { id: number }; -} - -@Injectable() -export class SafeAccountService { - constructor( - private readonly safeAccountRepo: SafeAccountRepository, - private readonly safeAccountAccessRepo: SafeAccountAccessRepository, - private readonly userDataService: UserDataService, - private readonly custodyBalanceRepo: CustodyBalanceRepository, - ) {} - - // --- GET SAFE ACCOUNTS --- // - async getSafeAccountsForUser(accountId: number): Promise { - const account = await this.userDataService.getUserData(accountId, { users: true }); - if (!account) throw new NotFoundException('User not found'); - - // 1. Check for explicit SafeAccounts (owned or shared) - const ownedAccounts = await this.safeAccountRepo.find({ - where: { owner: { id: accountId }, status: SafeAccountStatus.ACTIVE }, - relations: ['owner'], - }); - - const accessGrants = await this.safeAccountAccessRepo.find({ - where: { userData: { id: accountId } }, - relations: ['safeAccount', 'safeAccount.owner'], - }); - - const sharedAccounts = accessGrants - .filter((a) => a.safeAccount.status === SafeAccountStatus.ACTIVE) - .filter((a) => a.safeAccount.owner.id !== accountId); // exclude owned - - const safeAccounts: SafeAccountDto[] = [ - ...ownedAccounts.map((sa) => this.mapToDto(sa, SafeAccessLevel.WRITE, false)), - ...sharedAccounts.map((a) => this.mapToDto(a.safeAccount, a.accessLevel, false)), - ]; - - if (safeAccounts.length > 0) { - return safeAccounts; - } - - // 2. Fallback: Aggregate all CUSTODY users as one "Legacy Safe" - const custodyUsers = account.users.filter((u) => u.role === UserRole.CUSTODY); - if (custodyUsers.length > 0) { - return [this.createLegacySafeDto(account)]; - } - - return []; - } - - async getSafeAccountById(safeAccountId: number): Promise { - const safeAccount = await this.safeAccountRepo.findOne({ - where: { id: safeAccountId }, - relations: ['owner', 'accessGrants', 'accessGrants.userData'], - }); - - if (!safeAccount) throw new NotFoundException('SafeAccount not found'); - - return safeAccount; - } - - // --- ACCESS CHECK --- // - async checkAccess( - safeAccountId: number | null, - accountId: number, - requiredLevel: SafeAccessLevel, - ): Promise<{ safeAccount: SafeAccount | null; isLegacy: boolean }> { - // Legacy mode - if (safeAccountId === null) { - return { safeAccount: null, isLegacy: true }; - } - - const safeAccount = await this.getSafeAccountById(safeAccountId); - - // Owner has WRITE access - if (safeAccount.owner.id === accountId) { - return { safeAccount, isLegacy: false }; - } - - // Check access grants - const access = safeAccount.accessGrants.find((a) => a.userData.id === accountId); - if (!access) { - throw new ForbiddenException('No access to this SafeAccount'); - } - - // Check if access level is sufficient - if (requiredLevel === SafeAccessLevel.WRITE && access.accessLevel === SafeAccessLevel.READ) { - throw new ForbiddenException('Write access required'); - } - - return { safeAccount, isLegacy: false }; - } - - // --- CREATE --- // - async createSafeAccount( - accountId: number, - title: string, - description?: string, - ): Promise { - const owner = await this.userDataService.getUserData(accountId); - if (!owner) throw new NotFoundException('User not found'); - - const safeAccount = this.safeAccountRepo.create({ - title, - description, - owner, - status: SafeAccountStatus.ACTIVE, - requiredSignatures: 1, - }); - - const saved = await this.safeAccountRepo.save(safeAccount); - - // Create WRITE access for owner - const ownerAccess = this.safeAccountAccessRepo.create({ - safeAccount: saved, - userData: owner, - accessLevel: SafeAccessLevel.WRITE, - }); - await this.safeAccountAccessRepo.save(ownerAccess); - - return saved; - } - - // --- UPDATE --- // - async updateSafeAccount( - safeAccountId: number, - accountId: number, - title?: string, - description?: string, - ): Promise { - const { safeAccount } = await this.checkAccess(safeAccountId, accountId, SafeAccessLevel.WRITE); - if (!safeAccount) throw new ForbiddenException('Cannot update legacy safe'); - - if (title) safeAccount.title = title; - if (description !== undefined) safeAccount.description = description; - - return this.safeAccountRepo.save(safeAccount); - } - - // --- HELPERS --- // - private mapToDto(safeAccount: SafeAccount, accessLevel: SafeAccessLevel, isLegacy: boolean): SafeAccountDto { - return { - id: safeAccount.id, - title: safeAccount.title, - description: safeAccount.description, - isLegacy, - accessLevel, - owner: safeAccount.owner ? { id: safeAccount.owner.id } : undefined, - }; - } - - private createLegacySafeDto(userData: UserData): SafeAccountDto { - return { - id: null, - title: 'Safe', // Default title for legacy - description: undefined, - isLegacy: true, - accessLevel: SafeAccessLevel.WRITE, // Owner has full access - owner: { id: userData.id }, - }; - } - - // --- GET ACCESS LIST --- // - async getAccessList(safeAccountId: number, accountId: number): Promise { - await this.checkAccess(safeAccountId, accountId, SafeAccessLevel.READ); - - return this.safeAccountAccessRepo.find({ - where: { safeAccount: { id: safeAccountId } }, - relations: ['userData'], - }); - } -} diff --git a/src/subdomains/generic/user/models/user/user.entity.ts b/src/subdomains/generic/user/models/user/user.entity.ts index 5bbdd9b6ee..30b546e46d 100644 --- a/src/subdomains/generic/user/models/user/user.entity.ts +++ b/src/subdomains/generic/user/models/user/user.entity.ts @@ -7,7 +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 { SafeAccount } from 'src/subdomains/core/custody/entities/safe-account.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'; @@ -155,8 +155,8 @@ export class User extends IEntity { @Column({ nullable: true }) custodyAddressType: CustodyAddressType; - @ManyToOne(() => SafeAccount, { nullable: true }) - safeAccount?: SafeAccount; + @ManyToOne(() => CustodyAccount, { nullable: true }) + custodyAccount?: CustodyAccount; @OneToMany(() => CustodyOrder, (custodyOrder) => custodyOrder.user) custodyOrders: CustodyOrder[]; From d114e34dc80749758044b8e1d31b435f004c991f Mon Sep 17 00:00:00 2001 From: David May Date: Tue, 13 Jan 2026 22:19:17 +0100 Subject: [PATCH 3/7] feat: small cleanup --- ... 1768339070646-AddCustodyAccountTables.js} | 22 ++++++++--------- .../entities/custody-account-access.entity.ts | 4 ++-- .../entities/custody-account.entity.ts | 2 +- .../entities/custody-balance.entity.ts | 4 ++-- .../services/custody-account.service.ts | 24 +++++++++---------- 5 files changed, 28 insertions(+), 28 deletions(-) rename migration/{1768338479982-AddCustodyAccountTables.js => 1768339070646-AddCustodyAccountTables.js} (80%) diff --git a/migration/1768338479982-AddCustodyAccountTables.js b/migration/1768339070646-AddCustodyAccountTables.js similarity index 80% rename from migration/1768338479982-AddCustodyAccountTables.js rename to migration/1768339070646-AddCustodyAccountTables.js index e8c62c75df..90f57a21a3 100644 --- a/migration/1768338479982-AddCustodyAccountTables.js +++ b/migration/1768339070646-AddCustodyAccountTables.js @@ -7,26 +7,26 @@ * @class * @implements {MigrationInterface} */ -module.exports = class AddCustodyAccountTables1768338479982 { - name = 'AddCustodyAccountTables1768338479982' +module.exports = class AddCustodyAccountTables1768339070646 { + name = 'AddCustodyAccountTables1768339070646' /** * @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, "custodyAccountId" int NOT NULL, "userDataId" int NOT NULL, CONSTRAINT "PK_1657b7fd1d5a0ee0b01f657e508" PRIMARY KEY ("id"))`); - await queryRunner.query(`CREATE UNIQUE INDEX "IDX_8a2ec36ebff5b786fd4d7e0142" ON "custody_account_access" ("custodyAccountId", "userDataId") `); + 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 "custodyAccountId" int`); await queryRunner.query(`ALTER TABLE "custody_order" ADD "initiatedById" int`); - await queryRunner.query(`ALTER TABLE "custody_balance" ADD "custodyAccountId" 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_c678151aaee25061ce6ce5ba2f5" FOREIGN KEY ("custodyAccountId") REFERENCES "custody_account"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + 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_e6a0e5cbc91e9bdca945101c67f" FOREIGN KEY ("custodyAccountId") 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_9cd8ac552741c57822426335f70" FOREIGN KEY ("custodyAccountId") REFERENCES "custody_account"("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`); } @@ -35,18 +35,18 @@ module.exports = class AddCustodyAccountTables1768338479982 { */ async down(queryRunner) { await queryRunner.query(`ALTER TABLE "user" DROP CONSTRAINT "FK_bf8ce326ec41adc02940bccf91a"`); - await queryRunner.query(`ALTER TABLE "custody_balance" DROP CONSTRAINT "FK_9cd8ac552741c57822426335f70"`); + 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_e6a0e5cbc91e9bdca945101c67f"`); 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_c678151aaee25061ce6ce5ba2f5"`); + 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 "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 "custodyAccountId"`); await queryRunner.query(`DROP TABLE "custody_account"`); - await queryRunner.query(`DROP INDEX "IDX_8a2ec36ebff5b786fd4d7e0142" ON "custody_account_access"`); + 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/entities/custody-account-access.entity.ts b/src/subdomains/core/custody/entities/custody-account-access.entity.ts index c61098ae5a..4652bdcebd 100644 --- a/src/subdomains/core/custody/entities/custody-account-access.entity.ts +++ b/src/subdomains/core/custody/entities/custody-account-access.entity.ts @@ -5,10 +5,10 @@ import { CustodyAccessLevel } from '../enums/custody'; import { CustodyAccount } from './custody-account.entity'; @Entity() -@Index((a: CustodyAccountAccess) => [a.custodyAccount, a.userData], { unique: true }) +@Index((a: CustodyAccountAccess) => [a.account, a.userData], { unique: true }) export class CustodyAccountAccess extends IEntity { @ManyToOne(() => CustodyAccount, (custodyAccount) => custodyAccount.accessGrants, { nullable: false }) - custodyAccount: CustodyAccount; + account: CustodyAccount; @ManyToOne(() => UserData, { nullable: false }) userData: UserData; diff --git a/src/subdomains/core/custody/entities/custody-account.entity.ts b/src/subdomains/core/custody/entities/custody-account.entity.ts index 80c2837353..e2b2db0a95 100644 --- a/src/subdomains/core/custody/entities/custody-account.entity.ts +++ b/src/subdomains/core/custody/entities/custody-account.entity.ts @@ -21,6 +21,6 @@ export class CustodyAccount extends IEntity { @Column({ default: CustodyAccountStatus.ACTIVE }) status: CustodyAccountStatus; - @OneToMany(() => CustodyAccountAccess, (access) => access.custodyAccount) + @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 394e731a76..55f6540123 100644 --- a/src/subdomains/core/custody/entities/custody-balance.entity.ts +++ b/src/subdomains/core/custody/entities/custody-balance.entity.ts @@ -5,7 +5,7 @@ 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; @@ -17,5 +17,5 @@ export class CustodyBalance extends IEntity { asset: Asset; @ManyToOne(() => CustodyAccount, { nullable: true }) - custodyAccount?: CustodyAccount; + account?: CustodyAccount; } diff --git a/src/subdomains/core/custody/services/custody-account.service.ts b/src/subdomains/core/custody/services/custody-account.service.ts index 2e822262f2..e3c69507b6 100644 --- a/src/subdomains/core/custody/services/custody-account.service.ts +++ b/src/subdomains/core/custody/services/custody-account.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { UserRole } from 'src/shared/auth/user-role.enum'; import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service'; @@ -44,12 +44,12 @@ export class CustodyAccountService { }); const sharedAccounts = accessGrants - .filter((a) => a.custodyAccount.status === CustodyAccountStatus.ACTIVE) - .filter((a) => a.custodyAccount.owner.id !== accountId); // exclude owned + .filter((a) => a.account.status === CustodyAccountStatus.ACTIVE) + .filter((a) => a.account.owner.id !== accountId); // exclude owned const custodyAccounts: CustodyAccountDto[] = [ ...ownedAccounts.map((ca) => this.mapToDto(ca, CustodyAccessLevel.WRITE, false)), - ...sharedAccounts.map((a) => this.mapToDto(a.custodyAccount, a.accessLevel, false)), + ...sharedAccounts.map((a) => this.mapToDto(a.account, a.accessLevel, false)), ]; if (custodyAccounts.length > 0) { @@ -109,11 +109,7 @@ export class CustodyAccountService { } // --- CREATE --- // - async createCustodyAccount( - accountId: number, - title: string, - description?: string, - ): Promise { + async createCustodyAccount(accountId: number, title: string, description?: string): Promise { const owner = await this.userDataService.getUserData(accountId); if (!owner) throw new NotFoundException('User not found'); @@ -129,7 +125,7 @@ export class CustodyAccountService { // Create WRITE access for owner const ownerAccess = this.custodyAccountAccessRepo.create({ - custodyAccount: saved, + account: saved, userData: owner, accessLevel: CustodyAccessLevel.WRITE, }); @@ -155,7 +151,11 @@ export class CustodyAccountService { } // --- HELPERS --- // - private mapToDto(custodyAccount: CustodyAccount, accessLevel: CustodyAccessLevel, isLegacy: boolean): CustodyAccountDto { + private mapToDto( + custodyAccount: CustodyAccount, + accessLevel: CustodyAccessLevel, + isLegacy: boolean, + ): CustodyAccountDto { return { id: custodyAccount.id, title: custodyAccount.title, @@ -182,7 +182,7 @@ export class CustodyAccountService { await this.checkAccess(custodyAccountId, accountId, CustodyAccessLevel.READ); return this.custodyAccountAccessRepo.find({ - where: { custodyAccount: { id: custodyAccountId } }, + where: { account: { id: custodyAccountId } }, relations: ['userData'], }); } From 8aff3ea0dfd8b517b5da6830588ef8a8f159c938 Mon Sep 17 00:00:00 2001 From: David May Date: Tue, 13 Jan 2026 22:59:33 +0100 Subject: [PATCH 4/7] feat: refactoring --- .../controllers/custody-account.controller.ts | 63 +++++++++---------- .../dto/input/create-custody-account.dto.ts | 4 +- .../dto/input/update-custody-account.dto.ts | 4 +- .../custody/dto/output/custody-account.dto.ts | 16 ++--- .../guards/custody-account-access.guard.ts | 49 +++++---------- .../mappers/custody-account-dto.mapper.ts | 28 +++++++++ .../services/custody-account.service.ts | 45 ++----------- 7 files changed, 89 insertions(+), 120 deletions(-) create mode 100644 src/subdomains/core/custody/mappers/custody-account-dto.mapper.ts diff --git a/src/subdomains/core/custody/controllers/custody-account.controller.ts b/src/subdomains/core/custody/controllers/custody-account.controller.ts index 090f56e2e1..06fd9e1b30 100644 --- a/src/subdomains/core/custody/controllers/custody-account.controller.ts +++ b/src/subdomains/core/custody/controllers/custody-account.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Post, Put, UseGuards } from '@nestjs/common'; +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'; @@ -9,7 +9,9 @@ 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 } from '../services/custody-account.service'; @ApiTags('Custody') @@ -20,7 +22,7 @@ export class CustodyAccountController { @Get() @ApiBearerAuth() @UseGuards(AuthGuard(), RoleGuard(UserRole.ACCOUNT), UserActiveGuard()) - @ApiOkResponse({ type: [CustodyAccountDto], description: 'List of CustodyAccounts for the user' }) + @ApiOkResponse({ type: [CustodyAccountDto], description: 'List of custody accounts for the user' }) async getCustodyAccounts(@GetJwt() jwt: JwtPayload): Promise { return this.custodyAccountService.getCustodyAccountsForUser(jwt.account); } @@ -28,58 +30,51 @@ export class CustodyAccountController { @Get(':id') @ApiBearerAuth() @UseGuards(AuthGuard(), RoleGuard(UserRole.ACCOUNT), UserActiveGuard(), CustodyAccountReadGuard) - @ApiOkResponse({ type: CustodyAccountDto, description: 'CustodyAccount details' }) + @ApiOkResponse({ type: CustodyAccountDto, description: 'Custody account details' }) async getCustodyAccount(@GetJwt() jwt: JwtPayload, @Param('id') id: string): Promise { - const custodyAccountId = id === 'legacy' ? null : +id; const custodyAccounts = await this.custodyAccountService.getCustodyAccountsForUser(jwt.account); - if (custodyAccountId === null) { - const legacy = custodyAccounts.find((ca) => ca.isLegacy); - if (!legacy) throw new Error('No legacy custody account found'); - return legacy; - } + const isLegacy = id === 'legacy'; + 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`); - const custodyAccount = custodyAccounts.find((ca) => ca.id === custodyAccountId); - if (!custodyAccount) throw new Error('CustodyAccount not found'); - return custodyAccount; + return account; } @Post() @ApiBearerAuth() @UseGuards(AuthGuard(), RoleGuard(UserRole.ACCOUNT), UserActiveGuard()) - @ApiCreatedResponse({ type: CustodyAccountDto, description: 'Create a new CustodyAccount' }) - async createCustodyAccount(@GetJwt() jwt: JwtPayload, @Body() dto: CreateCustodyAccountDto): Promise { - const custodyAccount = await this.custodyAccountService.createCustodyAccount(jwt.account, dto.title, dto.description); + @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 { - id: custodyAccount.id, - title: custodyAccount.title, - description: custodyAccount.description, - isLegacy: false, - accessLevel: 'Write' as any, - owner: { id: jwt.account }, - }; + return CustodyAccountDtoMapper.toDto(custodyAccount, CustodyAccessLevel.WRITE); } @Put(':id') @ApiBearerAuth() @UseGuards(AuthGuard(), RoleGuard(UserRole.ACCOUNT), UserActiveGuard(), CustodyAccountWriteGuard) - @ApiOkResponse({ type: CustodyAccountDto, description: 'Update CustodyAccount' }) + @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); + const custodyAccount = await this.custodyAccountService.updateCustodyAccount( + +id, + jwt.account, + dto.title, + dto.description, + ); - return { - id: custodyAccount.id, - title: custodyAccount.title, - description: custodyAccount.description, - isLegacy: false, - accessLevel: 'Write' as any, - owner: custodyAccount.owner ? { id: custodyAccount.owner.id } : undefined, - }; + return CustodyAccountDtoMapper.toDto(custodyAccount, CustodyAccessLevel.WRITE); } @Get(':id/access') @@ -91,7 +86,7 @@ export class CustodyAccountController { return accessList.map((access) => ({ id: access.id, - userDataId: access.userData.id, + user: { id: access.userData.id }, accessLevel: access.accessLevel, })); } 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 783eec09bf..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 @@ -2,12 +2,12 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsOptional, IsString, MaxLength } from 'class-validator'; export class CreateCustodyAccountDto { - @ApiProperty({ description: 'Title of the CustodyAccount' }) + @ApiProperty({ description: 'Title of the custody account' }) @IsString() @MaxLength(256) title: string; - @ApiPropertyOptional({ description: 'Description of the CustodyAccount' }) + @ApiPropertyOptional({ description: 'Description of the custody account' }) @IsOptional() @IsString() description?: string; 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 index 8ef4124f89..7bd55bfc70 100644 --- a/src/subdomains/core/custody/dto/input/update-custody-account.dto.ts +++ b/src/subdomains/core/custody/dto/input/update-custody-account.dto.ts @@ -2,13 +2,13 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { IsOptional, IsString, MaxLength } from 'class-validator'; export class UpdateCustodyAccountDto { - @ApiPropertyOptional({ description: 'Title of the CustodyAccount' }) + @ApiPropertyOptional({ description: 'Title of the custody account' }) @IsOptional() @IsString() @MaxLength(256) title?: string; - @ApiPropertyOptional({ description: 'Description of the CustodyAccount' }) + @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 index 7c62cc14df..5f35f0dc31 100644 --- a/src/subdomains/core/custody/dto/output/custody-account.dto.ts +++ b/src/subdomains/core/custody/dto/output/custody-account.dto.ts @@ -1,19 +1,19 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { CustodyAccessLevel } from '../../enums/custody'; -export class CustodyAccountOwnerDto { +export class CustodyUserDto { @ApiProperty() id: number; } export class CustodyAccountDto { - @ApiPropertyOptional({ description: 'ID of the CustodyAccount (null for legacy)' }) + @ApiPropertyOptional({ description: 'ID of the custody account (null for legacy)' }) id: number | null; - @ApiProperty({ description: 'Title of the CustodyAccount' }) + @ApiProperty({ description: 'Title of the custody account' }) title: string; - @ApiPropertyOptional({ description: 'Description of the CustodyAccount' }) + @ApiPropertyOptional({ description: 'Description of the custody account' }) description?: string; @ApiProperty({ description: 'Whether this is a legacy account (aggregated custody users)' }) @@ -22,16 +22,16 @@ export class CustodyAccountDto { @ApiProperty({ enum: CustodyAccessLevel, description: 'Access level for current user' }) accessLevel: CustodyAccessLevel; - @ApiPropertyOptional({ type: CustodyAccountOwnerDto }) - owner?: CustodyAccountOwnerDto; + @ApiPropertyOptional({ type: CustodyUserDto }) + owner?: CustodyUserDto; } export class CustodyAccountAccessDto { @ApiProperty() id: number; - @ApiProperty() - userDataId: number; + @ApiProperty({ type: CustodyUserDto }) + user: CustodyUserDto; @ApiProperty({ enum: CustodyAccessLevel }) accessLevel: CustodyAccessLevel; diff --git a/src/subdomains/core/custody/guards/custody-account-access.guard.ts b/src/subdomains/core/custody/guards/custody-account-access.guard.ts index 95685a8694..028d02926c 100644 --- a/src/subdomains/core/custody/guards/custody-account-access.guard.ts +++ b/src/subdomains/core/custody/guards/custody-account-access.guard.ts @@ -1,22 +1,23 @@ -import { CanActivate, ExecutionContext, Injectable, ForbiddenException } from '@nestjs/common'; +import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; import { CustodyAccessLevel } from '../enums/custody'; import { CustodyAccountService } from '../services/custody-account.service'; -@Injectable() -export class CustodyAccountReadGuard implements CanActivate { - constructor(private readonly custodyAccountService: CustodyAccountService) {} +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; - const custodyAccountId = this.getCustodyAccountId(request); + const accountId = request.user?.account; if (!accountId) { throw new ForbiddenException('User not authenticated'); } try { - await this.custodyAccountService.checkAccess(custodyAccountId, accountId, CustodyAccessLevel.READ); + const custodyAccountId = this.getCustodyAccountId(request); + await this.custodyAccountService.checkAccess(custodyAccountId, accountId, this.requiredLevel); return true; } catch (error) { throw new ForbiddenException(error.message || 'Access denied'); @@ -25,7 +26,7 @@ export class CustodyAccountReadGuard implements CanActivate { private getCustodyAccountId(request: any): number | null { const id = request.params?.custodyAccountId || request.params?.id; - if (id === 'legacy' || id === null || id === undefined) { + if (id === 'legacy' || id == null) { return null; } return parseInt(id, 10); @@ -33,31 +34,11 @@ export class CustodyAccountReadGuard implements CanActivate { } @Injectable() -export class CustodyAccountWriteGuard implements CanActivate { - constructor(private readonly custodyAccountService: CustodyAccountService) {} - - async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); - const accountId = request.user?.account; - const custodyAccountId = this.getCustodyAccountId(request); - - if (!accountId) { - throw new ForbiddenException('User not authenticated'); - } - - try { - await this.custodyAccountService.checkAccess(custodyAccountId, accountId, CustodyAccessLevel.WRITE); - return true; - } catch (error) { - throw new ForbiddenException(error.message || 'Access denied'); - } - } +export class CustodyAccountReadGuard extends CustodyAccountAccessGuard { + protected readonly requiredLevel = CustodyAccessLevel.READ; +} - private getCustodyAccountId(request: any): number | null { - const id = request.params?.custodyAccountId || request.params?.id; - if (id === 'legacy' || id === null || id === undefined) { - return null; - } - return parseInt(id, 10); - } +@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/services/custody-account.service.ts b/src/subdomains/core/custody/services/custody-account.service.ts index e3c69507b6..0c4d30824c 100644 --- a/src/subdomains/core/custody/services/custody-account.service.ts +++ b/src/subdomains/core/custody/services/custody-account.service.ts @@ -1,23 +1,15 @@ import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { UserRole } from 'src/shared/auth/user-role.enum'; -import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; 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'; import { CustodyBalanceRepository } from '../repositories/custody-balance.repository'; -export interface CustodyAccountDto { - id: number | null; // null for legacy mode - title: string; - description?: string; - isLegacy: boolean; - accessLevel: CustodyAccessLevel; - owner?: { id: number }; -} - @Injectable() export class CustodyAccountService { constructor( @@ -48,8 +40,8 @@ export class CustodyAccountService { .filter((a) => a.account.owner.id !== accountId); // exclude owned const custodyAccounts: CustodyAccountDto[] = [ - ...ownedAccounts.map((ca) => this.mapToDto(ca, CustodyAccessLevel.WRITE, false)), - ...sharedAccounts.map((a) => this.mapToDto(a.account, a.accessLevel, false)), + ...ownedAccounts.map((ca) => CustodyAccountDtoMapper.toDto(ca, CustodyAccessLevel.WRITE)), + ...sharedAccounts.map((a) => CustodyAccountDtoMapper.toDto(a.account, a.accessLevel)), ]; if (custodyAccounts.length > 0) { @@ -59,7 +51,7 @@ export class CustodyAccountService { // 2. Fallback: Aggregate all CUSTODY users as one "Legacy" const custodyUsers = account.users.filter((u) => u.role === UserRole.CUSTODY); if (custodyUsers.length > 0) { - return [this.createLegacyDto(account)]; + return [CustodyAccountDtoMapper.toLegacyDto(account)]; } return []; @@ -150,33 +142,6 @@ export class CustodyAccountService { return this.custodyAccountRepo.save(custodyAccount); } - // --- HELPERS --- // - private mapToDto( - custodyAccount: CustodyAccount, - accessLevel: CustodyAccessLevel, - isLegacy: boolean, - ): CustodyAccountDto { - return { - id: custodyAccount.id, - title: custodyAccount.title, - description: custodyAccount.description, - isLegacy, - accessLevel, - owner: custodyAccount.owner ? { id: custodyAccount.owner.id } : undefined, - }; - } - - private createLegacyDto(userData: UserData): CustodyAccountDto { - return { - id: null, - title: 'Custody', // Default title for legacy - description: undefined, - isLegacy: true, - accessLevel: CustodyAccessLevel.WRITE, // Owner has full access - owner: { id: userData.id }, - }; - } - // --- GET ACCESS LIST --- // async getAccessList(custodyAccountId: number, accountId: number): Promise { await this.checkAccess(custodyAccountId, accountId, CustodyAccessLevel.READ); From 74cfa0d6eedc3a1fcc1906b487b99cf98a3fc22f Mon Sep 17 00:00:00 2001 From: David May Date: Wed, 14 Jan 2026 00:02:53 +0100 Subject: [PATCH 5/7] feat: refactoring 2 --- ... 1768341824012-AddCustodyAccountTables.js} | 12 ++--- .../controllers/custody-account.controller.ts | 4 +- .../custody/entities/custody-order.entity.ts | 4 +- src/subdomains/core/custody/enums/custody.ts | 3 +- .../guards/custody-account-access.guard.ts | 16 ++++--- .../services/custody-account.service.ts | 45 +++++++++---------- .../user/models/user-data/user-data.entity.ts | 4 ++ 7 files changed, 48 insertions(+), 40 deletions(-) rename migration/{1768339070646-AddCustodyAccountTables.js => 1768341824012-AddCustodyAccountTables.js} (92%) diff --git a/migration/1768339070646-AddCustodyAccountTables.js b/migration/1768341824012-AddCustodyAccountTables.js similarity index 92% rename from migration/1768339070646-AddCustodyAccountTables.js rename to migration/1768341824012-AddCustodyAccountTables.js index 90f57a21a3..81eebf2414 100644 --- a/migration/1768339070646-AddCustodyAccountTables.js +++ b/migration/1768341824012-AddCustodyAccountTables.js @@ -7,8 +7,8 @@ * @class * @implements {MigrationInterface} */ -module.exports = class AddCustodyAccountTables1768339070646 { - name = 'AddCustodyAccountTables1768339070646' +module.exports = class AddCustodyAccountTables1768341824012 { + name = 'AddCustodyAccountTables1768341824012' /** * @param {QueryRunner} queryRunner @@ -17,14 +17,14 @@ module.exports = class AddCustodyAccountTables1768339070646 { 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 "custodyAccountId" int`); + 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_e6a0e5cbc91e9bdca945101c67f" FOREIGN KEY ("custodyAccountId") REFERENCES "custody_account"("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`); @@ -37,14 +37,14 @@ module.exports = class AddCustodyAccountTables1768339070646 { 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_e6a0e5cbc91e9bdca945101c67f"`); + 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 "custodyAccountId"`); + 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 index 06fd9e1b30..d15699d560 100644 --- a/src/subdomains/core/custody/controllers/custody-account.controller.ts +++ b/src/subdomains/core/custody/controllers/custody-account.controller.ts @@ -12,7 +12,7 @@ import { CustodyAccountAccessDto, CustodyAccountDto } from '../dto/output/custod import { CustodyAccessLevel } from '../enums/custody'; import { CustodyAccountReadGuard, CustodyAccountWriteGuard } from '../guards/custody-account-access.guard'; import { CustodyAccountDtoMapper } from '../mappers/custody-account-dto.mapper'; -import { CustodyAccountService } from '../services/custody-account.service'; +import { CustodyAccountService, LegacyAccountId } from '../services/custody-account.service'; @ApiTags('Custody') @Controller('custody/account') @@ -34,7 +34,7 @@ export class CustodyAccountController { async getCustodyAccount(@GetJwt() jwt: JwtPayload, @Param('id') id: string): Promise { const custodyAccounts = await this.custodyAccountService.getCustodyAccountsForUser(jwt.account); - const isLegacy = id === 'legacy'; + 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`); diff --git a/src/subdomains/core/custody/entities/custody-order.entity.ts b/src/subdomains/core/custody/entities/custody-order.entity.ts index 34aea4398f..86b942bc81 100644 --- a/src/subdomains/core/custody/entities/custody-order.entity.ts +++ b/src/subdomains/core/custody/entities/custody-order.entity.ts @@ -10,8 +10,8 @@ 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 { CustodyOrderStep } from './custody-order-step.entity'; import { CustodyAccount } from './custody-account.entity'; +import { CustodyOrderStep } from './custody-order-step.entity'; @Entity() export class CustodyOrder extends IEntity { @@ -40,7 +40,7 @@ export class CustodyOrder extends IEntity { user: User; @ManyToOne(() => CustodyAccount, { nullable: true }) - custodyAccount?: CustodyAccount; + account?: CustodyAccount; @ManyToOne(() => UserData, { nullable: true }) initiatedBy?: UserData; diff --git a/src/subdomains/core/custody/enums/custody.ts b/src/subdomains/core/custody/enums/custody.ts index d0ed4546b6..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', @@ -39,7 +40,7 @@ export enum CustodyOrderStepCommand { SEND_TO_ROUTE = 'SendToRoute', } -// CustodyAccount Enums +// accounts export enum CustodyAccountStatus { ACTIVE = 'Active', BLOCKED = 'Blocked', diff --git a/src/subdomains/core/custody/guards/custody-account-access.guard.ts b/src/subdomains/core/custody/guards/custody-account-access.guard.ts index 028d02926c..d8ead9b53f 100644 --- a/src/subdomains/core/custody/guards/custody-account-access.guard.ts +++ b/src/subdomains/core/custody/guards/custody-account-access.guard.ts @@ -1,6 +1,6 @@ import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; import { CustodyAccessLevel } from '../enums/custody'; -import { CustodyAccountService } from '../services/custody-account.service'; +import { CustodyAccountId, CustodyAccountService, LegacyAccountId } from '../services/custody-account.service'; abstract class CustodyAccountAccessGuard implements CanActivate { protected abstract readonly requiredLevel: CustodyAccessLevel; @@ -24,12 +24,16 @@ abstract class CustodyAccountAccessGuard implements CanActivate { } } - private getCustodyAccountId(request: any): number | null { + private getCustodyAccountId(request: any): CustodyAccountId { const id = request.params?.custodyAccountId || request.params?.id; - if (id === 'legacy' || id == null) { - return null; - } - return parseInt(id, 10); + 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; } } diff --git a/src/subdomains/core/custody/services/custody-account.service.ts b/src/subdomains/core/custody/services/custody-account.service.ts index 0c4d30824c..99bc8cde42 100644 --- a/src/subdomains/core/custody/services/custody-account.service.ts +++ b/src/subdomains/core/custody/services/custody-account.service.ts @@ -8,7 +8,9 @@ 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'; -import { CustodyBalanceRepository } from '../repositories/custody-balance.repository'; + +export const LegacyAccountId = 'legacy'; +export type CustodyAccountId = number | typeof LegacyAccountId; @Injectable() export class CustodyAccountService { @@ -16,28 +18,26 @@ export class CustodyAccountService { private readonly custodyAccountRepo: CustodyAccountRepository, private readonly custodyAccountAccessRepo: CustodyAccountAccessRepository, private readonly userDataService: UserDataService, - private readonly custodyBalanceRepo: CustodyBalanceRepository, ) {} // --- GET CUSTODY ACCOUNTS --- // async getCustodyAccountsForUser(accountId: number): Promise { - const account = await this.userDataService.getUserData(accountId, { users: true }); + const account = await this.userDataService.getUserData(accountId, { + users: true, + custodyAccountAccesses: { account: { owner: true } }, + }); if (!account) throw new NotFoundException('User not found'); - // 1. Check for explicit CustodyAccounts (owned or shared) + // owned accounts (via direct ownership) const ownedAccounts = await this.custodyAccountRepo.find({ where: { owner: { id: accountId }, status: CustodyAccountStatus.ACTIVE }, - relations: ['owner'], - }); - - const accessGrants = await this.custodyAccountAccessRepo.find({ - where: { userData: { id: accountId } }, - relations: ['custodyAccount', 'custodyAccount.owner'], + relations: { owner: true }, }); - const sharedAccounts = accessGrants + // 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); // exclude owned + .filter((a) => a.account.owner.id !== accountId); const custodyAccounts: CustodyAccountDto[] = [ ...ownedAccounts.map((ca) => CustodyAccountDtoMapper.toDto(ca, CustodyAccessLevel.WRITE)), @@ -48,9 +48,9 @@ export class CustodyAccountService { return custodyAccounts; } - // 2. Fallback: Aggregate all CUSTODY users as one "Legacy" - const custodyUsers = account.users.filter((u) => u.role === UserRole.CUSTODY); - if (custodyUsers.length > 0) { + // fallback to legacy custody account + const hasCustody = account.users.some((u) => u.role === UserRole.CUSTODY); + if (hasCustody) { return [CustodyAccountDtoMapper.toLegacyDto(account)]; } @@ -60,22 +60,22 @@ export class CustodyAccountService { async getCustodyAccountById(custodyAccountId: number): Promise { const custodyAccount = await this.custodyAccountRepo.findOne({ where: { id: custodyAccountId }, - relations: ['owner', 'accessGrants', 'accessGrants.userData'], + relations: { owner: true, accessGrants: { userData: true } }, }); - if (!custodyAccount) throw new NotFoundException('CustodyAccount not found'); + if (!custodyAccount) throw new NotFoundException('Custody account not found'); return custodyAccount; } // --- ACCESS CHECK --- // async checkAccess( - custodyAccountId: number | null, + custodyAccountId: CustodyAccountId, accountId: number, requiredLevel: CustodyAccessLevel, ): Promise<{ custodyAccount: CustodyAccount | null; isLegacy: boolean }> { // Legacy mode - if (custodyAccountId === null) { + if (custodyAccountId === LegacyAccountId) { return { custodyAccount: null, isLegacy: true }; } @@ -89,7 +89,7 @@ export class CustodyAccountService { // Check access grants const access = custodyAccount.accessGrants.find((a) => a.userData.id === accountId); if (!access) { - throw new ForbiddenException('No access to this CustodyAccount'); + throw new ForbiddenException('No access to this custody account'); } // Check if access level is sufficient @@ -136,8 +136,7 @@ export class CustodyAccountService { const { custodyAccount } = await this.checkAccess(custodyAccountId, accountId, CustodyAccessLevel.WRITE); if (!custodyAccount) throw new ForbiddenException('Cannot update legacy account'); - if (title) custodyAccount.title = title; - if (description !== undefined) custodyAccount.description = description; + Object.assign(custodyAccount, { title, description }); return this.custodyAccountRepo.save(custodyAccount); } @@ -148,7 +147,7 @@ export class CustodyAccountService { return this.custodyAccountAccessRepo.find({ where: { account: { id: custodyAccountId } }, - relations: ['userData'], + relations: { userData: true }, }); } } 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..d95ee7c394 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,7 @@ 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 { FaucetRequest } from 'src/subdomains/core/faucet-request/entities/faucet-request.entity'; import { DefaultPaymentLinkConfig, @@ -377,6 +378,9 @@ export class UserData extends IEntity { @OneToMany(() => User, (user) => user.userData) users?: User[]; + @OneToMany(() => CustodyAccountAccess, (access) => access.userData) + custodyAccountAccesses?: CustodyAccountAccess[]; + // --- ENTITY METHODS --- // sendMail(): UpdateResult { this.blackSquadRecipientMail = this.mail; From d2ce2974a6bf19409a6d758864c3f9e3e11aa948 Mon Sep 17 00:00:00 2001 From: David May Date: Wed, 14 Jan 2026 00:18:19 +0100 Subject: [PATCH 6/7] feat: refactoring 3 --- .../custody/services/custody-account.service.ts | 14 ++++++++------ .../user/models/user-data/user-data.entity.ts | 4 ++++ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/subdomains/core/custody/services/custody-account.service.ts b/src/subdomains/core/custody/services/custody-account.service.ts index 99bc8cde42..4ac11f4a78 100644 --- a/src/subdomains/core/custody/services/custody-account.service.ts +++ b/src/subdomains/core/custody/services/custody-account.service.ts @@ -24,15 +24,15 @@ export class CustodyAccountService { 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 (via direct ownership) - const ownedAccounts = await this.custodyAccountRepo.find({ - where: { owner: { id: accountId }, status: CustodyAccountStatus.ACTIVE }, - relations: { owner: true }, - }); + // owned accounts + const ownedAccounts = (account.custodyAccounts ?? []).filter( + (ca) => ca.status === CustodyAccountStatus.ACTIVE, + ); // shared accounts (via access grants, excluding owned) const sharedAccounts = (account.custodyAccountAccesses ?? []) @@ -76,6 +76,9 @@ export class CustodyAccountService { ): 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 }; } @@ -134,7 +137,6 @@ export class CustodyAccountService { description?: string, ): Promise { const { custodyAccount } = await this.checkAccess(custodyAccountId, accountId, CustodyAccessLevel.WRITE); - if (!custodyAccount) throw new ForbiddenException('Cannot update legacy account'); Object.assign(custodyAccount, { title, description }); 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 d95ee7c394..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 @@ -8,6 +8,7 @@ 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, @@ -378,6 +379,9 @@ 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[]; From 768011ddcd73fd4ea693b7faf0e1e94e87ded63a Mon Sep 17 00:00:00 2001 From: David May Date: Wed, 14 Jan 2026 00:21:56 +0100 Subject: [PATCH 7/7] fix: fixed format --- .../core/custody/services/custody-account.service.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/subdomains/core/custody/services/custody-account.service.ts b/src/subdomains/core/custody/services/custody-account.service.ts index 4ac11f4a78..2c984329af 100644 --- a/src/subdomains/core/custody/services/custody-account.service.ts +++ b/src/subdomains/core/custody/services/custody-account.service.ts @@ -30,9 +30,7 @@ export class CustodyAccountService { if (!account) throw new NotFoundException('User not found'); // owned accounts - const ownedAccounts = (account.custodyAccounts ?? []).filter( - (ca) => ca.status === CustodyAccountStatus.ACTIVE, - ); + const ownedAccounts = (account.custodyAccounts ?? []).filter((ca) => ca.status === CustodyAccountStatus.ACTIVE); // shared accounts (via access grants, excluding owned) const sharedAccounts = (account.custodyAccountAccesses ?? [])