Feature/add validation for import (#415)

* Valid data types
* Maximum number of orders
* Data provider service returns data for the dataSource / symbol pair
This commit is contained in:
Thomas Kaul 2021-10-12 22:19:32 +02:00 committed by GitHub
parent b9f0a57522
commit 93dcbeb6c7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 171 additions and 30 deletions

View File

@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Added
- Extended the validation of the import functionality for transactions
- Valid data types
- Maximum number of orders
- Data provider service returns data for the `dataSource` / `symbol` pair
### Fixed ### Fixed
- Fixed the broken line charts showing value labels - Fixed the broken line charts showing value labels

View File

@ -1,7 +1,11 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Order } from '@prisma/client'; import { Order } from '@prisma/client';
import { IsArray } from 'class-validator'; import { Type } from 'class-transformer';
import { IsArray, ValidateNested } from 'class-validator';
export class ImportDataDto { export class ImportDataDto {
@IsArray() @IsArray()
orders: Partial<Order>[]; @Type(() => CreateOrderDto)
@ValidateNested({ each: true })
orders: Order[];
} }

View File

@ -42,7 +42,10 @@ export class ImportController {
console.error(error); console.error(error);
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST), {
error: getReasonPhrase(StatusCodes.BAD_REQUEST),
message: [error.message]
},
StatusCodes.BAD_REQUEST StatusCodes.BAD_REQUEST
); );
} }

View File

@ -1,11 +1,17 @@
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Order } from '@prisma/client'; import { Order } from '@prisma/client';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
@Injectable() @Injectable()
export class ImportService { export class ImportService {
public constructor(private readonly orderService: OrderService) {} private static MAX_ORDERS_TO_IMPORT = 20;
public constructor(
private readonly dataProviderService: DataProviderService,
private readonly orderService: OrderService
) {}
public async import({ public async import({
orders, orders,
@ -14,7 +20,10 @@ export class ImportService {
orders: Partial<Order>[]; orders: Partial<Order>[];
userId: string; userId: string;
}): Promise<void> { }): Promise<void> {
await this.validateOrders(orders);
for (const { for (const {
accountId,
currency, currency,
dataSource, dataSource,
date, date,
@ -25,6 +34,11 @@ export class ImportService {
unitPrice unitPrice
} of orders) { } of orders) {
await this.orderService.createOrder({ await this.orderService.createOrder({
Account: {
connect: {
id_userId: { userId, id: accountId }
}
},
currency, currency,
dataSource, dataSource,
fee, fee,
@ -37,4 +51,20 @@ export class ImportService {
}); });
} }
} }
private async validateOrders(orders: Partial<Order>[]) {
if (orders?.length > ImportService.MAX_ORDERS_TO_IMPORT) {
throw new Error('Too many transactions');
}
for (const { dataSource, symbol } of orders) {
const result = await this.dataProviderService.get([
{ dataSource, symbol }
]);
if (result[symbol] === undefined) {
throw new Error(`${symbol} is not a valid symbol for ${dataSource}`);
}
}
}
} }

View File

@ -1,5 +1,5 @@
import { DataSource, Type } from '@prisma/client'; import { DataSource, Type } from '@prisma/client';
import { IsISO8601, IsNumber, IsString } from 'class-validator'; import { IsEnum, IsISO8601, IsNumber, IsString } from 'class-validator';
export class CreateOrderDto { export class CreateOrderDto {
@IsString() @IsString()
@ -8,7 +8,7 @@ export class CreateOrderDto {
@IsString() @IsString()
currency: string; currency: string;
@IsString() @IsEnum(DataSource, { each: true })
dataSource: DataSource; dataSource: DataSource;
@IsISO8601() @IsISO8601()
@ -23,7 +23,7 @@ export class CreateOrderDto {
@IsString() @IsString()
symbol: string; symbol: string;
@IsString() @IsEnum(Type, { each: true })
type: Type; type: Type;
@IsNumber() @IsNumber()

View File

@ -30,9 +30,9 @@ export class CurrentRateService {
{ symbol, dataSource: DataSource.YAHOO } { symbol, dataSource: DataSource.YAHOO }
]); ]);
return { return {
symbol,
date: resetHours(date), date: resetHours(date),
marketPrice: dataProviderResult?.[symbol]?.marketPrice ?? 0, marketPrice: dataProviderResult?.[symbol]?.marketPrice ?? 0
symbol: symbol
}; };
} }

View File

@ -101,7 +101,7 @@ export class HttpResponseInterceptor implements HttpInterceptor {
} }
} }
return throwError(''); return throwError(error);
}) })
); );
} }

View File

@ -35,4 +35,4 @@ import { CreateOrUpdateTransactionDialog } from './create-or-update-transaction-
providers: [], providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class CreateOrUpdateTransactionDialogModule {} export class GfCreateOrUpdateTransactionDialogModule {}

View File

@ -1,5 +1,5 @@
import { User } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces';
import { Account, Order } from '@prisma/client'; import { Order } from '@prisma/client';
export interface CreateOrUpdateTransactionDialogParams { export interface CreateOrUpdateTransactionDialogParams {
accountId: string; accountId: string;

View File

@ -0,0 +1,36 @@
import {
ChangeDetectionStrategy,
Component,
Inject,
OnDestroy
} from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Subject } from 'rxjs';
import { ImportTransactionDialogParams } from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-import-transaction-dialog',
styleUrls: ['./import-transaction-dialog.scss'],
templateUrl: 'import-transaction-dialog.html'
})
export class ImportTransactionDialog implements OnDestroy {
private unsubscribeSubject = new Subject<void>();
public constructor(
@Inject(MAT_DIALOG_DATA) public data: ImportTransactionDialogParams,
public dialogRef: MatDialogRef<ImportTransactionDialog>
) {}
public ngOnInit() {}
public onCancel(): void {
this.dialogRef.close();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -0,0 +1,23 @@
<gf-dialog-header
mat-dialog-title
title="Import Transactions Error"
[deviceType]="data.deviceType"
(closeButtonClicked)="onCancel()"
></gf-dialog-header>
<div class="flex-grow-1" mat-dialog-content>
<ul class="list-unstyled">
<li *ngFor="let message of data.messages" class="d-flex">
<div class="align-items-center d-flex px-2">
<ion-icon name="warning-outline"></ion-icon>
</div>
<div>{{ message }}</div>
</li>
</ul>
</div>
<gf-dialog-footer
mat-dialog-actions
[deviceType]="data.deviceType"
(closeButtonClicked)="onCancel()"
></gf-dialog-footer>

View File

@ -0,0 +1,23 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
import { ImportTransactionDialog } from './import-transaction-dialog.component';
@NgModule({
declarations: [ImportTransactionDialog],
exports: [],
imports: [
CommonModule,
GfDialogFooterModule,
GfDialogHeaderModule,
MatButtonModule,
MatDialogModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfImportTransactionDialogModule {}

View File

@ -0,0 +1,4 @@
export interface ImportTransactionDialogParams {
deviceType: string;
messages: string[];
}

View File

@ -16,6 +16,7 @@ import { EMPTY, Subject, Subscription } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators'; import { catchError, takeUntil } from 'rxjs/operators';
import { CreateOrUpdateTransactionDialog } from './create-or-update-transaction-dialog/create-or-update-transaction-dialog.component'; import { CreateOrUpdateTransactionDialog } from './create-or-update-transaction-dialog/create-or-update-transaction-dialog.component';
import { ImportTransactionDialog } from './import-transaction-dialog/import-transaction-dialog.component';
@Component({ @Component({
selector: 'gf-transactions-page', selector: 'gf-transactions-page',
@ -23,6 +24,7 @@ import { CreateOrUpdateTransactionDialog } from './create-or-update-transaction-
styleUrls: ['./transactions-page.scss'] styleUrls: ['./transactions-page.scss']
}) })
export class TransactionsPageComponent implements OnDestroy, OnInit { export class TransactionsPageComponent implements OnDestroy, OnInit {
public defaultAccountId: string;
public deviceType: string; public deviceType: string;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public hasPermissionToCreateOrder: boolean; public hasPermissionToCreateOrder: boolean;
@ -93,6 +95,10 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
this.defaultAccountId = this.user?.accounts.find((account) => {
return account.isDefault;
})?.id;
this.hasPermissionToCreateOrder = hasPermission( this.hasPermissionToCreateOrder = hasPermission(
this.user.permissions, this.user.permissions,
permissions.createOrder permissions.createOrder
@ -175,7 +181,9 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
this.dataService this.dataService
.postImport({ .postImport({
orders: content.orders orders: content.orders.map((order) => {
return { ...order, accountId: this.defaultAccountId };
})
}) })
.pipe( .pipe(
catchError((error) => { catchError((error) => {
@ -195,7 +203,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
} }
}); });
} catch (error) { } catch (error) {
this.handleImportError(error); this.handleImportError({ error: { message: ['Unexpected format'] } });
} }
}; };
}; };
@ -281,20 +289,23 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
a.click(); a.click();
} }
private handleImportError(aError: unknown) { private handleImportError(aError: any) {
console.error(aError); this.snackBar.dismiss();
this.snackBar.open('❌ Oops, something went wrong...');
this.dialog.open(ImportTransactionDialog, {
data: {
deviceType: this.deviceType,
messages: aError?.error?.message
},
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
} }
private openCreateTransactionDialog(aTransaction?: OrderModel): void { private openCreateTransactionDialog(aTransaction?: OrderModel): void {
const dialogRef = this.dialog.open(CreateOrUpdateTransactionDialog, { const dialogRef = this.dialog.open(CreateOrUpdateTransactionDialog, {
data: { data: {
transaction: { transaction: {
accountId: accountId: aTransaction?.accountId ?? this.defaultAccountId,
aTransaction?.accountId ??
this.user?.accounts.find((account) => {
return account.isDefault;
})?.id,
currency: aTransaction?.currency ?? null, currency: aTransaction?.currency ?? null,
dataSource: aTransaction?.dataSource ?? null, dataSource: aTransaction?.dataSource ?? null,
date: new Date(), date: new Date(),

View File

@ -5,7 +5,8 @@ import { MatSnackBarModule } from '@angular/material/snack-bar';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfTransactionsTableModule } from '@ghostfolio/client/components/transactions-table/transactions-table.module'; import { GfTransactionsTableModule } from '@ghostfolio/client/components/transactions-table/transactions-table.module';
import { CreateOrUpdateTransactionDialogModule } from './create-or-update-transaction-dialog/create-or-update-transaction-dialog.module'; import { GfCreateOrUpdateTransactionDialogModule } from './create-or-update-transaction-dialog/create-or-update-transaction-dialog.module';
import { GfImportTransactionDialogModule } from './import-transaction-dialog/import-transaction-dialog.module';
import { TransactionsPageRoutingModule } from './transactions-page-routing.module'; import { TransactionsPageRoutingModule } from './transactions-page-routing.module';
import { TransactionsPageComponent } from './transactions-page.component'; import { TransactionsPageComponent } from './transactions-page.component';
@ -14,7 +15,8 @@ import { TransactionsPageComponent } from './transactions-page.component';
exports: [], exports: [],
imports: [ imports: [
CommonModule, CommonModule,
CreateOrUpdateTransactionDialogModule, GfCreateOrUpdateTransactionDialogModule,
GfImportTransactionDialogModule,
GfTransactionsTableModule, GfTransactionsTableModule,
MatButtonModule, MatButtonModule,
MatSnackBarModule, MatSnackBarModule,

View File

@ -39,18 +39,13 @@ import { cloneDeep } from 'lodash';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { SettingsStorageService } from './settings-storage.service';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class DataService { export class DataService {
private info: InfoItem; private info: InfoItem;
public constructor( public constructor(private http: HttpClient) {}
private http: HttpClient,
private settingsStorageService: SettingsStorageService
) {}
public createCheckoutSession({ public createCheckoutSession({
couponId, couponId,