From c7b7efae3b92eec0bed60a97e8ca707aabdfa20b Mon Sep 17 00:00:00 2001 From: Thomas <4159106+dtslvr@users.noreply.github.com> Date: Wed, 14 Jul 2021 20:54:05 +0200 Subject: [PATCH] Feature/import transactions (#212) * Implement import transactions functionality * Update changelog --- CHANGELOG.md | 4 + apps/api/src/app/app.module.ts | 2 + apps/api/src/app/import/import-data.dto.ts | 7 ++ apps/api/src/app/import/import.controller.ts | 50 +++++++++++++ apps/api/src/app/import/import.module.ts | 34 +++++++++ apps/api/src/app/import/import.service.ts | 43 +++++++++++ apps/api/src/app/info/info.service.ts | 4 + .../api/src/services/configuration.service.ts | 2 + .../interfaces/environment.interface.ts | 1 + .../transactions-table.component.html | 18 ++++- .../transactions-table.component.ts | 6 ++ .../transactions-page.component.ts | 73 ++++++++++++++++++- .../pages/transactions/transactions-page.html | 2 + .../transactions/transactions-page.module.ts | 2 + apps/client/src/app/services/data.service.ts | 5 ++ libs/common/src/lib/permissions.ts | 1 + 16 files changed, 251 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/app/import/import-data.dto.ts create mode 100644 apps/api/src/app/import/import.controller.ts create mode 100644 apps/api/src/app/import/import.module.ts create mode 100644 apps/api/src/app/import/import.service.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 04532e7b3..fef8a7c7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Added the import functionality for transactions + ### Changed - Upgraded `angular-material-css-vars` from version `2.0.0` to `2.1.0` diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 0ddeca1ad..6eed799bb 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -24,6 +24,7 @@ import { AuthModule } from './auth/auth.module'; import { CacheModule } from './cache/cache.module'; import { ExperimentalModule } from './experimental/experimental.module'; import { ExportModule } from './export/export.module'; +import { ImportModule } from './import/import.module'; import { InfoModule } from './info/info.module'; import { OrderModule } from './order/order.module'; import { PortfolioModule } from './portfolio/portfolio.module'; @@ -43,6 +44,7 @@ import { UserModule } from './user/user.module'; ConfigModule.forRoot(), ExperimentalModule, ExportModule, + ImportModule, InfoModule, OrderModule, PortfolioModule, diff --git a/apps/api/src/app/import/import-data.dto.ts b/apps/api/src/app/import/import-data.dto.ts new file mode 100644 index 000000000..c53cde073 --- /dev/null +++ b/apps/api/src/app/import/import-data.dto.ts @@ -0,0 +1,7 @@ +import { Order } from '@prisma/client'; +import { IsArray } from 'class-validator'; + +export class ImportDataDto { + @IsArray() + orders: Partial[]; +} diff --git a/apps/api/src/app/import/import.controller.ts b/apps/api/src/app/import/import.controller.ts new file mode 100644 index 000000000..c5d7f4a6d --- /dev/null +++ b/apps/api/src/app/import/import.controller.ts @@ -0,0 +1,50 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; +import { RequestWithUser } from '@ghostfolio/common/types'; +import { + Body, + Controller, + HttpException, + Inject, + Post, + UseGuards +} from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +import { ImportDataDto } from './import-data.dto'; +import { ImportService } from './import.service'; + +@Controller('import') +export class ImportController { + public constructor( + private readonly configurationService: ConfigurationService, + private readonly importService: ImportService, + @Inject(REQUEST) private readonly request: RequestWithUser + ) {} + + @Post() + @UseGuards(AuthGuard('jwt')) + public async import(@Body() importData: ImportDataDto): Promise { + if (!this.configurationService.get('ENABLE_FEATURE_IMPORT')) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + try { + return await this.importService.import({ + orders: importData.orders, + userId: this.request.user.id + }); + } catch (error) { + console.error(error); + + throw new HttpException( + getReasonPhrase(StatusCodes.BAD_REQUEST), + StatusCodes.BAD_REQUEST + ); + } + } +} diff --git a/apps/api/src/app/import/import.module.ts b/apps/api/src/app/import/import.module.ts new file mode 100644 index 000000000..02efd6f51 --- /dev/null +++ b/apps/api/src/app/import/import.module.ts @@ -0,0 +1,34 @@ +import { CacheService } from '@ghostfolio/api/app/cache/cache.service'; +import { OrderService } from '@ghostfolio/api/app/order/order.service'; +import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; +import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; +import { DataProviderService } from '@ghostfolio/api/services/data-provider.service'; +import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service'; +import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service'; +import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service'; +import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service'; +import { PrismaService } from '@ghostfolio/api/services/prisma.service'; +import { Module } from '@nestjs/common'; + +import { ImportController } from './import.controller'; +import { ImportService } from './import.service'; + +@Module({ + imports: [RedisCacheModule], + controllers: [ImportController], + providers: [ + AlphaVantageService, + CacheService, + ConfigurationService, + DataGatheringService, + DataProviderService, + GhostfolioScraperApiService, + ImportService, + OrderService, + PrismaService, + RakutenRapidApiService, + YahooFinanceService + ] +}) +export class ImportModule {} diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts new file mode 100644 index 000000000..6038a0783 --- /dev/null +++ b/apps/api/src/app/import/import.service.ts @@ -0,0 +1,43 @@ +import { OrderService } from '@ghostfolio/api/app/order/order.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) {} + + public async import({ + orders, + userId + }: { + orders: Partial[]; + userId: string; + }): Promise { + for (const { + currency, + dataSource, + date, + fee, + quantity, + symbol, + type, + unitPrice + } of orders) { + await this.orderService.createOrder( + { + currency, + dataSource, + fee, + quantity, + symbol, + type, + unitPrice, + date: parseISO((date)), + User: { connect: { id: userId } } + }, + userId + ); + } + } +} diff --git a/apps/api/src/app/info/info.service.ts b/apps/api/src/app/info/info.service.ts index a293ef4e6..54ee3a2c2 100644 --- a/apps/api/src/app/info/info.service.ts +++ b/apps/api/src/app/info/info.service.ts @@ -27,6 +27,10 @@ export class InfoService { const globalPermissions: string[] = []; + if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) { + globalPermissions.push(permissions.enableImport); + } + if (this.configurationService.get('ENABLE_FEATURE_SOCIAL_LOGIN')) { globalPermissions.push(permissions.enableSocialLogin); } diff --git a/apps/api/src/services/configuration.service.ts b/apps/api/src/services/configuration.service.ts index f605032e5..1ba240f4f 100644 --- a/apps/api/src/services/configuration.service.ts +++ b/apps/api/src/services/configuration.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { DataSource } from '@prisma/client'; import { bool, cleanEnv, host, json, num, port, str } from 'envalid'; +import { environment } from '../environments/environment'; import { Environment } from './interfaces/environment.interface'; @Injectable() @@ -16,6 +17,7 @@ export class ConfigurationService { DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }), ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }), ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }), + ENABLE_FEATURE_IMPORT: bool({ default: !environment.production }), ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }), ENABLE_FEATURE_STATISTICS: bool({ default: false }), ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }), diff --git a/apps/api/src/services/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts index 520682247..990922f25 100644 --- a/apps/api/src/services/interfaces/environment.interface.ts +++ b/apps/api/src/services/interfaces/environment.interface.ts @@ -7,6 +7,7 @@ export interface Environment extends CleanedEnvAccessors { DATA_SOURCES: string | string[]; // string is not correct, error in envalid? ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean; ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean; + ENABLE_FEATURE_IMPORT: boolean; ENABLE_FEATURE_SOCIAL_LOGIN: boolean; ENABLE_FEATURE_STATISTICS: boolean; ENABLE_FEATURE_SUBSCRIPTION: boolean; diff --git a/apps/client/src/app/components/transactions-table/transactions-table.component.html b/apps/client/src/app/components/transactions-table/transactions-table.component.html index 32d960751..bdb670d71 100644 --- a/apps/client/src/app/components/transactions-table/transactions-table.component.html +++ b/apps/client/src/app/components/transactions-table/transactions-table.component.html @@ -212,7 +212,23 @@ - + + diff --git a/apps/client/src/app/components/transactions-table/transactions-table.component.ts b/apps/client/src/app/components/transactions-table/transactions-table.component.ts index fb20ab647..b2ac1637d 100644 --- a/apps/client/src/app/components/transactions-table/transactions-table.component.ts +++ b/apps/client/src/app/components/transactions-table/transactions-table.component.ts @@ -43,11 +43,13 @@ export class TransactionsTableComponent { @Input() baseCurrency: string; @Input() deviceType: string; + @Input() hasPermissionToImportOrders: boolean; @Input() locale: string; @Input() showActions: boolean; @Input() transactions: OrderWithAccount[]; @Output() export = new EventEmitter(); + @Output() import = new EventEmitter(); @Output() transactionDeleted = new EventEmitter(); @Output() transactionToClone = new EventEmitter(); @Output() transactionToUpdate = new EventEmitter(); @@ -190,6 +192,10 @@ export class TransactionsTableComponent this.export.emit(); } + public onImport() { + this.import.emit(); + } + public onOpenPositionDialog({ symbol, title diff --git a/apps/client/src/app/pages/transactions/transactions-page.component.ts b/apps/client/src/app/pages/transactions/transactions-page.component.ts index 78db0273f..de52de282 100644 --- a/apps/client/src/app/pages/transactions/transactions-page.component.ts +++ b/apps/client/src/app/pages/transactions/transactions-page.component.ts @@ -1,5 +1,6 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; import { ActivatedRoute, Router } from '@angular/router'; import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto'; @@ -9,10 +10,11 @@ import { UserService } from '@ghostfolio/client/services/user/user.service'; import { User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { Order as OrderModel } from '@prisma/client'; +import { environment } from 'apps/client/src/environments/environment'; import { format, parseISO } from 'date-fns'; import { DeviceDetectorService } from 'ngx-device-detector'; -import { Subject, Subscription } from 'rxjs'; -import { takeUntil } from 'rxjs/operators'; +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'; @@ -26,6 +28,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit { public hasImpersonationId: boolean; public hasPermissionToCreateOrder: boolean; public hasPermissionToDeleteOrder: boolean; + public hasPermissionToImportOrders: boolean; public routeQueryParams: Subscription; public transactions: OrderModel[]; public user: User; @@ -43,6 +46,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit { private impersonationStorageService: ImpersonationStorageService, private route: ActivatedRoute, private router: Router, + private snackBar: MatSnackBar, private userService: UserService ) { this.routeQueryParams = route.queryParams @@ -68,6 +72,18 @@ export class TransactionsPageComponent implements OnDestroy, OnInit { * Initializes the controller */ public ngOnInit() { + this.dataService + .fetchInfo() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(({ globalPermissions }) => { + this.hasPermissionToImportOrders = hasPermission( + globalPermissions, + permissions.enableImport + ); + + this.changeDetectorRef.markForCheck(); + }); + this.deviceType = this.deviceService.getDeviceInfo().deviceType; this.impersonationStorageService @@ -145,6 +161,54 @@ export class TransactionsPageComponent implements OnDestroy, OnInit { }); } + public onImport() { + const input = document.createElement('input'); + input.type = 'file'; + + input.onchange = (event) => { + // Getting the file reference + const file = (event.target as HTMLInputElement).files[0]; + + // Setting up the reader + const reader = new FileReader(); + reader.readAsText(file, 'UTF-8'); + + reader.onload = (readerEvent) => { + try { + const content = JSON.parse(readerEvent.target.result as string); + + this.snackBar.open('⏳ Importing data...'); + + this.dataService + .postImport({ + orders: content.orders + }) + .pipe( + catchError((error) => { + this.handleImportError(error); + + return EMPTY; + }), + takeUntil(this.unsubscribeSubject) + ) + .subscribe({ + next: () => { + this.fetchOrders(); + + this.snackBar.open('✅ Import has been completed', undefined, { + duration: 3000 + }); + } + }); + } catch (error) { + this.handleImportError(error); + } + }; + }; + + input.click(); + } + public onUpdateTransaction(aTransaction: OrderModel) { this.router.navigate([], { queryParams: { editDialog: true, transactionId: aTransaction.id } @@ -223,6 +287,11 @@ export class TransactionsPageComponent implements OnDestroy, OnInit { a.click(); } + private handleImportError(aError: unknown) { + console.error(aError); + this.snackBar.open('❌ Oops, something went wrong...'); + } + private openCreateTransactionDialog(aTransaction?: OrderModel): void { const dialogRef = this.dialog.open(CreateOrUpdateTransactionDialog, { data: { diff --git a/apps/client/src/app/pages/transactions/transactions-page.html b/apps/client/src/app/pages/transactions/transactions-page.html index e6488287e..83ed79060 100644 --- a/apps/client/src/app/pages/transactions/transactions-page.html +++ b/apps/client/src/app/pages/transactions/transactions-page.html @@ -5,10 +5,12 @@ (`/api/account`, aAccount); } + public postImport(aImportData: ImportDataDto) { + return this.http.post('/api/import', aImportData); + } + public postOrder(aOrder: CreateOrderDto) { return this.http.post(`/api/order`, aOrder); } diff --git a/libs/common/src/lib/permissions.ts b/libs/common/src/lib/permissions.ts index fe6c0d066..5cc26ffd9 100644 --- a/libs/common/src/lib/permissions.ts +++ b/libs/common/src/lib/permissions.ts @@ -14,6 +14,7 @@ export const permissions = { deleteAuthDevice: 'deleteAuthDevice', deleteOrder: 'deleteOrder', deleteUser: 'deleteUser', + enableImport: 'enableImport', enableSocialLogin: 'enableSocialLogin', enableStatistics: 'enableStatistics', enableSubscription: 'enableSubscription',