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
|
## 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
|
||||||
|
@ -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[];
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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()
|
||||||
|
@ -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
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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: [],
|
providers: [],
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
export class CreateOrUpdateTransactionDialogModule {}
|
export class GfCreateOrUpdateTransactionDialogModule {}
|
||||||
|
@ -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;
|
||||||
|
@ -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 { 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(),
|
||||||
|
@ -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,
|
||||||
|
@ -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,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user