Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions migration/1768341824012-AddCustodyAccountTables.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
* @typedef {import('typeorm').QueryRunner} QueryRunner
*/

/**
* @class
* @implements {MigrationInterface}
*/
module.exports = class AddCustodyAccountTables1768341824012 {
name = 'AddCustodyAccountTables1768341824012'

/**
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
await queryRunner.query(`CREATE TABLE "custody_account_access" ("id" int NOT NULL IDENTITY(1,1), "updated" datetime2 NOT NULL CONSTRAINT "DF_7de1867f044392358470c9ade75" DEFAULT getdate(), "created" datetime2 NOT NULL CONSTRAINT "DF_8b3a56557a24d8c9157c4867435" DEFAULT getdate(), "accessLevel" nvarchar(255) NOT NULL, "accountId" int NOT NULL, "userDataId" int NOT NULL, CONSTRAINT "PK_1657b7fd1d5a0ee0b01f657e508" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_380e225bfd7707fff0e4f98035" ON "custody_account_access" ("accountId", "userDataId") `);
await queryRunner.query(`CREATE TABLE "custody_account" ("id" int NOT NULL IDENTITY(1,1), "updated" datetime2 NOT NULL CONSTRAINT "DF_91a0617046b1f9ca14218362617" DEFAULT getdate(), "created" datetime2 NOT NULL CONSTRAINT "DF_281789479a65769e9189e24f7d9" DEFAULT getdate(), "title" nvarchar(256) NOT NULL, "description" nvarchar(MAX), "requiredSignatures" int NOT NULL CONSTRAINT "DF_980d2b28fe8284b5060c70a36fd" DEFAULT 1, "status" nvarchar(255) NOT NULL CONSTRAINT "DF_aaeefb3ab36f3b7e02b5f4c67fc" DEFAULT 'Active', "ownerId" int NOT NULL, CONSTRAINT "PK_89fae3a990abaa76d843242fc6d" PRIMARY KEY ("id"))`);
await queryRunner.query(`ALTER TABLE "custody_order" ADD "accountId" int`);
await queryRunner.query(`ALTER TABLE "custody_order" ADD "initiatedById" int`);
await queryRunner.query(`ALTER TABLE "custody_balance" ADD "accountId" int`);
await queryRunner.query(`ALTER TABLE "user" ADD "custodyAccountId" int`);
await queryRunner.query(`ALTER TABLE "custody_account_access" ADD CONSTRAINT "FK_45213c9c7521d41be00fa5ead93" FOREIGN KEY ("accountId") REFERENCES "custody_account"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "custody_account_access" ADD CONSTRAINT "FK_8a4612269b283bf40950ddb8485" FOREIGN KEY ("userDataId") REFERENCES "user_data"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "custody_account" ADD CONSTRAINT "FK_b89a7cbab6c121f5a092815fce3" FOREIGN KEY ("ownerId") REFERENCES "user_data"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "custody_order" ADD CONSTRAINT "FK_6a769cd0d90bc68cafdd533f03e" FOREIGN KEY ("accountId") REFERENCES "custody_account"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "custody_order" ADD CONSTRAINT "FK_67425e623d89efe4ae1a48dbad6" FOREIGN KEY ("initiatedById") REFERENCES "user_data"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "custody_balance" ADD CONSTRAINT "FK_b141d5e0d74c87aef92eae2847a" FOREIGN KEY ("accountId") REFERENCES "custody_account"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "user" ADD CONSTRAINT "FK_bf8ce326ec41adc02940bccf91a" FOREIGN KEY ("custodyAccountId") REFERENCES "custody_account"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
}

/**
* @param {QueryRunner} queryRunner
*/
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user" DROP CONSTRAINT "FK_bf8ce326ec41adc02940bccf91a"`);
await queryRunner.query(`ALTER TABLE "custody_balance" DROP CONSTRAINT "FK_b141d5e0d74c87aef92eae2847a"`);
await queryRunner.query(`ALTER TABLE "custody_order" DROP CONSTRAINT "FK_67425e623d89efe4ae1a48dbad6"`);
await queryRunner.query(`ALTER TABLE "custody_order" DROP CONSTRAINT "FK_6a769cd0d90bc68cafdd533f03e"`);
await queryRunner.query(`ALTER TABLE "custody_account" DROP CONSTRAINT "FK_b89a7cbab6c121f5a092815fce3"`);
await queryRunner.query(`ALTER TABLE "custody_account_access" DROP CONSTRAINT "FK_8a4612269b283bf40950ddb8485"`);
await queryRunner.query(`ALTER TABLE "custody_account_access" DROP CONSTRAINT "FK_45213c9c7521d41be00fa5ead93"`);
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "custodyAccountId"`);
await queryRunner.query(`ALTER TABLE "custody_balance" DROP COLUMN "accountId"`);
await queryRunner.query(`ALTER TABLE "custody_order" DROP COLUMN "initiatedById"`);
await queryRunner.query(`ALTER TABLE "custody_order" DROP COLUMN "accountId"`);
await queryRunner.query(`DROP TABLE "custody_account"`);
await queryRunner.query(`DROP INDEX "IDX_380e225bfd7707fff0e4f98035" ON "custody_account_access"`);
await queryRunner.query(`DROP TABLE "custody_account_access"`);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { Body, Controller, Get, NotFoundException, Param, Post, Put, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ApiBearerAuth, ApiCreatedResponse, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { GetJwt } from 'src/shared/auth/get-jwt.decorator';
import { JwtPayload } from 'src/shared/auth/jwt-payload.interface';
import { RoleGuard } from 'src/shared/auth/role.guard';
import { UserActiveGuard } from 'src/shared/auth/user-active.guard';
import { UserRole } from 'src/shared/auth/user-role.enum';
import { CreateCustodyAccountDto } from '../dto/input/create-custody-account.dto';
import { UpdateCustodyAccountDto } from '../dto/input/update-custody-account.dto';
import { CustodyAccountAccessDto, CustodyAccountDto } from '../dto/output/custody-account.dto';
import { CustodyAccessLevel } from '../enums/custody';
import { CustodyAccountReadGuard, CustodyAccountWriteGuard } from '../guards/custody-account-access.guard';
import { CustodyAccountDtoMapper } from '../mappers/custody-account-dto.mapper';
import { CustodyAccountService, LegacyAccountId } from '../services/custody-account.service';

@ApiTags('Custody')
@Controller('custody/account')
export class CustodyAccountController {
constructor(private readonly custodyAccountService: CustodyAccountService) {}

@Get()
@ApiBearerAuth()
@UseGuards(AuthGuard(), RoleGuard(UserRole.ACCOUNT), UserActiveGuard())
@ApiOkResponse({ type: [CustodyAccountDto], description: 'List of custody accounts for the user' })
async getCustodyAccounts(@GetJwt() jwt: JwtPayload): Promise<CustodyAccountDto[]> {
return this.custodyAccountService.getCustodyAccountsForUser(jwt.account);
}

@Get(':id')
@ApiBearerAuth()
@UseGuards(AuthGuard(), RoleGuard(UserRole.ACCOUNT), UserActiveGuard(), CustodyAccountReadGuard)
@ApiOkResponse({ type: CustodyAccountDto, description: 'Custody account details' })
async getCustodyAccount(@GetJwt() jwt: JwtPayload, @Param('id') id: string): Promise<CustodyAccountDto> {
const custodyAccounts = await this.custodyAccountService.getCustodyAccountsForUser(jwt.account);

const isLegacy = id === LegacyAccountId;
const account = isLegacy ? custodyAccounts.find((ca) => ca.isLegacy) : custodyAccounts.find((ca) => ca.id === +id);
if (!account) throw new NotFoundException(`${isLegacy ? 'Legacy' : 'Custody'} account not found`);

return account;
}

@Post()
@ApiBearerAuth()
@UseGuards(AuthGuard(), RoleGuard(UserRole.ACCOUNT), UserActiveGuard())
@ApiCreatedResponse({ type: CustodyAccountDto, description: 'Create a new custody account' })
async createCustodyAccount(
@GetJwt() jwt: JwtPayload,
@Body() dto: CreateCustodyAccountDto,
): Promise<CustodyAccountDto> {
const custodyAccount = await this.custodyAccountService.createCustodyAccount(
jwt.account,
dto.title,
dto.description,
);

return CustodyAccountDtoMapper.toDto(custodyAccount, CustodyAccessLevel.WRITE);
}

@Put(':id')
@ApiBearerAuth()
@UseGuards(AuthGuard(), RoleGuard(UserRole.ACCOUNT), UserActiveGuard(), CustodyAccountWriteGuard)
@ApiOkResponse({ type: CustodyAccountDto, description: 'Update custody account' })
async updateCustodyAccount(
@GetJwt() jwt: JwtPayload,
@Param('id') id: string,
@Body() dto: UpdateCustodyAccountDto,
): Promise<CustodyAccountDto> {
const custodyAccount = await this.custodyAccountService.updateCustodyAccount(
+id,
jwt.account,
dto.title,
dto.description,
);

return CustodyAccountDtoMapper.toDto(custodyAccount, CustodyAccessLevel.WRITE);
}

@Get(':id/access')
@ApiBearerAuth()
@UseGuards(AuthGuard(), RoleGuard(UserRole.ACCOUNT), UserActiveGuard(), CustodyAccountReadGuard)
@ApiOkResponse({ type: [CustodyAccountAccessDto], description: 'List of users with access' })
async getAccessList(@GetJwt() jwt: JwtPayload, @Param('id') id: string): Promise<CustodyAccountAccessDto[]> {
const accessList = await this.custodyAccountService.getAccessList(+id, jwt.account);

return accessList.map((access) => ({
id: access.id,
user: { id: access.userData.id },
accessLevel: access.accessLevel,
}));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<CustodyAuthDto> {
return this.service.createCustodyAccount(jwt.account, dto, ip);
Expand Down
18 changes: 15 additions & 3 deletions src/subdomains/core/custody/custody.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,27 @@ import { ReferralModule } from '../referral/referral.module';
import { SellCryptoModule } from '../sell-crypto/sell-crypto.module';
import { DfxOrderStepAdapter } from './adapter/dfx-order-step.adapter';
import { CustodyAdminController, CustodyController } from './controllers/custody.controller';
import { CustodyAccountController } from './controllers/custody-account.controller';
import { CustodyBalance } from './entities/custody-balance.entity';
import { CustodyOrderStep } from './entities/custody-order-step.entity';
import { CustodyOrder } from './entities/custody-order.entity';
import { CustodyAccountAccess } from './entities/custody-account-access.entity';
import { CustodyAccount } from './entities/custody-account.entity';
import { CustodyBalanceRepository } from './repositories/custody-balance.repository';
import { CustodyOrderStepRepository } from './repositories/custody-order-step.repository';
import { CustodyOrderRepository } from './repositories/custody-order.repository';
import { CustodyAccountAccessRepository } from './repositories/custody-account-access.repository';
import { CustodyAccountRepository } from './repositories/custody-account.repository';
import { CustodyJobService } from './services/custody-job.service';
import { CustodyOrderService } from './services/custody-order.service';
import { CustodyPdfService } from './services/custody-pdf.service';
import { CustodyService } from './services/custody.service';
import { CustodyAccountService } from './services/custody-account.service';
import { CustodyAccountReadGuard, CustodyAccountWriteGuard } from './guards/custody-account-access.guard';

@Module({
imports: [
TypeOrmModule.forFeature([CustodyOrder, CustodyOrderStep]),
TypeOrmModule.forFeature([CustodyOrder, CustodyOrderStep, CustodyAccount, CustodyAccountAccess]),
forwardRef(() => UserModule),
forwardRef(() => ReferralModule),
SharedModule,
Expand All @@ -33,7 +40,7 @@ import { CustodyService } from './services/custody.service';
PricingModule,
PayoutModule,
],
controllers: [CustodyController, CustodyAdminController],
controllers: [CustodyController, CustodyAdminController, CustodyAccountController],
providers: [
CustodyService,
CustodyOrderRepository,
Expand All @@ -44,7 +51,12 @@ import { CustodyService } from './services/custody.service';
CustodyPdfService,
CustodyBalance,
CustodyBalanceRepository,
CustodyAccountRepository,
CustodyAccountAccessRepository,
CustodyAccountService,
CustodyAccountReadGuard,
CustodyAccountWriteGuard,
],
exports: [CustodyService, CustodyOrderService],
exports: [CustodyService, CustodyOrderService, CustodyAccountService],
})
export class CustodyModule {}
Original file line number Diff line number Diff line change
@@ -1,32 +1,14 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional, IsString, Matches } from 'class-validator';
import { GetConfig } from 'src/config/config';
import { Moderator } from 'src/subdomains/generic/user/models/user-data/user-data.enum';
import { CustodyAddressType } from '../../enums/custody';
import { IsOptional, IsString, MaxLength } from 'class-validator';

export class CreateCustodyAccountDto {
@ApiProperty({ enum: CustodyAddressType })
@IsEnum(CustodyAddressType)
addressType: CustodyAddressType;

@ApiPropertyOptional()
@IsOptional()
@ApiProperty({ description: 'Title of the custody account' })
@IsString()
wallet?: string;
@MaxLength(256)
title: string;

@ApiPropertyOptional()
@ApiPropertyOptional({ description: 'Description of the custody account' })
@IsOptional()
@IsString()
@Matches(GetConfig().formats.ref)
usedRef?: string;

@ApiPropertyOptional({ description: 'Special code' })
@IsOptional()
@IsString()
specialCode?: string;

@ApiPropertyOptional({ description: 'Moderator' })
@IsOptional()
@IsEnum(Moderator)
moderator?: Moderator;
description?: string;
}
32 changes: 32 additions & 0 deletions src/subdomains/core/custody/dto/input/custody-signup.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, MaxLength } from 'class-validator';

export class UpdateCustodyAccountDto {
@ApiPropertyOptional({ description: 'Title of the custody account' })
@IsOptional()
@IsString()
@MaxLength(256)
title?: string;

@ApiPropertyOptional({ description: 'Description of the custody account' })
@IsOptional()
@IsString()
description?: string;
}
38 changes: 38 additions & 0 deletions src/subdomains/core/custody/dto/output/custody-account.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { CustodyAccessLevel } from '../../enums/custody';

export class CustodyUserDto {
@ApiProperty()
id: number;
}

export class CustodyAccountDto {
@ApiPropertyOptional({ description: 'ID of the custody account (null for legacy)' })
id: number | null;

@ApiProperty({ description: 'Title of the custody account' })
title: string;

@ApiPropertyOptional({ description: 'Description of the custody account' })
description?: string;

@ApiProperty({ description: 'Whether this is a legacy account (aggregated custody users)' })
isLegacy: boolean;

@ApiProperty({ enum: CustodyAccessLevel, description: 'Access level for current user' })
accessLevel: CustodyAccessLevel;

@ApiPropertyOptional({ type: CustodyUserDto })
owner?: CustodyUserDto;
}

export class CustodyAccountAccessDto {
@ApiProperty()
id: number;

@ApiProperty({ type: CustodyUserDto })
user: CustodyUserDto;

@ApiProperty({ enum: CustodyAccessLevel })
accessLevel: CustodyAccessLevel;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { IEntity } from 'src/shared/models/entity';
import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity';
import { Column, Entity, Index, ManyToOne } from 'typeorm';
import { CustodyAccessLevel } from '../enums/custody';
import { CustodyAccount } from './custody-account.entity';

@Entity()
@Index((a: CustodyAccountAccess) => [a.account, a.userData], { unique: true })
export class CustodyAccountAccess extends IEntity {
@ManyToOne(() => CustodyAccount, (custodyAccount) => custodyAccount.accessGrants, { nullable: false })
account: CustodyAccount;

@ManyToOne(() => UserData, { nullable: false })
userData: UserData;

@Column()
accessLevel: CustodyAccessLevel;
}
26 changes: 26 additions & 0 deletions src/subdomains/core/custody/entities/custody-account.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { IEntity } from 'src/shared/models/entity';
import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity';
import { Column, Entity, ManyToOne, OneToMany } from 'typeorm';
import { CustodyAccountStatus } from '../enums/custody';
import { CustodyAccountAccess } from './custody-account-access.entity';

@Entity()
export class CustodyAccount extends IEntity {
@Column({ length: 256 })
title: string;

@Column({ length: 'MAX', nullable: true })
description?: string;

@ManyToOne(() => UserData, { nullable: false })
owner: UserData;

@Column({ type: 'int', default: 1 })
requiredSignatures: number;

@Column({ default: CustodyAccountStatus.ACTIVE })
status: CustodyAccountStatus;

@OneToMany(() => CustodyAccountAccess, (access) => access.account)
accessGrants: CustodyAccountAccess[];
}
Loading
Loading