From 3e9df51bf751e75dfb00cc16fda96c91568c2937 Mon Sep 17 00:00:00 2001 From: yksolanki9 Date: Wed, 25 Jan 2023 20:32:12 +0530 Subject: [PATCH] Add logic for importing accounts --- .../api/src/app/account/create-account.dto.ts | 8 ++ apps/api/src/app/import/import-data.dto.ts | 9 ++- apps/api/src/app/import/import.controller.ts | 11 ++- apps/api/src/app/import/import.service.ts | 73 +++++++++++++++++++ .../import-activities-dialog.component.ts | 19 +++-- .../app/services/import-activities.service.ts | 43 +++++++---- 6 files changed, 142 insertions(+), 21 deletions(-) diff --git a/apps/api/src/app/account/create-account.dto.ts b/apps/api/src/app/account/create-account.dto.ts index 3ea13e20a..3c32163e0 100644 --- a/apps/api/src/app/account/create-account.dto.ts +++ b/apps/api/src/app/account/create-account.dto.ts @@ -8,6 +8,14 @@ import { } from 'class-validator'; export class CreateAccountDto { + @IsOptional() + @IsString() + id: string; + + @IsOptional() + @IsBoolean() + isDefault: boolean; + @IsString() accountType: AccountType; diff --git a/apps/api/src/app/import/import-data.dto.ts b/apps/api/src/app/import/import-data.dto.ts index f3a0ba8fe..aa141aab5 100644 --- a/apps/api/src/app/import/import-data.dto.ts +++ b/apps/api/src/app/import/import-data.dto.ts @@ -1,8 +1,15 @@ import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { Type } from 'class-transformer'; -import { IsArray, ValidateNested } from 'class-validator'; +import { IsArray, ValidateNested, IsOptional } from 'class-validator'; +import { CreateAccountDto } from '../account/create-account.dto'; export class ImportDataDto { + @IsOptional() + @IsArray() + @Type(() => CreateAccountDto) + @ValidateNested({ each: true }) + accounts: CreateAccountDto[]; + @IsArray() @Type(() => CreateOrderDto) @ValidateNested({ each: true }) diff --git a/apps/api/src/app/import/import.controller.ts b/apps/api/src/app/import/import.controller.ts index 2591ab638..a1a1d4b1d 100644 --- a/apps/api/src/app/import/import.controller.ts +++ b/apps/api/src/app/import/import.controller.ts @@ -2,6 +2,7 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interce import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ImportResponse } from '@ghostfolio/common/interfaces'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import type { RequestWithUser } from '@ghostfolio/common/types'; import { Body, @@ -38,7 +39,14 @@ export class ImportController { @Body() importData: ImportDataDto, @Query('dryRun') isDryRun?: boolean ): Promise { - if (!this.configurationService.get('ENABLE_FEATURE_IMPORT')) { + if ( + !this.configurationService.get('ENABLE_FEATURE_IMPORT') || + (importData.accounts?.length > 0 && + !hasPermission( + this.request.user.permissions, + permissions.createAccount + )) + ) { throw new HttpException( getReasonPhrase(StatusCodes.FORBIDDEN), StatusCodes.FORBIDDEN @@ -64,6 +72,7 @@ export class ImportController { isDryRun, userCurrency, activitiesDto: importData.activities, + accountsDto: importData.accounts || [], userId: this.request.user.id }); diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index d3be33bbc..2d09ca761 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -17,6 +17,7 @@ import { SymbolProfile } from '@prisma/client'; import Big from 'big.js'; import { endOfToday, isAfter, isSameDay, parseISO } from 'date-fns'; import { v4 as uuidv4 } from 'uuid'; +import { CreateAccountDto } from '../account/create-account.dto'; @Injectable() export class ImportService { @@ -101,17 +102,84 @@ export class ImportService { public async import({ activitiesDto, + accountsDto, isDryRun = false, maxActivitiesToImport, userCurrency, userId }: { activitiesDto: Partial[]; + accountsDto: Partial[]; isDryRun?: boolean; maxActivitiesToImport: number; userCurrency: string; userId: string; }): Promise { + const accountMappings = {}; + //Validate accounts + if (accountsDto?.length) { + for (let account of accountsDto) { + const existingAccounts = await this.accountService.accounts({ + where: { id: account.id } + }); + + const oldAccountId = account.id; + const platformId = account.platformId; + + delete account.id; + delete account.platformId; + delete account.isDefault; + + //If account id does not exist, then create a new one + if (existingAccounts.length === 0) { + const newAccountConfig = { + ...account, + User: { connect: { id: userId } } + }; + + if (platformId) { + Object.assign(newAccountConfig, { + Platform: { connect: { id: platformId } } + }); + } + + const newAccount = await this.accountService.createAccount( + newAccountConfig, + userId + ); + + accountMappings[oldAccountId] = newAccount.id; + continue; + } + + //If account id is used, then check if it belongs to the same user + //Yes -> Merge the accounts and don't create a new one + if (existingAccounts[0].userId === userId) { + continue; + } + + //No -> Replace the account id with a new account id as well as update all the activities when looping + + const newAccountConfig = { + ...account, + User: { connect: { id: userId } } + }; + + if (platformId) { + Object.assign(newAccountConfig, { + Platform: { connect: { id: platformId } } + }); + } + + const newAccount = await this.accountService.createAccount( + newAccountConfig, + userId + ); + + accountMappings[oldAccountId] = newAccount.id; + } + } + for (const activity of activitiesDto) { if (!activity.dataSource) { if (activity.type === 'ITEM') { @@ -120,6 +188,11 @@ export class ImportService { activity.dataSource = this.dataProviderService.getPrimaryDataSource(); } } + + //If we updated the account id, then update the account id in the activity as well + if (Object.keys(accountMappings).includes(activity.accountId)) { + activity.accountId = accountMappings[activity.accountId]; + } } const assetProfiles = await this.validateActivities({ diff --git a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts index b7c143c37..b5d7b0a83 100644 --- a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts @@ -11,6 +11,7 @@ import { MatLegacyDialogRef as MatDialogRef } from '@angular/material/legacy-dialog'; import { MatLegacySnackBar as MatSnackBar } from '@angular/material/legacy-snack-bar'; +import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { DataService } from '@ghostfolio/client/services/data.service'; import { ImportActivitiesService } from '@ghostfolio/client/services/import-activities.service'; @@ -28,6 +29,7 @@ import { ImportActivitiesDialogParams } from './interfaces/interfaces'; templateUrl: 'import-activities-dialog.html' }) export class ImportActivitiesDialog implements OnDestroy { + public accounts: CreateAccountDto[] = []; public activities: Activity[] = []; public details: any[] = []; public errorMessages: string[] = []; @@ -91,9 +93,10 @@ export class ImportActivitiesDialog implements OnDestroy { try { this.snackBar.open('⏳ ' + $localize`Importing data...`); - await this.importActivitiesService.importSelectedActivities( - this.selectedActivities - ); + await this.importActivitiesService.importSelectedActivities({ + accounts: this.accounts, + activities: this.selectedActivities + }); this.snackBar.open( '✅ ' + $localize`Import has been completed`, @@ -180,10 +183,13 @@ export class ImportActivitiesDialog implements OnDestroy { } try { - this.activities = await this.importActivitiesService.importJson({ - content: content.activities, + const data = await this.importActivitiesService.importJson({ + activities: content.activities, + accounts: content.accounts, isDryRun: true }); + this.activities = data.activities; + this.accounts = data.accounts; } catch (error) { console.error(error); this.handleImportError({ error, activities: content.activities }); @@ -192,11 +198,12 @@ export class ImportActivitiesDialog implements OnDestroy { return; } else if (file.name.endsWith('.csv')) { try { - this.activities = await this.importActivitiesService.importCsv({ + const data = await this.importActivitiesService.importCsv({ fileContent, isDryRun: true, userAccounts: this.data.user.accounts }); + this.activities = data.activities; } catch (error) { console.error(error); this.handleImportError({ diff --git a/apps/client/src/app/services/import-activities.service.ts b/apps/client/src/app/services/import-activities.service.ts index 2e15f367f..c2b0bbe05 100644 --- a/apps/client/src/app/services/import-activities.service.ts +++ b/apps/client/src/app/services/import-activities.service.ts @@ -1,5 +1,6 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; +import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto'; import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Account, DataSource, Type } from '@prisma/client'; @@ -33,7 +34,10 @@ export class ImportActivitiesService { fileContent: string; isDryRun?: boolean; userAccounts: Account[]; - }): Promise { + }): Promise<{ + accounts?: CreateAccountDto[]; + activities: Activity[]; + }> { const content = csvToJson(fileContent, { dynamicTyping: true, header: true, @@ -55,20 +59,26 @@ export class ImportActivitiesService { }); } - return await this.importJson({ isDryRun, content: activities }); + return await this.importJson({ isDryRun, activities }); } public importJson({ - content, + activities, + accounts, isDryRun = false }: { - content: CreateOrderDto[]; + activities: CreateOrderDto[]; + accounts?: CreateAccountDto[]; isDryRun?: boolean; - }): Promise { + }): Promise<{ + accounts?: CreateAccountDto[]; + activities: Activity[]; + }> { return new Promise((resolve, reject) => { this.postImport( { - activities: content + activities, + accounts }, isDryRun ) @@ -80,22 +90,29 @@ export class ImportActivitiesService { ) .subscribe({ next: (data) => { - resolve(data.activities); + resolve(data); } }); }); } - public importSelectedActivities( - selectedActivities: Activity[] - ): Promise { + public importSelectedActivities({ + accounts, + activities + }: { + accounts?: CreateAccountDto[]; + activities: Activity[]; + }): Promise<{ + accounts?: CreateAccountDto[]; + activities: Activity[]; + }> { const importData: CreateOrderDto[] = []; - for (const activity of selectedActivities) { + for (const activity of activities) { importData.push(this.convertToCreateOrderDto(activity)); } - return this.importJson({ content: importData }); + return this.importJson({ activities: importData, accounts }); } private convertToCreateOrderDto({ @@ -347,7 +364,7 @@ export class ImportActivitiesService { } private postImport( - aImportData: { activities: CreateOrderDto[] }, + aImportData: { activities: CreateOrderDto[]; accounts: CreateAccountDto[] }, aIsDryRun = false ) { return this.http.post<{ activities: Activity[] }>(