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:
parent
b9f0a57522
commit
93dcbeb6c7
@ -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,3 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
@ -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…
x
Reference in New Issue
Block a user