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.controller.ts b/apps/api/src/app/import/import.controller.ts new file mode 100644 index 000000000..5f89e6644 --- /dev/null +++ b/apps/api/src/app/import/import.controller.ts @@ -0,0 +1,54 @@ +import { environment } from '@ghostfolio/api/environments/environment'; +import { RequestWithUser } from '@ghostfolio/common/types'; +import { + Controller, + HttpException, + Inject, + Post, + UseGuards +} from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { Order } from '@prisma/client'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +import { ImportService } from './import.service'; + +@Controller('import') +export class ImportController { + public constructor( + private readonly importService: ImportService, + @Inject(REQUEST) private readonly request: RequestWithUser + ) {} + + @Post() + @UseGuards(AuthGuard('jwt')) + public async import(): Promise { + if (environment.production) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + let orders: Partial[]; + + try { + // TODO: Wire with file upload + const data = { orders: [] }; + orders = data.orders; + + return await this.importService.import({ + 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/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..b4869e690 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,6 +10,7 @@ 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'; @@ -26,6 +28,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit { public hasImpersonationId: boolean; public hasPermissionToCreateOrder: boolean; public hasPermissionToDeleteOrder: boolean; + public hasPermissionToImportOrders = !environment.production; 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 @@ -145,6 +149,23 @@ export class TransactionsPageComponent implements OnDestroy, OnInit { }); } + public onImport() { + this.snackBar.open('⏳ Importing data...'); + + this.dataService + .postImport() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe({ + next: () => { + this.fetchOrders(); + + this.snackBar.open('✅ Import has been completed', undefined, { + duration: 3000 + }); + } + }); + } + public onUpdateTransaction(aTransaction: OrderModel) { this.router.navigate([], { queryParams: { editDialog: true, transactionId: aTransaction.id } 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() { + return this.http.post('/api/import', {}); + } + public postOrder(aOrder: CreateOrderDto) { return this.http.post(`/api/order`, aOrder); }