Feature/add validation for import (#415)

* Valid data types
* Maximum number of orders
* Data provider service returns data for the dataSource / symbol pair
pull/416/head
Thomas Kaul 3 years ago committed by GitHub
parent b9f0a57522
commit 93dcbeb6c7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## 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 the broken line charts showing value labels

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

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

@ -1,11 +1,17 @@
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 { Order } from '@prisma/client';
import { parseISO } from 'date-fns';
@Injectable()
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({
orders,
@ -14,7 +20,10 @@ export class ImportService {
orders: Partial<Order>[];
userId: string;
}): Promise<void> {
await this.validateOrders(orders);
for (const {
accountId,
currency,
dataSource,
date,
@ -25,6 +34,11 @@ export class ImportService {
unitPrice
} of orders) {
await this.orderService.createOrder({
Account: {
connect: {
id_userId: { userId, id: accountId }
}
},
currency,
dataSource,
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}`);
}
}
}
}

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

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

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

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

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

@ -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();
}
}

@ -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>

@ -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 {}

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

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

@ -5,7 +5,8 @@ import { MatSnackBarModule } from '@angular/material/snack-bar';
import { RouterModule } from '@angular/router';
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 { TransactionsPageComponent } from './transactions-page.component';
@ -14,7 +15,8 @@ import { TransactionsPageComponent } from './transactions-page.component';
exports: [],
imports: [
CommonModule,
CreateOrUpdateTransactionDialogModule,
GfCreateOrUpdateTransactionDialogModule,
GfImportTransactionDialogModule,
GfTransactionsTableModule,
MatButtonModule,
MatSnackBarModule,

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

Loading…
Cancel
Save