Compare commits

...

61 Commits

Author SHA1 Message Date
2aedd74480 Release 1.89.0 (#534) 2021-12-11 17:23:29 +01:00
11076592d1 Do not log coupon code (#533) 2021-12-11 17:12:24 +01:00
ebee851b23 Feature/support data gathering by symbol and date (#532)
* Support data gathering by symbol and date

* Update changelog
2021-12-11 11:45:13 +01:00
7d3f1832b4 Feature/upgrade nx and storybook dependencies (#531)
* Upgrade dependencies
  * nx
  * storybook

* Update changelog
2021-12-10 19:50:46 +01:00
39e6abfc8c Clean up preview features (#530) 2021-12-10 19:43:53 +01:00
78e0fdb0ca Add coupon system (#529)
* Add coupon system

* Update changelog
2021-12-09 21:14:44 +01:00
606350b2ff Release 1.87.0 (#526) 2021-12-07 20:34:52 +01:00
d09cad4e05 Feature/read only mode (#520)
* Setup read only mode and update permissions dynamically

* Update changelog
2021-12-07 20:24:15 +01:00
069660afe4 Feature/increase fear and greed index to 10 days (#525)
* Increase to 10 days

* Update changelog
2021-12-07 19:10:40 +01:00
4d9a223491 Feature/add system message (#519)
* Add system message

* Update changelog
2021-12-06 20:51:38 +01:00
aed8f5cf04 Feature/upgrade prisma to version 3.6.0 (#518)
* Upgrade prisma from version 2.30.2 to 3.6.0

* Update changelog
2021-12-05 16:52:24 +01:00
1beb4de62f Feature/support additional currencies (#517)
* Support additional currencies

* Update changelog
2021-12-04 21:05:11 +01:00
9bc3505ded Release 1.86.0 (#516) 2021-12-04 11:51:06 +01:00
3e82de6b21 Feature/add historical data chart of fear and greed index (#515)
* Add historical data chart of market mood

* Update changelog
2021-12-04 11:49:00 +01:00
563f354e7e Feature/symbol to uppercase to avoid duplicates (#514)
* Convert the symbol to uppercase to avoid case-sensitive duplicates

* Update changelog
2021-12-04 11:40:12 +01:00
e96e6c717c Feature/enable import by default (#513)
* Enable import by default

* Update changelog
2021-12-04 08:57:22 +01:00
961774ce9f Feature/improve market data detail (#511)
* Improve historical data view (hide invalid and future dates)

* Update changelog
2021-12-03 21:24:05 +01:00
49f46e1a1e Bugfix/improve allocations by currency with cash balances (#508)
* Improve allocations by currency in combination with cash balances

* Update changelog
2021-12-02 21:53:38 +01:00
050c0a4da7 Release 1.85.0 (#506) 2021-12-01 21:20:33 +01:00
4908e6d35d Bugfix/fix data gathering of fear and greed index (#505)
* Fix data gathering of fear and greed index

* Update changelog
2021-12-01 21:18:46 +01:00
fe4013830d Release 1.84.0 (#504) 2021-11-30 21:07:17 +01:00
11be6f630f Feature/expose data gathering by symbol (#503)
* Expose data gathering by symbol as endpoint

* Update changelog
2021-11-30 21:06:10 +01:00
85d123e1b1 Fix colspan (#502) 2021-11-29 21:39:54 +01:00
c5e9804c25 Release 1.83.0 (#501) 2021-11-29 21:17:20 +01:00
1f042ee791 Feature/eliminate redundant storage of historical exchange rates (#500)
* Eliminate redundant storage of historical exchange rates

* Clean up experimental API

* Update changelog
2021-11-29 21:08:58 +01:00
da6eaa0d77 Harmonize error log (#499) 2021-11-29 21:01:53 +01:00
3f31cec859 Release 1.82.0 (#498) 2021-11-28 19:58:00 +01:00
6c07759eb7 Feature/add market data tab to admin control panel (#497)
* Add market data tab

* Update changelog
2021-11-28 19:46:34 +01:00
fcf07a0fd1 Clean up (#496) 2021-11-28 13:26:26 +01:00
2f402c0c8e Feature/introduce tabs with routing to home page (#495)
* Introduce tabs with routing

* Update changelog
2021-11-28 12:52:37 +01:00
a24a094407 Feature/introduce tabs to admin control panel (#494)
* Add tabs

* Update changelog
2021-11-28 12:34:10 +01:00
dc9b2ce194 Release 1.81.0 (#493) 2021-11-27 20:57:32 +01:00
72067459d6 Feature/add value to position detail dialog (#492)
* Add value to position detail dialog

* Update changelog
2021-11-27 09:51:08 +01:00
705441ecf8 Bugfix/fix line chart labels (#491)
* Fix line chart labels

* Fix click event for drafts

* Update changelog
2021-11-26 20:41:44 +01:00
fbd1475402 Feature/upgrade core dependencies (#490)
* Upgrade core dependencies

  * angular
  * nestjs
  * Nx
  * rxjs
  * storybook

* Temporarily fix imports for storybook

* Update changelog
2021-11-25 18:05:02 +01:00
4dc4f13f40 fix storybook after currency changed to string (#488) 2021-11-23 20:58:41 +01:00
3b857aa8bb Release 1.80.0 (#487) 2021-11-23 20:28:50 +01:00
1c2ca5b96b Feature/accentuate all time high and low (#428)
* Accentuate all time high and all time low

* Update changelog

Co-authored-by: Valentin Zickner <ghostfolio@zickner.ch>
2021-11-22 21:28:32 +01:00
572bfc59b8 Add guards (#486) 2021-11-22 20:35:25 +01:00
147f0162b7 Release 1.79.0 (#485) 2021-11-21 18:04:28 +01:00
f6acf5207b Feature/add value to positions table (#484)
* Add value column

* Update changelog
2021-11-21 17:55:58 +01:00
80782f1098 add support for euro cryptocurrencies, ALGO and remove unknown crypto… (#480)
* add support for euro cryptocurrencies, ALGO and remove unknown cryptocurrencies from list

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-11-21 17:38:48 +01:00
bc58ee86ca Feature/usability improvements in the create or edit transaction dialog (#483)
* Usability improvements
  * Disable the symbol input in edit mode
  * Filter accounts by type (SECURITIES)

* Update changelog
2021-11-20 20:41:33 +01:00
0cb632b165 Improve wording (#482) 2021-11-20 10:38:30 +01:00
fca3a659d0 Release 1.78.0 (#481) 2021-11-20 10:31:45 +01:00
904dec040e Feature/add testimonial (#479)
* Add testimonials

* Update changelog
2021-11-20 10:28:05 +01:00
fc6c81fe02 Bugfix/fix footer row border in dark mode (#478)
* Fix border color in dark mode

* Update changelog
2021-11-17 23:23:32 +01:00
634171e4e3 Release 1.77.0 (#476) 2021-11-16 21:58:41 +01:00
f8f36e4f4e Bugfix/fix accounts table footer on mobile (#475)
* Fix footer on mobile

* Update changelog
2021-11-16 21:32:04 +01:00
5e7cf9d0b6 Feature/hide get started button on registration page (#474)
* Hide button

* Update changelog
2021-11-15 20:49:03 +01:00
e1932eb5a1 Bugfix/exclude drafts from transaction count (#473)
* Fix transactions count (exclude drafts)

* Improve wording (summary page)

* Update changelog
2021-11-14 19:06:54 +01:00
dba47d59e3 Release 1.76.0 (#472) 2021-11-14 17:16:06 +01:00
3032126508 Feature/add footer row to accounts table (#471)
* Add footer row to accounts table with total balance and value

* Update changelog
2021-11-14 17:04:52 +01:00
a50b55da75 Clean up (#470) 2021-11-14 16:57:46 +01:00
5422df05b3 Release 1.75.0 (#469) 2021-11-13 20:50:02 +01:00
d2fabe7ce4 Feature/add value column to accounts table (#468)
* Add value column

* Update changelog
2021-11-13 20:38:29 +01:00
a42700b9fe Feature/introduce data gathering progress (#467)
* Add data gathering progress

* Update changelog
2021-11-13 11:32:28 +01:00
9df8541145 Feature/log logo on server start (#466)
* Log logo on server start

* Update changelog
2021-11-12 22:50:40 +01:00
0b2252755c Release 1.74.0 (#464) 2021-11-11 21:52:57 +01:00
239bd09cbd Feature/move market mood to tab (#463)
* Move market mood to tab

* Update changelog
2021-11-11 21:43:17 +01:00
cd76f89902 Feature/increase decimal places for cryptocurrencies (#462)
* Calculate quantity precision

* Update changelog
2021-11-11 21:21:37 +01:00
190 changed files with 7394 additions and 4935 deletions

1
.gitignore vendored
View File

@ -23,6 +23,7 @@
!.vscode/settings.json !.vscode/settings.json
# misc # misc
/.angular/cache
/.sass-cache /.sass-cache
/connect.lock /connect.lock
/coverage /coverage

View File

@ -5,6 +5,169 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.89.0 - 11.12.2021
### Added
- Extended the data gathering by symbol endpoint with an optional date
### Changed
- Upgraded `Nx` from version `13.2.2` to `13.3.0`
- Upgraded `storybook` from version `6.4.0-rc.3` to `6.4.9`
## 1.88.0 - 09.12.2021
### Added
- Added a coupon system
## 1.87.0 - 07.12.2021
### Added
- Supported the management of additional currencies in the admin control panel
- Introduced the system message
- Introduced the read only mode
### Changed
- Increased the historical data chart of the _Fear & Greed Index_ (market mood) to 10 days
- Upgraded `prisma` from version `2.30.2` to `3.6.0`
## 1.86.0 - 04.12.2021
### Added
- Added the historical data chart of the _Fear & Greed Index_ (market mood)
### Changed
- Improved the historical data view in the admin control panel (hide invalid and future dates)
- Enabled the import functionality for transactions by default
- Converted the symbols to uppercase to avoid case-sensitive duplicates in the symbol profile model
### Fixed
- Improved the allocations by currency in combination with cash balances
## 1.85.0 - 01.12.2021
### Fixed
- Fixed the data gathering of the _Fear & Greed Index_ (market mood)
## 1.84.0 - 30.11.2021
### Added
- Exposed the data gathering by symbol as an endpoint
## 1.83.0 - 29.11.2021
### Changed
- Removed the experimental API
### Fixed
- Eliminated the redundant storage of historical exchange rates
## 1.82.0 - 28.11.2021
### Added
- Added tabs with routing to the admin control panel
- Added a new tab to manage historical data to the admin control panel
### Changed
- Introduced tabs with routing to the home page
## 1.81.0 - 27.11.2021
### Added
- Added the value to the position detail dialog
### Changed
- Upgraded `angular` from version `12.2.4` to `13.0.2`
- Upgraded `angular-material-css-vars` from version `2.1.2` to `3.0.0`
- Upgraded `nestjs` from version `7.6.18` to `8.2.3`
- Upgraded `Nx` from version `12.8.0` to `13.2.2`
- Upgraded `rxjs` from version `6.6.7` to `7.4.0`
- Upgraded `storybook` from version `6.3.8` to `6.4.0-rc.3`
### Fixed
- Fixed the broken line charts showing value labels if openend from the allocations page
- Fixed the click event for drafts in the transactions table
## 1.80.0 - 23.11.2021
### Added
- Accentuated the all time high and the all time low
## 1.79.0 - 21.11.2021
### Added
- Added the value column to the positions table
- Added support for cryptocurrency _Algorand_
### Changed
- Locked the symbol input in the edit transaction dialog
- Filtered the account selector by account type (`SECURITIES`) in the create or edit transaction dialog
### Fixed
- Fixed the search functionality for cryptocurrency symbols (do not show unsupported symbols)
## 1.78.0 - 20.11.2021
### Added
- Added a testimonial section to the landing page
### Fixed
- Fixed the footer row border of the accounts table in dark mode
## 1.77.0 - 16.11.2021
### Changed
- Hid the _Get Started_ button on the registration page
### Fixed
- Fixed the footer row of the accounts table on mobile
- Fixed the transactions count calculation in the accounts table (exclude drafts)
## 1.76.0 - 14.11.2021
### Added
- Added the footer row with buying power and net worth to the accounts table
## 1.75.0 - 13.11.2021
### Added
- Added a logo to the log on the server start
- Added the data gathering progress to the log and the admin control panel
- Added the value column to the accounts table
## 1.74.0 - 11.11.2021
### Changed
- Adapted the decimal places for cryptocurrencies in the position detail dialog
- Moved the _Fear & Greed Index_ (market mood) to a new tab on the home page
## 1.73.0 - 10.11.2021 ## 1.73.0 - 10.11.2021
### Changed ### Changed

View File

@ -1,22 +1,5 @@
{ {
"version": 1, "version": 1,
"cli": {
"defaultCollection": "@nrwl/nest"
},
"defaultProject": "api",
"schematics": {
"@nrwl/angular:application": {
"linter": "eslint",
"unitTestRunner": "jest",
"e2eTestRunner": "cypress"
},
"@nrwl/angular:library": {
"linter": "eslint",
"unitTestRunner": "jest"
},
"@nrwl/nest": {},
"@nrwl/angular:component": {}
},
"projects": { "projects": {
"api": { "api": {
"root": "apps/api", "root": "apps/api",
@ -69,7 +52,8 @@
}, },
"outputs": ["coverage/apps/api"] "outputs": ["coverage/apps/api"]
} }
} },
"tags": []
}, },
"client": { "client": {
"projectType": "application", "projectType": "application",
@ -201,7 +185,8 @@
}, },
"outputs": ["coverage/apps/client"] "outputs": ["coverage/apps/client"]
} }
} },
"tags": []
}, },
"client-e2e": { "client-e2e": {
"root": "apps/client-e2e", "root": "apps/client-e2e",
@ -221,7 +206,9 @@
} }
} }
} }
} },
"tags": [],
"implicitDependencies": ["client"]
}, },
"common": { "common": {
"root": "libs/common", "root": "libs/common",
@ -242,7 +229,8 @@
"passWithNoTests": true "passWithNoTests": true
} }
} }
} },
"tags": []
}, },
"ui": { "ui": {
"projectType": "library", "projectType": "library",
@ -300,7 +288,8 @@
} }
} }
} }
} },
"tags": []
}, },
"ui-e2e": { "ui-e2e": {
"root": "apps/ui-e2e", "root": "apps/ui-e2e",
@ -326,7 +315,9 @@
"lintFilePatterns": ["apps/ui-e2e/**/*.{js,ts}"] "lintFilePatterns": ["apps/ui-e2e/**/*.{js,ts}"]
} }
} }
} },
"tags": [],
"implicitDependencies": ["ui"]
} }
} }
} }

View File

@ -1,9 +1,5 @@
import { Access } from '@ghostfolio/common/interfaces'; import { Access } from '@ghostfolio/common/interfaces';
import { import { hasPermission, permissions } from '@ghostfolio/common/permissions';
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -66,10 +62,7 @@ export class AccessController {
@Body() data: CreateAccessDto @Body() data: CreateAccessDto
): Promise<AccessModel> { ): Promise<AccessModel> {
if ( if (
!hasPermission( !hasPermission(this.request.user.permissions, permissions.createAccess)
getPermissions(this.request.user.role),
permissions.createAccess
)
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
@ -86,10 +79,7 @@ export class AccessController {
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async deleteAccess(@Param('id') id: string): Promise<AccessModule> { public async deleteAccess(@Param('id') id: string): Promise<AccessModule> {
if ( if (
!hasPermission( !hasPermission(this.request.user.permissions, permissions.deleteAccess)
getPermissions(this.request.user.role),
permissions.deleteAccess
)
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),

View File

@ -24,7 +24,7 @@ export class AccessService {
take?: number; take?: number;
cursor?: Prisma.AccessWhereUniqueInput; cursor?: Prisma.AccessWhereUniqueInput;
where?: Prisma.AccessWhereInput; where?: Prisma.AccessWhereInput;
orderBy?: Prisma.AccessOrderByInput; orderBy?: Prisma.AccessOrderByWithRelationInput;
}): Promise<AccessWithGranteeUser[]> { }): Promise<AccessWithGranteeUser[]> {
const { include, skip, take, cursor, where, orderBy } = params; const { include, skip, take, cursor, where, orderBy } = params;

View File

@ -1,11 +1,12 @@
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { import {
getPermissions, nullifyValuesInObject,
hasPermission, nullifyValuesInObjects
permissions } from '@ghostfolio/api/helper/object.helper';
} from '@ghostfolio/common/permissions'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { Accounts } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -34,6 +35,7 @@ export class AccountController {
public constructor( public constructor(
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly impersonationService: ImpersonationService, private readonly impersonationService: ImpersonationService,
private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService private readonly userService: UserService
) {} ) {}
@ -42,10 +44,7 @@ export class AccountController {
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async deleteAccount(@Param('id') id: string): Promise<AccountModel> { public async deleteAccount(@Param('id') id: string): Promise<AccountModel> {
if ( if (
!hasPermission( !hasPermission(this.request.user.permissions, permissions.deleteAccount)
getPermissions(this.request.user.role),
permissions.deleteAccount
)
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
@ -85,30 +84,39 @@ export class AccountController {
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getAllAccounts( public async getAllAccounts(
@Headers('impersonation-id') impersonationId @Headers('impersonation-id') impersonationId
): Promise<AccountModel[]> { ): Promise<Accounts> {
const impersonationUserId = const impersonationUserId =
await this.impersonationService.validateImpersonationId( await this.impersonationService.validateImpersonationId(
impersonationId, impersonationId,
this.request.user.id this.request.user.id
); );
let accounts = await this.accountService.getAccounts( let accountsWithAggregations =
impersonationUserId || this.request.user.id await this.portfolioService.getAccountsWithAggregations(
); impersonationUserId || this.request.user.id
);
if ( if (
impersonationUserId || impersonationUserId ||
this.userService.isRestrictedView(this.request.user) this.userService.isRestrictedView(this.request.user)
) { ) {
accounts = nullifyValuesInObjects(accounts, [ accountsWithAggregations = {
'balance', ...nullifyValuesInObject(accountsWithAggregations, [
'fee', 'totalBalance',
'quantity', 'totalValue'
'unitPrice' ]),
]); accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [
'balance',
'convertedBalance',
'fee',
'quantity',
'unitPrice',
'value'
])
};
} }
return accounts; return accountsWithAggregations;
} }
@Get(':id') @Get(':id')
@ -128,10 +136,7 @@ export class AccountController {
@Body() data: CreateAccountDto @Body() data: CreateAccountDto
): Promise<AccountModel> { ): Promise<AccountModel> {
if ( if (
!hasPermission( !hasPermission(this.request.user.permissions, permissions.createAccount)
getPermissions(this.request.user.role),
permissions.createAccount
)
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
@ -168,10 +173,7 @@ export class AccountController {
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) { public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {
if ( if (
!hasPermission( !hasPermission(this.request.user.permissions, permissions.updateAccount)
getPermissions(this.request.user.role),
permissions.updateAccount
)
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),

View File

@ -1,3 +1,4 @@
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
@ -11,16 +12,17 @@ import { AccountController } from './account.controller';
import { AccountService } from './account.service'; import { AccountService } from './account.service';
@Module({ @Module({
controllers: [AccountController],
imports: [ imports: [
ConfigurationModule, ConfigurationModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
ImpersonationModule, ImpersonationModule,
RedisCacheModule, PortfolioModule,
PrismaModule, PrismaModule,
RedisCacheModule,
UserModule UserModule
], ],
controllers: [AccountController],
providers: [AccountService] providers: [AccountService]
}) })
export class AccountModule {} export class AccountModule {}

View File

@ -40,7 +40,7 @@ export class AccountService {
take?: number; take?: number;
cursor?: Prisma.AccountWhereUniqueInput; cursor?: Prisma.AccountWhereUniqueInput;
where?: Prisma.AccountWhereInput; where?: Prisma.AccountWhereInput;
orderBy?: Prisma.AccountOrderByInput; orderBy?: Prisma.AccountOrderByWithRelationInput;
}): Promise< }): Promise<
(Account & { (Account & {
Order?: Order[]; Order?: Order[];
@ -85,7 +85,15 @@ export class AccountService {
}); });
return accounts.map((account) => { return accounts.map((account) => {
const result = { ...account, transactionCount: account.Order.length }; let transactionCount = 0;
for (const order of account.Order) {
if (!order.isDraft) {
transactionCount += 1;
}
}
const result = { ...account, transactionCount };
delete result.Order; delete result.Order;

View File

@ -1,21 +1,28 @@
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { AdminData } from '@ghostfolio/common/interfaces'; import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { import {
getPermissions, AdminData,
hasPermission, AdminMarketData,
permissions AdminMarketDataDetails
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body,
Controller, Controller,
Get, Get,
HttpException, HttpException,
Inject, Inject,
Param,
Post, Post,
Put,
UseGuards UseGuards
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { DataSource, MarketData } from '@prisma/client';
import { isDate, isValid } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AdminService } from './admin.service'; import { AdminService } from './admin.service';
@ -33,7 +40,7 @@ export class AdminController {
public async getAdminData(): Promise<AdminData> { public async getAdminData(): Promise<AdminData> {
if ( if (
!hasPermission( !hasPermission(
getPermissions(this.request.user.role), this.request.user.permissions,
permissions.accessAdminControl permissions.accessAdminControl
) )
) { ) {
@ -51,7 +58,7 @@ export class AdminController {
public async gatherMax(): Promise<void> { public async gatherMax(): Promise<void> {
if ( if (
!hasPermission( !hasPermission(
getPermissions(this.request.user.role), this.request.user.permissions,
permissions.accessAdminControl permissions.accessAdminControl
) )
) { ) {
@ -72,7 +79,7 @@ export class AdminController {
public async gatherProfileData(): Promise<void> { public async gatherProfileData(): Promise<void> {
if ( if (
!hasPermission( !hasPermission(
getPermissions(this.request.user.role), this.request.user.permissions,
permissions.accessAdminControl permissions.accessAdminControl
) )
) { ) {
@ -86,4 +93,121 @@ export class AdminController {
return; return;
} }
@Post('gather/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
public async gatherSymbol(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
this.dataGatheringService.gatherSymbol({ dataSource, symbol });
return;
}
@Post('gather/:dataSource/:symbol/:dateString')
@UseGuards(AuthGuard('jwt'))
public async gatherSymbolForDate(
@Param('dataSource') dataSource: DataSource,
@Param('dateString') dateString: string,
@Param('symbol') symbol: string
): Promise<MarketData> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const date = new Date(dateString);
if (!isDate(date)) {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
return this.dataGatheringService.gatherSymbolForDate({
dataSource,
date,
symbol
});
}
@Get('market-data')
@UseGuards(AuthGuard('jwt'))
public async getMarketData(): Promise<AdminMarketData> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.adminService.getMarketData();
}
@Get('market-data/:symbol')
@UseGuards(AuthGuard('jwt'))
public async getMarketDataBySymbol(
@Param('symbol') symbol
): Promise<AdminMarketDataDetails> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.adminService.getMarketDataBySymbol(symbol);
}
@Put('settings/:key')
@UseGuards(AuthGuard('jwt'))
public async updateProperty(
@Param('key') key: string,
@Body() data: PropertyDto
) {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return await this.adminService.putSetting(key, data.value);
}
} }

View File

@ -3,7 +3,9 @@ import { ConfigurationModule } from '@ghostfolio/api/services/configuration.modu
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller'; import { AdminController } from './admin.controller';
@ -15,7 +17,9 @@ import { AdminService } from './admin.service';
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
MarketDataModule,
PrismaModule, PrismaModule,
PropertyModule,
SubscriptionModule SubscriptionModule
], ],
controllers: [AdminController], controllers: [AdminController],

View File

@ -2,10 +2,17 @@ import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscripti
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { baseCurrency } from '@ghostfolio/common/config'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { AdminData } from '@ghostfolio/common/interfaces'; import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config';
import {
AdminData,
AdminMarketData,
AdminMarketDataDetails
} from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Property } from '@prisma/client';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
@Injectable() @Injectable()
@ -14,12 +21,16 @@ export class AdminService {
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService private readonly subscriptionService: SubscriptionService
) {} ) {}
public async get(): Promise<AdminData> { public async get(): Promise<AdminData> {
return { return {
dataGatheringProgress:
await this.dataGatheringService.getDataGatheringProgress(),
exchangeRates: this.exchangeRateDataService exchangeRates: this.exchangeRateDataService
.getCurrencies() .getCurrencies()
.filter((currency) => { .filter((currency) => {
@ -37,12 +48,55 @@ export class AdminService {
}; };
}), }),
lastDataGathering: await this.getLastDataGathering(), lastDataGathering: await this.getLastDataGathering(),
settings: await this.propertyService.get(),
transactionCount: await this.prismaService.order.count(), transactionCount: await this.prismaService.order.count(),
userCount: await this.prismaService.user.count(), userCount: await this.prismaService.user.count(),
users: await this.getUsersWithAnalytics() users: await this.getUsersWithAnalytics()
}; };
} }
public async getMarketData(): Promise<AdminMarketData> {
return {
marketData: await (
await this.dataGatheringService.getSymbolsMax()
).map((symbol) => {
return symbol;
})
};
}
public async getMarketDataBySymbol(
aSymbol: string
): Promise<AdminMarketDataDetails> {
return {
marketData: await this.marketDataService.marketDataItems({
orderBy: {
date: 'asc'
},
where: {
symbol: aSymbol
}
})
};
}
public async putSetting(key: string, value: string) {
let response: Property;
if (value === '') {
response = await this.propertyService.delete({ key });
} else {
response = await this.propertyService.put({ key, value });
}
if (key === PROPERTY_CURRENCIES) {
await this.exchangeRateDataService.initialize();
await this.dataGatheringService.reset();
}
return response;
}
private async getLastDataGathering() { private async getLastDataGathering() {
const lastDataGathering = const lastDataGathering =
await this.dataGatheringService.getLastDataGathering(); await this.dataGatheringService.getLastDataGathering();
@ -58,7 +112,7 @@ export class AdminService {
return 'IN_PROGRESS'; return 'IN_PROGRESS';
} }
return null; return undefined;
} }
private async getUsersWithAnalytics(): Promise<AdminData['users']> { private async getUsersWithAnalytics(): Promise<AdminData['users']> {

View File

@ -19,7 +19,6 @@ import { AdminModule } from './admin/admin.module';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { CacheModule } from './cache/cache.module'; import { CacheModule } from './cache/cache.module';
import { ExperimentalModule } from './experimental/experimental.module';
import { ExportModule } from './export/export.module'; import { ExportModule } from './export/export.module';
import { ImportModule } from './import/import.module'; import { ImportModule } from './import/import.module';
import { InfoModule } from './info/info.module'; import { InfoModule } from './info/info.module';
@ -42,7 +41,6 @@ import { UserModule } from './user/user.module';
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
ExperimentalModule,
ExportModule, ExportModule,
ImportModule, ImportModule,
InfoModule, InfoModule,

View File

@ -1,9 +1,5 @@
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { import { hasPermission, permissions } from '@ghostfolio/common/permissions';
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
@ -29,7 +25,7 @@ export class AuthDeviceController {
public async deleteAuthDevice(@Param('id') id: string): Promise<void> { public async deleteAuthDevice(@Param('id') id: string): Promise<void> {
if ( if (
!hasPermission( !hasPermission(
getPermissions(this.request.user.role), this.request.user.permissions,
permissions.deleteAuthDevice permissions.deleteAuthDevice
) )
) { ) {

View File

@ -23,7 +23,7 @@ export class AuthDeviceService {
take?: number; take?: number;
cursor?: Prisma.AuthDeviceWhereUniqueInput; cursor?: Prisma.AuthDeviceWhereUniqueInput;
where?: Prisma.AuthDeviceWhereInput; where?: Prisma.AuthDeviceWhereInput;
orderBy?: Prisma.AuthDeviceOrderByInput; orderBy?: Prisma.AuthDeviceOrderByWithRelationInput;
}): Promise<AuthDevice[]> { }): Promise<AuthDevice[]> {
const { skip, take, cursor, where, orderBy } = params; const { skip, take, cursor, where, orderBy } = params;
return this.prismaService.authDevice.findMany({ return this.prismaService.authDevice.findMany({

View File

@ -1,7 +1,7 @@
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -19,7 +19,8 @@ import { JwtStrategy } from './jwt.strategy';
secret: process.env.JWT_SECRET_KEY, secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '180 days' } signOptions: { expiresIn: '180 days' }
}), }),
SubscriptionModule SubscriptionModule,
UserModule
], ],
providers: [ providers: [
AuthDeviceService, AuthDeviceService,
@ -28,7 +29,6 @@ import { JwtStrategy } from './jwt.strategy';
GoogleStrategy, GoogleStrategy,
JwtStrategy, JwtStrategy,
PrismaService, PrismaService,
UserService,
WebAuthService WebAuthService
] ]
}) })

View File

@ -1,22 +0,0 @@
import { Type } from '@prisma/client';
import { IsISO8601, IsNumber, IsString } from 'class-validator';
export class CreateOrderDto {
@IsString()
currency: string;
@IsISO8601()
date: string;
@IsNumber()
quantity: number;
@IsString()
symbol: string;
@IsString()
type: Type;
@IsNumber()
unitPrice: number;
}

View File

@ -1,69 +0,0 @@
import { baseCurrency, benchmarks } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { isApiTokenAuthorized } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Get,
Headers,
HttpException,
Inject,
Param,
Post
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { parse } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreateOrderDto } from './create-order.dto';
import { ExperimentalService } from './experimental.service';
import { Data } from './interfaces/data.interface';
@Controller('experimental')
export class ExperimentalController {
public constructor(
private readonly experimentalService: ExperimentalService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Get('benchmarks')
public async getBenchmarks(
@Headers('Authorization') apiToken: string
): Promise<string[]> {
if (!isApiTokenAuthorized(apiToken)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return benchmarks.map(({ symbol }) => {
return symbol;
});
}
@Get('benchmarks/:symbol')
public async getBenchmark(
@Headers('Authorization') apiToken: string,
@Param('symbol') symbol: string
): Promise<{ date: Date; marketPrice: number }[]> {
if (!isApiTokenAuthorized(apiToken)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const marketData = await this.experimentalService.getBenchmark(symbol);
if (marketData?.length === 0) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
return marketData;
}
}

View File

@ -1,23 +0,0 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { Module } from '@nestjs/common';
import { ExperimentalController } from './experimental.controller';
import { ExperimentalService } from './experimental.service';
@Module({
imports: [
ConfigurationModule,
DataProviderModule,
ExchangeRateDataModule,
RedisCacheModule,
PrismaModule
],
controllers: [ExperimentalController],
providers: [AccountService, ExperimentalService]
})
export class ExperimentalModule {}

View File

@ -1,23 +0,0 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable } from '@nestjs/common';
@Injectable()
export class ExperimentalService {
public constructor(
private readonly accountService: AccountService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService
) {}
public async getBenchmark(aSymbol: string) {
return this.prismaService.marketData.findMany({
orderBy: { date: 'asc' },
select: { date: true, marketPrice: true },
where: { symbol: aSymbol }
});
}
}

View File

@ -1,4 +0,0 @@
export interface Data {
currency: string;
value: number;
}

View File

@ -5,6 +5,7 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
@ -21,6 +22,7 @@ import { InfoService } from './info.service';
secret: process.env.JWT_SECRET_KEY, secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '30 days' } signOptions: { expiresIn: '30 days' }
}), }),
PropertyModule,
RedisCacheModule, RedisCacheModule,
SymbolProfileModule SymbolProfileModule
], ],

View File

@ -4,6 +4,12 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_STRIPE_CONFIG,
PROPERTY_SYSTEM_MESSAGE
} from '@ghostfolio/common/config';
import { InfoItem } from '@ghostfolio/common/interfaces'; import { InfoItem } from '@ghostfolio/common/interfaces';
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface'; import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface'; import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
@ -15,8 +21,8 @@ import { subDays } from 'date-fns';
@Injectable() @Injectable()
export class InfoService { export class InfoService {
private static DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f';
private static CACHE_KEY_STATISTICS = 'STATISTICS'; private static CACHE_KEY_STATISTICS = 'STATISTICS';
private static DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f';
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
@ -25,15 +31,18 @@ export class InfoService {
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService private readonly redisCacheService: RedisCacheService
) {} ) {}
public async get(): Promise<InfoItem> { public async get(): Promise<InfoItem> {
const info: Partial<InfoItem> = {}; const info: Partial<InfoItem> = {};
let isReadOnlyMode: boolean;
const platforms = await this.prismaService.platform.findMany({ const platforms = await this.prismaService.platform.findMany({
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
select: { id: true, name: true } select: { id: true, name: true }
}); });
let systemMessage: string;
const globalPermissions: string[] = []; const globalPermissions: string[] = [];
@ -45,6 +54,12 @@ export class InfoService {
globalPermissions.push(permissions.enableImport); globalPermissions.push(permissions.enableImport);
} }
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
isReadOnlyMode = (await this.propertyService.getByKey(
PROPERTY_IS_READ_ONLY_MODE
)) as boolean;
}
if (this.configurationService.get('ENABLE_FEATURE_SOCIAL_LOGIN')) { if (this.configurationService.get('ENABLE_FEATURE_SOCIAL_LOGIN')) {
globalPermissions.push(permissions.enableSocialLogin); globalPermissions.push(permissions.enableSocialLogin);
} }
@ -59,10 +74,20 @@ export class InfoService {
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY'); info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
} }
if (this.configurationService.get('ENABLE_FEATURE_SYSTEM_MESSAGE')) {
globalPermissions.push(permissions.enableSystemMessage);
systemMessage = (await this.propertyService.getByKey(
PROPERTY_SYSTEM_MESSAGE
)) as string;
}
return { return {
...info, ...info,
globalPermissions, globalPermissions,
isReadOnlyMode,
platforms, platforms,
systemMessage,
currencies: this.exchangeRateDataService.getCurrencies(), currencies: this.exchangeRateDataService.getCurrencies(),
demoAuthToken: this.getDemoAuthToken(), demoAuthToken: this.getDemoAuthToken(),
lastDataGathering: await this.getLastDataGathering(), lastDataGathering: await this.getLastDataGathering(),
@ -222,7 +247,7 @@ export class InfoService {
} }
const stripeConfig = await this.prismaService.property.findUnique({ const stripeConfig = await this.prismaService.property.findUnique({
where: { key: 'STRIPE_CONFIG' } where: { key: PROPERTY_STRIPE_CONFIG }
}); });
if (stripeConfig) { if (stripeConfig) {

View File

@ -1,11 +1,7 @@
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper'; import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { import { hasPermission, permissions } from '@ghostfolio/common/permissions';
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -43,10 +39,7 @@ export class OrderController {
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> { public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
if ( if (
!hasPermission( !hasPermission(this.request.user.permissions, permissions.deleteOrder)
getPermissions(this.request.user.role),
permissions.deleteOrder
)
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
@ -115,10 +108,7 @@ export class OrderController {
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> { public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> {
if ( if (
!hasPermission( !hasPermission(this.request.user.permissions, permissions.createOrder)
getPermissions(this.request.user.role),
permissions.createOrder
)
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
@ -161,10 +151,7 @@ export class OrderController {
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) { public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
if ( if (
!hasPermission( !hasPermission(this.request.user.permissions, permissions.updateOrder)
getPermissions(this.request.user.role),
permissions.updateOrder
)
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),

View File

@ -28,7 +28,7 @@ export class OrderService {
take?: number; take?: number;
cursor?: Prisma.OrderWhereUniqueInput; cursor?: Prisma.OrderWhereUniqueInput;
where?: Prisma.OrderWhereInput; where?: Prisma.OrderWhereInput;
orderBy?: Prisma.OrderOrderByInput; orderBy?: Prisma.OrderOrderByWithRelationInput;
}): Promise<OrderWithAccount[]> { }): Promise<OrderWithAccount[]> {
const { include, skip, take, cursor, where, orderBy } = params; const { include, skip, take, cursor, where, orderBy } = params;
@ -45,19 +45,22 @@ export class OrderService {
public async createOrder(data: Prisma.OrderCreateInput): Promise<Order> { public async createOrder(data: Prisma.OrderCreateInput): Promise<Order> {
const isDraft = isAfter(data.date as Date, endOfToday()); const isDraft = isAfter(data.date as Date, endOfToday());
// Convert the symbol to uppercase to avoid case-sensitive duplicates
const symbol = data.symbol.toUpperCase();
if (!isDraft) { if (!isDraft) {
// Gather symbol data of order in the background, if not draft // Gather symbol data of order in the background, if not draft
this.dataGatheringService.gatherSymbols([ this.dataGatheringService.gatherSymbols([
{ {
symbol,
dataSource: data.dataSource, dataSource: data.dataSource,
date: <Date>data.date, date: <Date>data.date
symbol: data.symbol
} }
]); ]);
} }
this.dataGatheringService.gatherProfileData([ this.dataGatheringService.gatherProfileData([
{ dataSource: data.dataSource, symbol: data.symbol } { symbol, dataSource: data.dataSource }
]); ]);
await this.cacheService.flush(); await this.cacheService.flush();
@ -65,7 +68,8 @@ export class OrderService {
return this.prismaService.order.create({ return this.prismaService.order.create({
data: { data: {
...data, ...data,
isDraft isDraft,
symbol
} }
}); });
} }

View File

@ -1,11 +1,11 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource, MarketData } from '@prisma/client';
import { CurrentRateService } from './current-rate.service'; import { CurrentRateService } from './current-rate.service';
import { MarketDataService } from './market-data.service';
jest.mock('./market-data.service', () => { jest.mock('@ghostfolio/api/services/market-data.service', () => {
return { return {
MarketDataService: jest.fn().mockImplementation(() => { MarketDataService: jest.fn().mockImplementation(() => {
return { return {
@ -73,7 +73,7 @@ describe('CurrentRateService', () => {
beforeAll(async () => { beforeAll(async () => {
dataProviderService = new DataProviderService(null, [], null); dataProviderService = new DataProviderService(null, [], null);
exchangeRateDataService = new ExchangeRateDataService(null, null); exchangeRateDataService = new ExchangeRateDataService(null, null, null);
marketDataService = new MarketDataService(null); marketDataService = new MarketDataService(null);
await exchangeRateDataService.initialize(); await exchangeRateDataService.initialize();

View File

@ -1,5 +1,6 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { resetHours } from '@ghostfolio/common/helper'; import { resetHours } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { isBefore, isToday } from 'date-fns'; import { isBefore, isToday } from 'date-fns';
@ -8,7 +9,6 @@ import { flatten } from 'lodash';
import { GetValueObject } from './interfaces/get-value-object.interface'; import { GetValueObject } from './interfaces/get-value-object.interface';
import { GetValueParams } from './interfaces/get-value-params.interface'; import { GetValueParams } from './interfaces/get-value-params.interface';
import { GetValuesParams } from './interfaces/get-values-params.interface'; import { GetValuesParams } from './interfaces/get-values-params.interface';
import { MarketDataService } from './market-data.service';
@Injectable() @Injectable()
export class CurrentRateService { export class CurrentRateService {

View File

@ -1,4 +1,8 @@
import { AssetClass, AssetSubClass } from '@prisma/client';
export interface PortfolioPositionDetail { export interface PortfolioPositionDetail {
assetClass?: AssetClass;
assetSubClass?: AssetSubClass;
averagePrice: number; averagePrice: number;
currency: string; currency: string;
firstBuyDate: string; firstBuyDate: string;
@ -15,6 +19,13 @@ export interface PortfolioPositionDetail {
quantity: number; quantity: number;
symbol: string; symbol: string;
transactionCount: number; transactionCount: number;
value: number;
}
export interface HistoricalDataContainer {
isAllTimeHigh: boolean;
isAllTimeLow: boolean;
items: HistoricalDataItem[];
} }
export interface HistoricalDataItem { export interface HistoricalDataItem {

View File

@ -0,0 +1,8 @@
import { TimelinePeriod } from '@ghostfolio/api/app/portfolio/interfaces/timeline-period.interface';
import Big from 'big.js';
export interface TimelineInfoInterface {
maxNetPerformance: Big;
minNetPerformance: Big;
timelinePeriods: TimelinePeriod[];
}

View File

@ -1502,11 +1502,11 @@ describe('PortfolioCalculator', () => {
accuracy: 'year' accuracy: 'year'
} }
]; ];
const timeline: TimelinePeriod[] = const timelineInfo = await portfolioCalculator.calculateTimeline(
await portfolioCalculator.calculateTimeline( timelineSpecification,
timelineSpecification, '2021-06-30'
'2021-06-30' );
); const timeline: TimelinePeriod[] = timelineInfo.timelinePeriods;
expect(timeline).toEqual([ expect(timeline).toEqual([
{ {
@ -1622,11 +1622,11 @@ describe('PortfolioCalculator', () => {
accuracy: 'year' accuracy: 'year'
} }
]; ];
const timeline: TimelinePeriod[] = const timelineInfo = await portfolioCalculator.calculateTimeline(
await portfolioCalculator.calculateTimeline( timelineSpecification,
timelineSpecification, '2021-06-30'
'2021-06-30' );
); const timeline: TimelinePeriod[] = timelineInfo.timelinePeriods;
expect(timeline).toEqual([ expect(timeline).toEqual([
{ {
@ -1665,11 +1665,11 @@ describe('PortfolioCalculator', () => {
accuracy: 'month' accuracy: 'month'
} }
]; ];
const timeline: TimelinePeriod[] = const timelineInfo = await portfolioCalculator.calculateTimeline(
await portfolioCalculator.calculateTimeline( timelineSpecification,
timelineSpecification, '2021-06-30'
'2021-06-30' );
); const timeline: TimelinePeriod[] = timelineInfo.timelinePeriods;
expect(timeline).toEqual([ expect(timeline).toEqual([
{ {
@ -1883,6 +1883,9 @@ describe('PortfolioCalculator', () => {
value: new Big('3186.9') // 15 * (144.38 + days=851 * 0.08) value: new Big('3186.9') // 15 * (144.38 + days=851 * 0.08)
} }
]); ]);
expect(timelineInfo.maxNetPerformance).toEqual(new Big('547.9'));
expect(timelineInfo.minNetPerformance).toEqual(new Big('0'));
}); });
it('with yearly and monthly mixed', async () => { it('with yearly and monthly mixed', async () => {
@ -1901,11 +1904,11 @@ describe('PortfolioCalculator', () => {
accuracy: 'month' accuracy: 'month'
} }
]; ];
const timeline: TimelinePeriod[] = const timelineInfo = await portfolioCalculator.calculateTimeline(
await portfolioCalculator.calculateTimeline( timelineSpecification,
timelineSpecification, '2021-06-30'
'2021-06-30' );
); const timeline: TimelinePeriod[] = timelineInfo.timelinePeriods;
expect(timeline).toEqual([ expect(timeline).toEqual([
{ {
@ -1987,11 +1990,11 @@ describe('PortfolioCalculator', () => {
accuracy: 'day' accuracy: 'day'
} }
]; ];
const timeline: TimelinePeriod[] = const timelineInfo = await portfolioCalculator.calculateTimeline(
await portfolioCalculator.calculateTimeline( timelineSpecification,
timelineSpecification, '2021-06-30'
'2021-06-30' );
); const timeline: TimelinePeriod[] = timelineInfo.timelinePeriods;
expect(timeline).toEqual( expect(timeline).toEqual(
expect.objectContaining([ expect.objectContaining([
@ -2296,11 +2299,11 @@ describe('PortfolioCalculator', () => {
accuracy: 'year' accuracy: 'year'
} }
]; ];
const timeline: TimelinePeriod[] = const timelineInfo = await portfolioCalculator.calculateTimeline(
await portfolioCalculator.calculateTimeline( timelineSpecification,
timelineSpecification, '2020-01-01'
'2020-01-01' );
); const timeline: TimelinePeriod[] = timelineInfo.timelinePeriods;
expect(timeline).toEqual([ expect(timeline).toEqual([
{ {

View File

@ -1,3 +1,4 @@
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
import { OrderType } from '@ghostfolio/api/models/order-type'; import { OrderType } from '@ghostfolio/api/models/order-type';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper'; import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
@ -365,16 +366,20 @@ export class PortfolioCalculator {
public async calculateTimeline( public async calculateTimeline(
timelineSpecification: TimelineSpecification[], timelineSpecification: TimelineSpecification[],
endDate: string endDate: string
): Promise<TimelinePeriod[]> { ): Promise<TimelineInfoInterface> {
if (timelineSpecification.length === 0) { if (timelineSpecification.length === 0) {
return []; return {
maxNetPerformance: new Big(0),
minNetPerformance: new Big(0),
timelinePeriods: []
};
} }
const startDate = timelineSpecification[0].start; const startDate = timelineSpecification[0].start;
const start = parseDate(startDate); const start = parseDate(startDate);
const end = parseDate(endDate); const end = parseDate(endDate);
const timelinePeriodPromises: Promise<TimelinePeriod[]>[] = []; const timelinePeriodPromises: Promise<TimelineInfoInterface>[] = [];
let i = 0; let i = 0;
let j = -1; let j = -1;
for ( for (
@ -417,11 +422,40 @@ export class PortfolioCalculator {
} }
} }
const timelinePeriods: TimelinePeriod[][] = await Promise.all( const timelineInfoInterfaces: TimelineInfoInterface[] = await Promise.all(
timelinePeriodPromises timelinePeriodPromises
); );
const minNetPerformance = timelineInfoInterfaces
.map((timelineInfo) => timelineInfo.minNetPerformance)
.filter((performance) => performance !== null)
.reduce((minPerformance, current) => {
if (minPerformance.lt(current)) {
return minPerformance;
} else {
return current;
}
});
return flatten(timelinePeriods); const maxNetPerformance = timelineInfoInterfaces
.map((timelineInfo) => timelineInfo.maxNetPerformance)
.filter((performance) => performance !== null)
.reduce((maxPerformance, current) => {
if (maxPerformance.gt(current)) {
return maxPerformance;
} else {
return current;
}
});
const timelinePeriods = timelineInfoInterfaces.map(
(timelineInfo) => timelineInfo.timelinePeriods
);
return {
maxNetPerformance,
minNetPerformance,
timelinePeriods: flatten(timelinePeriods)
};
} }
private calculateOverallPerformance( private calculateOverallPerformance(
@ -482,7 +516,7 @@ export class PortfolioCalculator {
); );
} else if (!currentPosition.quantity.eq(0)) { } else if (!currentPosition.quantity.eq(0)) {
Logger.error( Logger.error(
`Initial value is missing for symbol ${currentPosition.symbol}` `Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`
); );
hasErrors = true; hasErrors = true;
} }
@ -513,7 +547,7 @@ export class PortfolioCalculator {
j: number, j: number,
startDate: Date, startDate: Date,
endDate: Date endDate: Date
): Promise<TimelinePeriod[]> { ): Promise<TimelineInfoInterface> {
let investment: Big = new Big(0); let investment: Big = new Big(0);
let fees: Big = new Big(0); let fees: Big = new Big(0);
@ -569,6 +603,8 @@ export class PortfolioCalculator {
} }
const results: TimelinePeriod[] = []; const results: TimelinePeriod[] = [];
let maxNetPerformance: Big = null;
let minNetPerformance: Big = null;
for ( for (
let currentDate = startDate; let currentDate = startDate;
isBefore(currentDate, endDate); isBefore(currentDate, endDate);
@ -592,18 +628,36 @@ export class PortfolioCalculator {
} }
if (!invalid) { if (!invalid) {
const grossPerformance = value.minus(investment); const grossPerformance = value.minus(investment);
const netPerformance = grossPerformance.minus(fees);
if (
minNetPerformance === null ||
minNetPerformance.gt(netPerformance)
) {
minNetPerformance = netPerformance;
}
if (
maxNetPerformance === null ||
maxNetPerformance.lt(netPerformance)
) {
maxNetPerformance = netPerformance;
}
const result = { const result = {
grossPerformance, grossPerformance,
investment, investment,
netPerformance,
value, value,
date: currentDateAsString, date: currentDateAsString
netPerformance: grossPerformance.minus(fees)
}; };
results.push(result); results.push(result);
} }
} }
return results; return {
maxNetPerformance,
minNetPerformance,
timelinePeriods: results
};
} }
private getFactor(type: OrderType) { private getFactor(type: OrderType) {

View File

@ -8,6 +8,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { baseCurrency } from '@ghostfolio/common/config'; import { baseCurrency } from '@ghostfolio/common/config';
import { import {
PortfolioChart,
PortfolioDetails, PortfolioDetails,
PortfolioPerformance, PortfolioPerformance,
PortfolioPublicDetails, PortfolioPublicDetails,
@ -32,10 +33,7 @@ import { AuthGuard } from '@nestjs/passport';
import { Response } from 'express'; import { Response } from 'express';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
HistoricalDataItem,
PortfolioPositionDetail
} from './interfaces/portfolio-position-detail.interface';
import { PortfolioPositions } from './interfaces/portfolio-positions.interface'; import { PortfolioPositions } from './interfaces/portfolio-positions.interface';
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
@ -92,12 +90,14 @@ export class PortfolioController {
@Headers('impersonation-id') impersonationId, @Headers('impersonation-id') impersonationId,
@Query('range') range, @Query('range') range,
@Res() res: Response @Res() res: Response
): Promise<HistoricalDataItem[]> { ): Promise<PortfolioChart> {
let chartData = await this.portfolioService.getChart( const historicalDataContainer = await this.portfolioService.getChart(
impersonationId, impersonationId,
range range
); );
let chartData = historicalDataContainer.items;
let hasNullValue = false; let hasNullValue = false;
chartData.forEach((chartDataItem) => { chartData.forEach((chartDataItem) => {
@ -130,7 +130,11 @@ export class PortfolioController {
}); });
} }
return <any>res.json(chartData); return <any>res.json({
chart: chartData,
isAllTimeHigh: historicalDataContainer.isAllTimeHigh,
isAllTimeLow: historicalDataContainer.isAllTimeLow
});
} }
@Get('details') @Get('details')
@ -366,7 +370,8 @@ export class PortfolioController {
'grossPerformance', 'grossPerformance',
'investment', 'investment',
'netPerformance', 'netPerformance',
'quantity' 'quantity',
'value'
]); ]);
} }

View File

@ -7,17 +7,18 @@ import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.mod
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { CurrentRateService } from './current-rate.service'; import { CurrentRateService } from './current-rate.service';
import { MarketDataService } from './market-data.service';
import { PortfolioController } from './portfolio.controller'; import { PortfolioController } from './portfolio.controller';
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
import { RulesService } from './rules.service'; import { RulesService } from './rules.service';
@Module({ @Module({
exports: [PortfolioService],
imports: [ imports: [
AccessModule, AccessModule,
ConfigurationModule, ConfigurationModule,
@ -25,6 +26,7 @@ import { RulesService } from './rules.service';
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
ImpersonationModule, ImpersonationModule,
MarketDataModule,
OrderModule, OrderModule,
PrismaModule, PrismaModule,
SymbolProfileModule, SymbolProfileModule,
@ -34,7 +36,6 @@ import { RulesService } from './rules.service';
providers: [ providers: [
AccountService, AccountService,
CurrentRateService, CurrentRateService,
MarketDataService,
PortfolioService, PortfolioService,
RulesService RulesService
] ]

View File

@ -1,5 +1,3 @@
// TODO ///////////
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface'; import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service';
@ -30,6 +28,7 @@ import {
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { import {
Accounts,
PortfolioDetails, PortfolioDetails,
PortfolioPerformance, PortfolioPerformance,
PortfolioReport, PortfolioReport,
@ -39,6 +38,7 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import type { import type {
AccountWithValue,
DateRange, DateRange,
OrderWithAccount, OrderWithAccount,
RequestWithUser RequestWithUser
@ -56,12 +56,14 @@ import {
parse, parse,
parseISO, parseISO,
setDayOfYear, setDayOfYear,
startOfDay,
subDays, subDays,
subYears subYears
} from 'date-fns'; } from 'date-fns';
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import { import {
HistoricalDataContainer,
HistoricalDataItem, HistoricalDataItem,
PortfolioPositionDetail PortfolioPositionDetail
} from './interfaces/portfolio-position-detail.interface'; } from './interfaces/portfolio-position-detail.interface';
@ -81,6 +83,59 @@ export class PortfolioService {
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) {} ) {}
public async getAccounts(aUserId: string): Promise<AccountWithValue[]> {
const [accounts, details] = await Promise.all([
this.accountService.accounts({
include: { Order: true, Platform: true },
orderBy: { name: 'asc' },
where: { userId: aUserId }
}),
this.getDetails(aUserId, aUserId)
]);
const userCurrency = this.request.user.Settings.currency;
return accounts.map((account) => {
let transactionCount = 0;
for (const order of account.Order) {
if (!order.isDraft) {
transactionCount += 1;
}
}
const result = {
...account,
transactionCount,
convertedBalance: this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
userCurrency
),
value: details.accounts[account.name]?.current ?? 0
};
delete result.Order;
return result;
});
}
public async getAccountsWithAggregations(aUserId: string): Promise<Accounts> {
const accounts = await this.getAccounts(aUserId);
let totalBalance = 0;
let totalValue = 0;
let transactionCount = 0;
for (const account of accounts) {
totalBalance += account.convertedBalance;
totalValue += account.value;
transactionCount += account.transactionCount;
}
return { accounts, totalBalance, totalValue, transactionCount };
}
public async getInvestments( public async getInvestments(
aImpersonationId: string aImpersonationId: string
): Promise<InvestmentItem[]> { ): Promise<InvestmentItem[]> {
@ -111,7 +166,7 @@ export class PortfolioService {
public async getChart( public async getChart(
aImpersonationId: string, aImpersonationId: string,
aDateRange: DateRange = 'max' aDateRange: DateRange = 'max'
): Promise<HistoricalDataItem[]> { ): Promise<HistoricalDataContainer> {
const userId = await this.getUserId(aImpersonationId, this.request.user.id); const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const portfolioCalculator = new PortfolioCalculator( const portfolioCalculator = new PortfolioCalculator(
@ -122,14 +177,21 @@ export class PortfolioService {
const { transactionPoints } = await this.getTransactionPoints({ userId }); const { transactionPoints } = await this.getTransactionPoints({ userId });
portfolioCalculator.setTransactionPoints(transactionPoints); portfolioCalculator.setTransactionPoints(transactionPoints);
if (transactionPoints.length === 0) { if (transactionPoints.length === 0) {
return []; return {
isAllTimeHigh: false,
isAllTimeLow: false,
items: []
};
} }
let portfolioStart = parse( let portfolioStart = parse(
transactionPoints[0].date, transactionPoints[0].date,
DATE_FORMAT, DATE_FORMAT,
new Date() new Date()
); );
portfolioStart = this.getStartDate(aDateRange, portfolioStart);
// Get start date for the full portfolio because of because of the
// min and max calculation
portfolioStart = this.getStartDate('max', portfolioStart);
const timelineSpecification: TimelineSpecification[] = [ const timelineSpecification: TimelineSpecification[] = [
{ {
@ -138,18 +200,52 @@ export class PortfolioService {
} }
]; ];
const timeline = await portfolioCalculator.calculateTimeline( const timelineInfo = await portfolioCalculator.calculateTimeline(
timelineSpecification, timelineSpecification,
format(new Date(), DATE_FORMAT) format(new Date(), DATE_FORMAT)
); );
return timeline const timeline = timelineInfo.timelinePeriods;
const items = timeline
.filter((timelineItem) => timelineItem !== null) .filter((timelineItem) => timelineItem !== null)
.map((timelineItem) => ({ .map((timelineItem) => ({
date: timelineItem.date, date: timelineItem.date,
marketPrice: timelineItem.value, marketPrice: timelineItem.value,
value: timelineItem.netPerformance.toNumber() value: timelineItem.netPerformance.toNumber()
})); }));
let lastItem = null;
if (timeline.length > 0) {
lastItem = timeline[timeline.length - 1];
}
let isAllTimeHigh = timelineInfo.maxNetPerformance?.eq(
lastItem?.netPerformance
);
let isAllTimeLow = timelineInfo.minNetPerformance?.eq(
lastItem?.netPerformance
);
if (isAllTimeHigh && isAllTimeLow) {
isAllTimeHigh = false;
isAllTimeLow = false;
}
portfolioStart = startOfDay(
this.getStartDate(
aDateRange,
parse(transactionPoints[0].date, DATE_FORMAT, new Date())
)
);
return {
isAllTimeHigh,
isAllTimeLow,
items: items.filter((item) => {
// Filter items of date range
return !isAfter(portfolioStart, parseDate(item.date));
})
};
} }
public async getDetails( public async getDetails(
@ -251,14 +347,18 @@ export class PortfolioService {
}; };
} }
// TODO: Add a cash position for each currency const cashPositions = await this.getCashPositions({
holdings[ghostfolioCashSymbol] = await this.getCashPosition({
cashDetails, cashDetails,
userCurrency,
investment: totalInvestment, investment: totalInvestment,
value: totalValue value: totalValue
}); });
const accounts = await this.getAccounts( for (const symbol of Object.keys(cashPositions)) {
holdings[symbol] = cashPositions[symbol];
}
const accounts = await this.getValueOfAccounts(
orders, orders,
portfolioItemsNow, portfolioItemsNow,
userCurrency, userCurrency,
@ -295,10 +395,13 @@ export class PortfolioService {
netPerformancePercent: undefined, netPerformancePercent: undefined,
quantity: undefined, quantity: undefined,
symbol: aSymbol, symbol: aSymbol,
transactionCount: undefined transactionCount: undefined,
value: undefined
}; };
} }
const assetClass = orders[0].SymbolProfile?.assetClass;
const assetSubClass = orders[0].SymbolProfile?.assetSubClass;
const positionCurrency = orders[0].currency; const positionCurrency = orders[0].currency;
const name = orders[0].SymbolProfile?.name ?? ''; const name = orders[0].SymbolProfile?.name ?? '';
@ -412,6 +515,8 @@ export class PortfolioService {
} }
return { return {
assetClass,
assetSubClass,
currency, currency,
firstBuyDate, firstBuyDate,
grossPerformance, grossPerformance,
@ -427,7 +532,12 @@ export class PortfolioService {
historicalData: historicalDataArray, historicalData: historicalDataArray,
netPerformancePercent: position.netPerformancePercentage.toNumber(), netPerformancePercent: position.netPerformancePercentage.toNumber(),
quantity: quantity.toNumber(), quantity: quantity.toNumber(),
symbol: aSymbol symbol: aSymbol,
value: this.exchangeRateDataService.toCurrency(
quantity.mul(marketPrice).toNumber(),
currency,
userCurrency
)
}; };
} else { } else {
const currentData = await this.dataProviderService.get([ const currentData = await this.dataProviderService.get([
@ -467,6 +577,8 @@ export class PortfolioService {
} }
return { return {
assetClass,
assetSubClass,
marketPrice, marketPrice,
maxPrice, maxPrice,
minPrice, minPrice,
@ -482,7 +594,8 @@ export class PortfolioService {
netPerformancePercent: undefined, netPerformancePercent: undefined,
quantity: 0, quantity: 0,
symbol: aSymbol, symbol: aSymbol,
transactionCount: undefined transactionCount: undefined,
value: 0
}; };
} }
} }
@ -580,7 +693,9 @@ export class PortfolioService {
currentGrossPerformancePercent: 0, currentGrossPerformancePercent: 0,
currentNetPerformance: 0, currentNetPerformance: 0,
currentNetPerformancePercent: 0, currentNetPerformancePercent: 0,
currentValue: 0 currentValue: 0,
isAllTimeHigh: false,
isAllTimeLow: false
} }
}; };
} }
@ -613,7 +728,9 @@ export class PortfolioService {
currentGrossPerformancePercent, currentGrossPerformancePercent,
currentNetPerformance, currentNetPerformance,
currentNetPerformancePercent, currentNetPerformancePercent,
currentValue: currentValue currentValue,
isAllTimeHigh: true,
isAllTimeLow: false
} }
}; };
} }
@ -663,7 +780,7 @@ export class PortfolioService {
for (const position of currentPositions.positions) { for (const position of currentPositions.positions) {
portfolioItemsNow[position.symbol] = position; portfolioItemsNow[position.symbol] = position;
} }
const accounts = await this.getAccounts( const accounts = await this.getValueOfAccounts(
orders, orders,
portfolioItemsNow, portfolioItemsNow,
currency, currency,
@ -759,38 +876,73 @@ export class PortfolioService {
}; };
} }
private async getCashPosition({ private async getCashPositions({
cashDetails, cashDetails,
investment, investment,
userCurrency,
value value
}: { }: {
cashDetails: CashDetails; cashDetails: CashDetails;
investment: Big; investment: Big;
value: Big; value: Big;
userCurrency: string;
}) { }) {
const cashValue = new Big(cashDetails.balance); const cashPositions = {};
return { for (const account of cashDetails.accounts) {
allocationCurrent: cashValue.div(value).toNumber(), const convertedBalance = this.exchangeRateDataService.toCurrency(
allocationInvestment: cashValue.div(investment).toNumber(), account.balance,
assetClass: AssetClass.CASH, account.currency,
assetSubClass: AssetClass.CASH, userCurrency
countries: [], );
currency: 'CHF',
grossPerformance: 0, if (convertedBalance === 0) {
grossPerformancePercent: 0, continue;
investment: cashValue.toNumber(), }
marketPrice: 0,
marketState: MarketState.open, if (cashPositions[account.currency]) {
name: 'Cash', cashPositions[account.currency].investment += convertedBalance;
netPerformance: 0, cashPositions[account.currency].value += convertedBalance;
netPerformancePercent: 0, } else {
quantity: 0, cashPositions[account.currency] = {
sectors: [], allocationCurrent: 0,
symbol: ghostfolioCashSymbol, allocationInvestment: 0,
transactionCount: 0, assetClass: AssetClass.CASH,
value: cashValue.toNumber() assetSubClass: AssetClass.CASH,
}; countries: [],
currency: account.currency,
grossPerformance: 0,
grossPerformancePercent: 0,
investment: convertedBalance,
marketPrice: 0,
marketState: MarketState.open,
name: account.currency,
netPerformance: 0,
netPerformancePercent: 0,
quantity: 0,
sectors: [],
symbol: account.currency,
transactionCount: 0,
value: convertedBalance
};
}
}
for (const symbol of Object.keys(cashPositions)) {
// Calculate allocations for each currency
cashPositions[symbol].allocationCurrent = new Big(
cashPositions[symbol].value
)
.div(value)
.toNumber();
cashPositions[symbol].allocationInvestment = new Big(
cashPositions[symbol].investment
)
.div(investment)
.toNumber();
}
return cashPositions;
} }
private getStartDate(aDateRange: DateRange, portfolioStart: Date) { private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
@ -863,7 +1015,7 @@ export class PortfolioService {
}; };
} }
private async getAccounts( private async getValueOfAccounts(
orders: OrderWithAccount[], orders: OrderWithAccount[],
portfolioItemsNow: { [p: string]: TimelinePosition }, portfolioItemsNow: { [p: string]: TimelinePosition },
userCurrency: string, userCurrency: string,
@ -878,32 +1030,22 @@ export class PortfolioService {
return accountId === account.id; return accountId === account.id;
}); });
if (ordersByAccount.length <= 0) { const convertedBalance = this.exchangeRateDataService.toCurrency(
// Add account without orders account.balance,
const balance = this.exchangeRateDataService.toCurrency( account.currency,
account.balance, userCurrency
account.currency, );
userCurrency accounts[account.name] = {
); balance: convertedBalance,
accounts[account.name] = { currency: account.currency,
current: balance, current: convertedBalance,
original: balance original: convertedBalance
}; };
continue;
}
for (const order of ordersByAccount) { for (const order of ordersByAccount) {
let currentValueOfSymbol = this.exchangeRateDataService.toCurrency( let currentValueOfSymbol =
order.quantity * portfolioItemsNow[order.symbol].marketPrice, order.quantity * portfolioItemsNow[order.symbol].marketPrice;
order.currency, let originalValueOfSymbol = order.quantity * order.unitPrice;
userCurrency
);
let originalValueOfSymbol = this.exchangeRateDataService.toCurrency(
order.quantity * order.unitPrice,
order.currency,
userCurrency
);
if (order.type === 'SELL') { if (order.type === 'SELL') {
currentValueOfSymbol *= -1; currentValueOfSymbol *= -1;
@ -917,6 +1059,8 @@ export class PortfolioService {
originalValueOfSymbol; originalValueOfSymbol;
} else { } else {
accounts[order.Account?.name || UNKNOWN_KEY] = { accounts[order.Account?.name || UNKNOWN_KEY] = {
balance: 0,
currency: order.Account?.currency,
current: currentValueOfSymbol, current: currentValueOfSymbol,
original: originalValueOfSymbol original: originalValueOfSymbol
}; };

View File

@ -1,4 +1,7 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_COUPONS } from '@ghostfolio/common/config';
import { Coupon } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -14,6 +17,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Response } from 'express';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { SubscriptionService } from './subscription.service'; import { SubscriptionService } from './subscription.service';
@ -22,16 +26,70 @@ import { SubscriptionService } from './subscription.service';
export class SubscriptionController { export class SubscriptionController {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly propertyService: PropertyService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly subscriptionService: SubscriptionService private readonly subscriptionService: SubscriptionService
) {} ) {}
@Post('redeem-coupon')
@UseGuards(AuthGuard('jwt'))
public async redeemCoupon(
@Body() { couponCode }: { couponCode: string },
@Res() res: Response
) {
if (!this.request.user) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
let coupons =
((await this.propertyService.getByKey(PROPERTY_COUPONS)) as Coupon[]) ??
[];
const isValid = coupons.some((coupon) => {
return coupon.code === couponCode;
});
if (!isValid) {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
await this.subscriptionService.createSubscription(this.request.user.id);
// Destroy coupon
coupons = coupons.filter((coupon) => {
return coupon.code !== couponCode;
});
await this.propertyService.put({
key: PROPERTY_COUPONS,
value: JSON.stringify(coupons)
});
Logger.log(
`Subscription for user '${this.request.user.id}' has been created with coupon`
);
res.status(StatusCodes.OK);
return <any>res.json({
message: getReasonPhrase(StatusCodes.OK),
statusCode: StatusCodes.OK
});
}
@Get('stripe/callback') @Get('stripe/callback')
public async stripeCallback(@Req() req, @Res() res) { public async stripeCallback(@Req() req, @Res() res) {
await this.subscriptionService.createSubscription( const userId = await this.subscriptionService.createSubscriptionViaStripe(
req.query.checkoutSessionId req.query.checkoutSessionId
); );
Logger.log(`Subscription for user '${userId}' has been created via Stripe`);
res.redirect(`${this.configurationService.get('ROOT_URL')}/account`); res.redirect(`${this.configurationService.get('ROOT_URL')}/account`);
} }

View File

@ -1,12 +1,13 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { SubscriptionController } from './subscription.controller'; import { SubscriptionController } from './subscription.controller';
import { SubscriptionService } from './subscription.service'; import { SubscriptionService } from './subscription.service';
@Module({ @Module({
imports: [], imports: [PropertyModule],
controllers: [SubscriptionController], controllers: [SubscriptionController],
providers: [ConfigurationService, PrismaService, SubscriptionService], providers: [ConfigurationService, PrismaService, SubscriptionService],
exports: [SubscriptionService] exports: [SubscriptionService]

View File

@ -2,7 +2,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type'; import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Subscription } from '@prisma/client'; import { Subscription, User } from '@prisma/client';
import { addDays, isBefore } from 'date-fns'; import { addDays, isBefore } from 'date-fns';
import Stripe from 'stripe'; import Stripe from 'stripe';
@ -64,26 +64,32 @@ export class SubscriptionService {
}; };
} }
public async createSubscription(aCheckoutSessionId: string) { public async createSubscription(aUserId: string) {
await this.prismaService.subscription.create({
data: {
expiresAt: addDays(new Date(), 365),
User: {
connect: {
id: aUserId
}
}
}
});
}
public async createSubscriptionViaStripe(aCheckoutSessionId: string) {
try { try {
const session = await this.stripe.checkout.sessions.retrieve( const session = await this.stripe.checkout.sessions.retrieve(
aCheckoutSessionId aCheckoutSessionId
); );
await this.prismaService.subscription.create({ await this.createSubscription(session.client_reference_id);
data: {
expiresAt: addDays(new Date(), 365),
User: {
connect: {
id: session.client_reference_id
}
}
}
});
await this.stripe.customers.update(session.customer as string, { await this.stripe.customers.update(session.customer as string, {
description: session.client_reference_id description: session.client_reference_id
}); });
return session.client_reference_id;
} catch (error) { } catch (error) {
Logger.error(error); Logger.error(error);
} }

View File

@ -1,7 +1,9 @@
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
export interface SymbolItem { export interface SymbolItem {
currency: string; currency: string;
dataSource: DataSource; dataSource: DataSource;
historicalData: HistoricalDataItem[];
marketPrice: number; marketPrice: number;
} }

View File

@ -1,10 +1,12 @@
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
DefaultValuePipe,
Get, Get,
HttpException, HttpException,
Inject, Inject,
Param, Param,
ParseBoolPipe,
Query, Query,
UseGuards UseGuards
} from '@nestjs/common'; } from '@nestjs/common';
@ -51,7 +53,9 @@ export class SymbolController {
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getSymbolData( public async getSymbolData(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string,
@Query('includeHistoricalData', new DefaultValuePipe(false), ParseBoolPipe)
includeHistoricalData: boolean
): Promise<SymbolItem> { ): Promise<SymbolItem> {
if (!DataSource[dataSource]) { if (!DataSource[dataSource]) {
throw new HttpException( throw new HttpException(
@ -60,7 +64,10 @@ export class SymbolController {
); );
} }
const result = await this.symbolService.get({ dataSource, symbol }); const result = await this.symbolService.get({
includeHistoricalData,
dataGatheringItem: { dataSource, symbol }
});
if (!result || isEmpty(result)) { if (!result || isEmpty(result)) {
throw new HttpException( throw new HttpException(

View File

@ -1,5 +1,6 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -7,7 +8,12 @@ import { SymbolController } from './symbol.controller';
import { SymbolService } from './symbol.service'; import { SymbolService } from './symbol.service';
@Module({ @Module({
imports: [ConfigurationModule, DataProviderModule, PrismaModule], imports: [
ConfigurationModule,
DataProviderModule,
MarketDataModule,
PrismaModule
],
controllers: [SymbolController], controllers: [SymbolController],
providers: [SymbolService] providers: [SymbolService]
}) })

View File

@ -1,8 +1,11 @@
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { subDays } from 'date-fns';
import { LookupItem } from './interfaces/lookup-item.interface'; import { LookupItem } from './interfaces/lookup-item.interface';
import { SymbolItem } from './interfaces/symbol-item.interface'; import { SymbolItem } from './interfaces/symbol-item.interface';
@ -11,16 +14,42 @@ import { SymbolItem } from './interfaces/symbol-item.interface';
export class SymbolService { export class SymbolService {
public constructor( public constructor(
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService private readonly prismaService: PrismaService
) {} ) {}
public async get(dataGatheringItem: IDataGatheringItem): Promise<SymbolItem> { public async get({
dataGatheringItem,
includeHistoricalData = false
}: {
dataGatheringItem: IDataGatheringItem;
includeHistoricalData?: boolean;
}): Promise<SymbolItem> {
const response = await this.dataProviderService.get([dataGatheringItem]); const response = await this.dataProviderService.get([dataGatheringItem]);
const { currency, marketPrice } = response[dataGatheringItem.symbol] ?? {}; const { currency, marketPrice } = response[dataGatheringItem.symbol] ?? {};
if (dataGatheringItem.dataSource && marketPrice) { if (dataGatheringItem.dataSource && marketPrice) {
let historicalData: HistoricalDataItem[];
if (includeHistoricalData) {
const days = 10;
const marketData = await this.marketDataService.getRange({
dateQuery: { gte: subDays(new Date(), days) },
symbols: [dataGatheringItem.symbol]
});
historicalData = marketData.map(({ date, marketPrice }) => {
return {
date: date.toISOString(),
value: marketPrice
};
});
}
return { return {
currency, currency,
historicalData,
marketPrice, marketPrice,
dataSource: dataGatheringItem.dataSource dataSource: dataGatheringItem.dataSource
}; };

View File

@ -1,7 +1,10 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_IS_READ_ONLY_MODE } from '@ghostfolio/common/config';
import { User } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces';
import { import {
getPermissions,
hasPermission, hasPermission,
hasRole,
permissions permissions
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
@ -20,7 +23,7 @@ import {
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Provider } from '@prisma/client'; import { Provider, Role } from '@prisma/client';
import { User as UserModel } from '@prisma/client'; import { User as UserModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -34,7 +37,9 @@ import { UserService } from './user.service';
@Controller('user') @Controller('user')
export class UserController { export class UserController {
public constructor( public constructor(
private readonly configurationService: ConfigurationService,
private jwtService: JwtService, private jwtService: JwtService,
private readonly propertyService: PropertyService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService private readonly userService: UserService
) {} ) {}
@ -43,10 +48,7 @@ export class UserController {
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async deleteUser(@Param('id') id: string): Promise<UserModel> { public async deleteUser(@Param('id') id: string): Promise<UserModel> {
if ( if (
!hasPermission( !hasPermission(this.request.user.permissions, permissions.deleteUser) ||
getPermissions(this.request.user.role),
permissions.deleteUser
) ||
id === this.request.user.id id === this.request.user.id
) { ) {
throw new HttpException( throw new HttpException(
@ -68,6 +70,19 @@ export class UserController {
@Post() @Post()
public async signupUser(): Promise<UserItem> { public async signupUser(): Promise<UserItem> {
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
const isReadOnlyMode = (await this.propertyService.getByKey(
PROPERTY_IS_READ_ONLY_MODE
)) as boolean;
if (isReadOnlyMode) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
}
const { accessToken, id } = await this.userService.createUser({ const { accessToken, id } = await this.userService.createUser({
provider: Provider.ANONYMOUS provider: Provider.ANONYMOUS
}); });
@ -85,7 +100,7 @@ export class UserController {
public async updateUserSetting(@Body() data: UpdateUserSettingDto) { public async updateUserSetting(@Body() data: UpdateUserSettingDto) {
if ( if (
!hasPermission( !hasPermission(
getPermissions(this.request.user.role), this.request.user.permissions,
permissions.updateUserSettings permissions.updateUserSettings
) )
) { ) {
@ -111,7 +126,7 @@ export class UserController {
public async updateUserSettings(@Body() data: UpdateUserSettingsDto) { public async updateUserSettings(@Body() data: UpdateUserSettingsDto) {
if ( if (
!hasPermission( !hasPermission(
getPermissions(this.request.user.role), this.request.user.permissions,
permissions.updateUserSettings permissions.updateUserSettings
) )
) { ) {
@ -127,10 +142,7 @@ export class UserController {
}; };
if ( if (
hasPermission( hasPermission(this.request.user.permissions, permissions.updateViewMode)
getPermissions(this.request.user.role),
permissions.updateViewMode
)
) { ) {
userSettings.viewMode = data.viewMode; userSettings.viewMode = data.viewMode;
} }

View File

@ -1,6 +1,7 @@
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
@ -13,6 +14,7 @@ import { UserService } from './user.service';
secret: process.env.JWT_SECRET_KEY, secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '30 days' } signOptions: { expiresIn: '30 days' }
}), }),
PropertyModule,
SubscriptionModule SubscriptionModule
], ],
controllers: [UserController], controllers: [UserController],

View File

@ -1,12 +1,21 @@
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { baseCurrency, locale } from '@ghostfolio/common/config'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
PROPERTY_IS_READ_ONLY_MODE,
baseCurrency,
locale
} from '@ghostfolio/common/config';
import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces'; import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces';
import { getPermissions, permissions } from '@ghostfolio/common/permissions'; import {
getPermissions,
hasRole,
permissions
} from '@ghostfolio/common/permissions';
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type'; import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Prisma, Provider, User, ViewMode } from '@prisma/client'; import { Prisma, Provider, Role, User, ViewMode } from '@prisma/client';
import { UserSettingsParams } from './interfaces/user-settings-params.interface'; import { UserSettingsParams } from './interfaces/user-settings-params.interface';
import { UserSettings } from './interfaces/user-settings.interface'; import { UserSettings } from './interfaces/user-settings.interface';
@ -20,6 +29,7 @@ export class UserService {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService private readonly subscriptionService: SubscriptionService
) {} ) {}
@ -74,12 +84,32 @@ export class UserService {
const user: UserWithSettings = userFromDatabase; const user: UserWithSettings = userFromDatabase;
const currentPermissions = getPermissions(userFromDatabase.role); let currentPermissions = getPermissions(userFromDatabase.role);
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) { if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
currentPermissions.push(permissions.accessFearAndGreedIndex); currentPermissions.push(permissions.accessFearAndGreedIndex);
} }
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
if (hasRole(user, Role.ADMIN)) {
currentPermissions.push(permissions.toggleReadOnlyMode);
}
const isReadOnlyMode = (await this.propertyService.getByKey(
PROPERTY_IS_READ_ONLY_MODE
)) as boolean;
if (isReadOnlyMode) {
currentPermissions = currentPermissions.filter((permission) => {
return !(
permission.startsWith('create') ||
permission.startsWith('delete') ||
permission.startsWith('update')
);
});
}
}
user.permissions = currentPermissions; user.permissions = currentPermissions;
if (userFromDatabase?.Settings) { if (userFromDatabase?.Settings) {
@ -119,7 +149,7 @@ export class UserService {
take?: number; take?: number;
cursor?: Prisma.UserWhereUniqueInput; cursor?: Prisma.UserWhereUniqueInput;
where?: Prisma.UserWhereInput; where?: Prisma.UserWhereInput;
orderBy?: Prisma.UserOrderByInput; orderBy?: Prisma.UserOrderByWithRelationInput;
}): Promise<User[]> { }): Promise<User[]> {
const { skip, take, cursor, where, orderBy } = params; const { skip, take, cursor, where, orderBy } = params;
return this.prismaService.user.findMany({ return this.prismaService.user.findMany({

View File

@ -2,6 +2,7 @@ import { Logger, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { AppModule } from './app/app.module'; import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
@ -18,8 +19,21 @@ async function bootstrap() {
const port = process.env.PORT || 3333; const port = process.env.PORT || 3333;
await app.listen(port, () => { await app.listen(port, () => {
logLogo();
Logger.log(`Listening at http://localhost:${port}`); Logger.log(`Listening at http://localhost:${port}`);
Logger.log('');
}); });
} }
function logLogo() {
Logger.log(' ________ __ ____ ___');
Logger.log(' / ____/ /_ ____ _____/ /_/ __/___ / (_)___');
Logger.log(' / / __/ __ \\/ __ \\/ ___/ __/ /_/ __ \\/ / / __ \\');
Logger.log('/ /_/ / / / / /_/ (__ ) /_/ __/ /_/ / / / /_/ /');
Logger.log(
`\\____/_/ /_/\\____/____/\\__/_/ \\____/_/_/\\____/ ${environment.version}`
);
Logger.log('');
}
bootstrap(); bootstrap();

View File

@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { bool, cleanEnv, host, json, num, port, str } from 'envalid'; import { bool, cleanEnv, host, json, num, port, str } from 'envalid';
import { environment } from '../environments/environment';
import { Environment } from './interfaces/environment.interface'; import { Environment } from './interfaces/environment.interface';
@Injectable() @Injectable()
@ -18,10 +17,12 @@ export class ConfigurationService {
ENABLE_FEATURE_BLOG: bool({ default: false }), ENABLE_FEATURE_BLOG: bool({ default: false }),
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }), ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }), ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
ENABLE_FEATURE_IMPORT: bool({ default: !environment.production }), ENABLE_FEATURE_IMPORT: bool({ default: true }),
ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }),
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }), ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),
ENABLE_FEATURE_STATISTICS: bool({ default: false }), ENABLE_FEATURE_STATISTICS: bool({ default: false }),
ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }), ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }),
ENABLE_FEATURE_SYSTEM_MESSAGE: bool({ default: false }),
GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }), GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }),
GOOGLE_SECRET: str({ default: 'dummySecret' }), GOOGLE_SECRET: str({ default: 'dummySecret' }),
JWT_SECRET_KEY: str({}), JWT_SECRET_KEY: str({}),

View File

@ -1,5 +1,6 @@
{ {
"1INCH": "1inch", "1INCH": "1inch",
"ALGO": "Algorand",
"AVAX": "Avalanche", "AVAX": "Avalanche",
"MATIC": "Polygon", "MATIC": "Polygon",
"SHIB": "Shiba Inu" "SHIB": "Shiba Inu"

View File

@ -1,11 +1,12 @@
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { import {
benchmarks, PROPERTY_LAST_DATA_GATHERING,
PROPERTY_LOCKED_DATA_GATHERING,
ghostfolioFearAndGreedIndexSymbol ghostfolioFearAndGreedIndexSymbol
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper'; import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource, MarketData } from '@prisma/client';
import { import {
differenceInHours, differenceInHours,
format, format,
@ -18,7 +19,6 @@ import {
import { ConfigurationService } from './configuration.service'; import { ConfigurationService } from './configuration.service';
import { DataProviderService } from './data-provider/data-provider.service'; import { DataProviderService } from './data-provider/data-provider.service';
import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface'; import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface';
import { ExchangeRateDataService } from './exchange-rate-data.service'; import { ExchangeRateDataService } from './exchange-rate-data.service';
import { IDataGatheringItem } from './interfaces/interfaces'; import { IDataGatheringItem } from './interfaces/interfaces';
@ -26,13 +26,14 @@ import { PrismaService } from './prisma.service';
@Injectable() @Injectable()
export class DataGatheringService { export class DataGatheringService {
private dataGatheringProgress: number;
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
@Inject('DataEnhancers') @Inject('DataEnhancers')
private readonly dataEnhancers: DataEnhancerInterface[], private readonly dataEnhancers: DataEnhancerInterface[],
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly ghostfolioScraperApi: GhostfolioScraperApiService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) {} ) {}
@ -46,7 +47,7 @@ export class DataGatheringService {
await this.prismaService.property.create({ await this.prismaService.property.create({
data: { data: {
key: 'LOCKED_DATA_GATHERING', key: PROPERTY_LOCKED_DATA_GATHERING,
value: new Date().toISOString() value: new Date().toISOString()
} }
}); });
@ -58,11 +59,11 @@ export class DataGatheringService {
await this.prismaService.property.upsert({ await this.prismaService.property.upsert({
create: { create: {
key: 'LAST_DATA_GATHERING', key: PROPERTY_LAST_DATA_GATHERING,
value: new Date().toISOString() value: new Date().toISOString()
}, },
update: { value: new Date().toISOString() }, update: { value: new Date().toISOString() },
where: { key: 'LAST_DATA_GATHERING' } where: { key: PROPERTY_LAST_DATA_GATHERING }
}); });
} catch (error) { } catch (error) {
Logger.error(error); Logger.error(error);
@ -70,7 +71,7 @@ export class DataGatheringService {
await this.prismaService.property.delete({ await this.prismaService.property.delete({
where: { where: {
key: 'LOCKED_DATA_GATHERING' key: PROPERTY_LOCKED_DATA_GATHERING
} }
}); });
@ -81,7 +82,7 @@ export class DataGatheringService {
public async gatherMax() { public async gatherMax() {
const isDataGatheringLocked = await this.prismaService.property.findUnique({ const isDataGatheringLocked = await this.prismaService.property.findUnique({
where: { key: 'LOCKED_DATA_GATHERING' } where: { key: PROPERTY_LOCKED_DATA_GATHERING }
}); });
if (!isDataGatheringLocked) { if (!isDataGatheringLocked) {
@ -90,7 +91,7 @@ export class DataGatheringService {
await this.prismaService.property.create({ await this.prismaService.property.create({
data: { data: {
key: 'LOCKED_DATA_GATHERING', key: PROPERTY_LOCKED_DATA_GATHERING,
value: new Date().toISOString() value: new Date().toISOString()
} }
}); });
@ -102,11 +103,11 @@ export class DataGatheringService {
await this.prismaService.property.upsert({ await this.prismaService.property.upsert({
create: { create: {
key: 'LAST_DATA_GATHERING', key: PROPERTY_LAST_DATA_GATHERING,
value: new Date().toISOString() value: new Date().toISOString()
}, },
update: { value: new Date().toISOString() }, update: { value: new Date().toISOString() },
where: { key: 'LAST_DATA_GATHERING' } where: { key: PROPERTY_LAST_DATA_GATHERING }
}); });
} catch (error) { } catch (error) {
Logger.error(error); Logger.error(error);
@ -114,7 +115,7 @@ export class DataGatheringService {
await this.prismaService.property.delete({ await this.prismaService.property.delete({
where: { where: {
key: 'LOCKED_DATA_GATHERING' key: PROPERTY_LOCKED_DATA_GATHERING
} }
}); });
@ -123,6 +124,101 @@ export class DataGatheringService {
} }
} }
public async gatherSymbol({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
const isDataGatheringLocked = await this.prismaService.property.findUnique({
where: { key: PROPERTY_LOCKED_DATA_GATHERING }
});
if (!isDataGatheringLocked) {
Logger.log(`Symbol data gathering for ${symbol} has been started.`);
console.time('data-gathering-symbol');
await this.prismaService.property.create({
data: {
key: PROPERTY_LOCKED_DATA_GATHERING,
value: new Date().toISOString()
}
});
const symbols = (await this.getSymbolsMax()).filter(
(dataGatheringItem) => {
return (
dataGatheringItem.dataSource === dataSource &&
dataGatheringItem.symbol === symbol
);
}
);
try {
await this.gatherSymbols(symbols);
await this.prismaService.property.upsert({
create: {
key: PROPERTY_LAST_DATA_GATHERING,
value: new Date().toISOString()
},
update: { value: new Date().toISOString() },
where: { key: PROPERTY_LAST_DATA_GATHERING }
});
} catch (error) {
Logger.error(error);
}
await this.prismaService.property.delete({
where: {
key: PROPERTY_LOCKED_DATA_GATHERING
}
});
Logger.log(`Symbol data gathering for ${symbol} has been completed.`);
console.timeEnd('data-gathering-symbol');
}
}
public async gatherSymbolForDate({
dataSource,
date,
symbol
}: {
dataSource: DataSource;
date: Date;
symbol: string;
}) {
try {
const historicalData = await this.dataProviderService.getHistoricalRaw(
[{ dataSource, symbol }],
date,
date
);
const marketPrice =
historicalData[symbol][format(date, DATE_FORMAT)].marketPrice;
if (marketPrice) {
return await this.prismaService.marketData.upsert({
create: {
dataSource,
date,
marketPrice,
symbol
},
update: { marketPrice },
where: { date_symbol: { date, symbol } }
});
}
} catch (error) {
Logger.error(error);
} finally {
return undefined;
}
}
public async gatherProfileData(aDataGatheringItems?: IDataGatheringItem[]) { public async gatherProfileData(aDataGatheringItems?: IDataGatheringItem[]) {
Logger.log('Profile data gathering has been started.'); Logger.log('Profile data gathering has been started.');
console.time('data-gathering-profile'); console.time('data-gathering-profile');
@ -204,8 +300,11 @@ export class DataGatheringService {
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) { public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
let hasError = false; let hasError = false;
let symbolCounter = 0;
for (const { dataSource, date, symbol } of aSymbolsWithStartDate) { for (const { dataSource, date, symbol } of aSymbolsWithStartDate) {
this.dataGatheringProgress = symbolCounter / aSymbolsWithStartDate.length;
try { try {
const historicalData = await this.dataProviderService.getHistoricalRaw( const historicalData = await this.dataProviderService.getHistoricalRaw(
[{ dataSource, symbol }], [{ dataSource, symbol }],
@ -263,6 +362,16 @@ export class DataGatheringService {
hasError = true; hasError = true;
Logger.error(error); Logger.error(error);
} }
if (symbolCounter > 0 && symbolCounter % 100 === 0) {
Logger.log(
`Data gathering progress: ${(
this.dataGatheringProgress * 100
).toFixed(2)}%`
);
}
symbolCounter += 1;
} }
await this.exchangeRateDataService.initialize(); await this.exchangeRateDataService.initialize();
@ -272,15 +381,25 @@ export class DataGatheringService {
} }
} }
public async getDataGatheringProgress() {
const isInProgress = await this.getIsInProgress();
if (isInProgress) {
return this.dataGatheringProgress;
}
return undefined;
}
public async getIsInProgress() { public async getIsInProgress() {
return await this.prismaService.property.findUnique({ return await this.prismaService.property.findUnique({
where: { key: 'LOCKED_DATA_GATHERING' } where: { key: PROPERTY_LOCKED_DATA_GATHERING }
}); });
} }
public async getLastDataGathering() { public async getLastDataGathering() {
const lastDataGathering = await this.prismaService.property.findUnique({ const lastDataGathering = await this.prismaService.property.findUnique({
where: { key: 'LAST_DATA_GATHERING' } where: { key: PROPERTY_LAST_DATA_GATHERING }
}); });
if (lastDataGathering?.value) { if (lastDataGathering?.value) {
@ -290,24 +409,67 @@ export class DataGatheringService {
return undefined; return undefined;
} }
public async getSymbolsMax(): Promise<IDataGatheringItem[]> {
const startDate =
(
await this.prismaService.order.findFirst({
orderBy: [{ date: 'asc' }]
})
)?.date ?? new Date();
const currencyPairsToGather = this.exchangeRateDataService
.getCurrencyPairs()
.map(({ dataSource, symbol }) => {
return {
dataSource,
symbol,
date: startDate
};
});
const symbolProfilesToGather = (
await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }],
select: {
dataSource: true,
Order: {
orderBy: [{ date: 'asc' }],
select: { date: true },
take: 1
},
scraperConfiguration: true,
symbol: true
}
})
).map((symbolProfile) => {
return {
...symbolProfile,
date: symbolProfile.Order?.[0]?.date ?? startDate
};
});
return [
...this.getBenchmarksToGather(startDate),
...currencyPairsToGather,
...symbolProfilesToGather
];
}
public async reset() { public async reset() {
Logger.log('Data gathering has been reset.'); Logger.log('Data gathering has been reset.');
await this.prismaService.property.deleteMany({ await this.prismaService.property.deleteMany({
where: { where: {
OR: [{ key: 'LAST_DATA_GATHERING' }, { key: 'LOCKED_DATA_GATHERING' }] OR: [
{ key: PROPERTY_LAST_DATA_GATHERING },
{ key: PROPERTY_LOCKED_DATA_GATHERING }
]
} }
}); });
} }
private getBenchmarksToGather(startDate: Date): IDataGatheringItem[] { private getBenchmarksToGather(startDate: Date): IDataGatheringItem[] {
const benchmarksToGather = benchmarks.map(({ dataSource, symbol }) => { const benchmarksToGather: IDataGatheringItem[] = [];
return {
dataSource,
symbol,
date: startDate
};
});
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) { if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
benchmarksToGather.push({ benchmarksToGather.push({
@ -356,52 +518,6 @@ export class DataGatheringService {
]; ];
} }
private async getSymbolsMax(): Promise<IDataGatheringItem[]> {
const startDate =
(
await this.prismaService.order.findFirst({
orderBy: [{ date: 'asc' }]
})
)?.date ?? new Date();
const currencyPairsToGather = this.exchangeRateDataService
.getCurrencyPairs()
.map(({ dataSource, symbol }) => {
return {
dataSource,
symbol,
date: startDate
};
});
const symbolProfilesToGather = (
await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }],
select: {
dataSource: true,
Order: {
orderBy: [{ date: 'asc' }],
select: { date: true },
take: 1
},
scraperConfiguration: true,
symbol: true
}
})
).map((symbolProfile) => {
return {
...symbolProfile,
date: symbolProfile.Order?.[0]?.date ?? startDate
};
});
return [
...this.getBenchmarksToGather(startDate),
...currencyPairsToGather,
...symbolProfilesToGather
];
}
private async getSymbolsProfileData(): Promise<IDataGatheringItem[]> { private async getSymbolsProfileData(): Promise<IDataGatheringItem[]> {
const startDate = subDays(resetHours(new Date()), 7); const startDate = subDays(resetHours(new Date()), 7);
@ -425,7 +541,7 @@ export class DataGatheringService {
const lastDataGathering = await this.getLastDataGathering(); const lastDataGathering = await this.getLastDataGathering();
const isDataGatheringLocked = await this.prismaService.property.findUnique({ const isDataGatheringLocked = await this.prismaService.property.findUnique({
where: { key: 'LOCKED_DATA_GATHERING' } where: { key: PROPERTY_LOCKED_DATA_GATHERING }
}); });
const diffInHours = differenceInHours(new Date(), lastDataGathering); const diffInHours = differenceInHours(new Date(), lastDataGathering);

View File

@ -11,7 +11,7 @@ import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource, MarketData } from '@prisma/client';
import { format } from 'date-fns'; import { format, isValid } from 'date-fns';
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
@Injectable() @Injectable()
@ -62,7 +62,7 @@ export class DataProviderService {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
} = {}; } = {};
if (isEmpty(aItems)) { if (isEmpty(aItems) || !isValid(from) || !isValid(to)) {
return response; return response;
} }
@ -96,7 +96,7 @@ export class DataProviderService {
ORDER BY date;`; ORDER BY date;`;
const marketDataByGranularity: MarketData[] = const marketDataByGranularity: MarketData[] =
await this.prismaService.$queryRaw(queryRaw); await this.prismaService.$queryRawUnsafe(queryRaw);
response = marketDataByGranularity.reduce((r, marketData) => { response = marketDataByGranularity.reduce((r, marketData) => {
const { date, marketPrice, symbol } = marketData; const { date, marketPrice, symbol } = marketData;

View File

@ -2,12 +2,7 @@ import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.in
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config'; import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
import { import { DATE_FORMAT, getToday, getYesterday } from '@ghostfolio/common/helper';
DATE_FORMAT,
getToday,
getYesterday,
isRakutenRapidApiSymbol
} from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
@ -31,10 +26,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
) {} ) {}
public canHandle(symbol: string) { public canHandle(symbol: string) {
return ( return !!this.configurationService.get('RAKUTEN_RAPID_API_KEY');
isRakutenRapidApiSymbol(symbol) &&
!!this.configurationService.get('RAKUTEN_RAPID_API_KEY')
);
} }
public async get( public async get(

View File

@ -8,7 +8,7 @@ import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
import * as bent from 'bent'; import * as bent from 'bent';
import Big from 'big.js'; import Big from 'big.js';
import { countries } from 'countries-list'; import { countries } from 'countries-list';
import { format } from 'date-fns'; import { addDays, format, isSameDay } from 'date-fns';
import * as yahooFinance from 'yahoo-finance'; import * as yahooFinance from 'yahoo-finance';
import { import {
@ -135,6 +135,10 @@ export class YahooFinanceService implements DataProviderInterface {
return {}; return {};
} }
if (isSameDay(from, to)) {
to = addDays(to, 1);
}
const yahooFinanceSymbols = aSymbols.map((symbol) => { const yahooFinanceSymbols = aSymbols.map((symbol) => {
return this.convertToYahooFinanceSymbol(symbol); return this.convertToYahooFinanceSymbol(symbol);
}); });
@ -197,16 +201,20 @@ export class YahooFinanceService implements DataProviderInterface {
// filter out undefined symbols // filter out undefined symbols
return quote.symbol; return quote.symbol;
}) })
.filter(({ quoteType }) => { .filter(({ quoteType, symbol }) => {
return ( return (
quoteType === 'CRYPTOCURRENCY' || (quoteType === 'CRYPTOCURRENCY' &&
this.cryptocurrencyService.isCrypto(
symbol.replace(new RegExp('-USD$'), 'USD').replace('1', '')
)) ||
quoteType === 'EQUITY' || quoteType === 'EQUITY' ||
quoteType === 'ETF' quoteType === 'ETF'
); );
}) })
.filter(({ quoteType, symbol }) => { .filter(({ quoteType, symbol }) => {
if (quoteType === 'CRYPTOCURRENCY') { if (quoteType === 'CRYPTOCURRENCY') {
// Only allow cryptocurrencies in USD // Only allow cryptocurrencies in USD to avoid having redundancy in the database.
// Trades need to be converted manually before to USD (or a UI converter needs to be developed)
return symbol.includes('USD'); return symbol.includes('USD');
} }
@ -254,14 +262,15 @@ export class YahooFinanceService implements DataProviderInterface {
if (isCurrency(aSymbol.substring(0, aSymbol.length - 3))) { if (isCurrency(aSymbol.substring(0, aSymbol.length - 3))) {
return `${aSymbol}=X`; return `${aSymbol}=X`;
} else if ( } else if (
this.cryptocurrencyService.isCrypto(aSymbol) || this.cryptocurrencyService.isCrypto(
this.cryptocurrencyService.isCrypto(aSymbol.replace('1', '')) aSymbol.replace(new RegExp('-USD$'), 'USD').replace('1', '')
)
) { ) {
// Add a dash before the last three characters // Add a dash before the last three characters
// BTCUSD -> BTC-USD // BTCUSD -> BTC-USD
// DOGEUSD -> DOGE-USD // DOGEUSD -> DOGE-USD
// SOL1USD -> SOL1-USD // SOL1USD -> SOL1-USD
return aSymbol.replace('USD', '-USD'); return aSymbol.replace(new RegExp('-?USD$'), '-USD');
} }
} }

View File

@ -3,9 +3,10 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { PrismaModule } from './prisma.module'; import { PrismaModule } from './prisma.module';
import { PropertyModule } from './property/property.module';
@Module({ @Module({
imports: [DataProviderModule, PrismaModule], imports: [DataProviderModule, PrismaModule, PropertyModule],
providers: [ExchangeRateDataService], providers: [ExchangeRateDataService],
exports: [ExchangeRateDataService] exports: [ExchangeRateDataService]
}) })

View File

@ -1,13 +1,13 @@
import { baseCurrency } from '@ghostfolio/common/config'; import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config';
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper'; import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { isEmpty, isNumber, uniq } from 'lodash'; import { isEmpty, isNumber, uniq } from 'lodash';
import { DataProviderService } from './data-provider/data-provider.service'; import { DataProviderService } from './data-provider/data-provider.service';
import { IDataGatheringItem } from './interfaces/interfaces'; import { IDataGatheringItem } from './interfaces/interfaces';
import { PrismaService } from './prisma.service'; import { PrismaService } from './prisma.service';
import { PropertyService } from './property/property.service';
@Injectable() @Injectable()
export class ExchangeRateDataService { export class ExchangeRateDataService {
@ -17,7 +17,8 @@ export class ExchangeRateDataService {
public constructor( public constructor(
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly prismaService: PrismaService private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService
) { ) {
this.initialize(); this.initialize();
} }
@ -40,7 +41,10 @@ export class ExchangeRateDataService {
currency2, currency2,
dataSource dataSource
} of this.prepareCurrencyPairs(this.currencies)) { } of this.prepareCurrencyPairs(this.currencies)) {
this.addCurrencyPairs({ currency1, currency2, dataSource }); this.currencyPairs.push({
dataSource,
symbol: `${currency1}${currency2}`
});
} }
await this.loadCurrencies(); await this.loadCurrencies();
@ -86,7 +90,7 @@ export class ExchangeRateDataService {
}; };
}); });
this.currencyPairs.forEach(({ symbol }) => { Object.keys(resultExtended).forEach((symbol) => {
const [currency1, currency2] = symbol.match(/.{1,3}/g); const [currency1, currency2] = symbol.match(/.{1,3}/g);
const date = format(getYesterday(), DATE_FORMAT); const date = format(getYesterday(), DATE_FORMAT);
@ -146,27 +150,8 @@ export class ExchangeRateDataService {
return aValue; return aValue;
} }
private addCurrencyPairs({
currency1,
currency2,
dataSource
}: {
currency1: string;
currency2: string;
dataSource: DataSource;
}) {
this.currencyPairs.push({
dataSource,
symbol: `${currency1}${currency2}`
});
this.currencyPairs.push({
dataSource,
symbol: `${currency2}${currency1}`
});
}
private async prepareCurrencies(): Promise<string[]> { private async prepareCurrencies(): Promise<string[]> {
const currencies: string[] = []; let currencies: string[] = [];
( (
await this.prismaService.account.findMany({ await this.prismaService.account.findMany({
@ -198,6 +183,14 @@ export class ExchangeRateDataService {
currencies.push(symbolProfile.currency); currencies.push(symbolProfile.currency);
}); });
const customCurrencies = (await this.propertyService.getByKey(
PROPERTY_CURRENCIES
)) as string[];
if (customCurrencies?.length > 0) {
currencies = currencies.concat(customCurrencies);
}
return uniq(currencies).sort(); return uniq(currencies).sort();
} }

View File

@ -9,9 +9,11 @@ export interface Environment extends CleanedEnvAccessors {
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean; ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean; ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
ENABLE_FEATURE_IMPORT: boolean; ENABLE_FEATURE_IMPORT: boolean;
ENABLE_FEATURE_READ_ONLY_MODE: boolean;
ENABLE_FEATURE_SOCIAL_LOGIN: boolean; ENABLE_FEATURE_SOCIAL_LOGIN: boolean;
ENABLE_FEATURE_STATISTICS: boolean; ENABLE_FEATURE_STATISTICS: boolean;
ENABLE_FEATURE_SUBSCRIPTION: boolean; ENABLE_FEATURE_SUBSCRIPTION: boolean;
ENABLE_FEATURE_SYSTEM_MESSAGE: boolean;
GOOGLE_CLIENT_ID: string; GOOGLE_CLIENT_ID: string;
GOOGLE_SECRET: string; GOOGLE_SECRET: string;
JWT_SECRET_KEY: string; JWT_SECRET_KEY: string;

View File

@ -0,0 +1,11 @@
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { Module } from '@nestjs/common';
import { MarketDataService } from './market-data.service';
@Module({
exports: [MarketDataService],
imports: [PrismaModule],
providers: [MarketDataService]
})
export class MarketDataModule {}

View File

@ -1,9 +1,8 @@
import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.interface';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { resetHours } from '@ghostfolio/common/helper'; import { resetHours } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { MarketData } from '@prisma/client'; import { MarketData, Prisma } from '@prisma/client';
import { DateQuery } from './interfaces/date-query.interface';
@Injectable() @Injectable()
export class MarketDataService { export class MarketDataService {
@ -48,4 +47,22 @@ export class MarketDataService {
} }
}); });
} }
public async marketDataItems(params: {
skip?: number;
take?: number;
cursor?: Prisma.MarketDataWhereUniqueInput;
where?: Prisma.MarketDataWhereInput;
orderBy?: Prisma.MarketDataOrderByWithRelationInput;
}): Promise<MarketData[]> {
const { skip, take, cursor, where, orderBy } = params;
return this.prismaService.marketData.findMany({
cursor,
orderBy,
skip,
take,
where
});
}
} }

View File

@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class PropertyDto {
@IsString()
value: string;
}

View File

@ -0,0 +1,11 @@
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { Module } from '@nestjs/common';
import { PropertyService } from './property.service';
@Module({
exports: [PropertyService],
imports: [PrismaModule],
providers: [PropertyService]
})
export class PropertyModule {}

View File

@ -0,0 +1,49 @@
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
import { Injectable } from '@nestjs/common';
@Injectable()
export class PropertyService {
public constructor(private readonly prismaService: PrismaService) {}
public async delete({ key }: { key: string }) {
return this.prismaService.property.delete({
where: { key }
});
}
public async get() {
const response: {
[key: string]: boolean | object | string | string[];
} = {
[PROPERTY_CURRENCIES]: []
};
const properties = await this.prismaService.property.findMany();
for (const property of properties) {
let value = property.value;
try {
value = JSON.parse(property.value);
} catch {}
response[property.key] = value;
}
return response;
}
public async getByKey(aKey: string) {
const properties = await this.get();
return properties?.[aKey];
}
public async put({ key, value }: { key: string; value: string }) {
return this.prismaService.property.upsert({
create: { key, value },
update: { value },
where: { key }
});
}
}

View File

@ -6,6 +6,6 @@
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"target": "es2015" "target": "es2015"
}, },
"exclude": ["**/*.spec.ts"], "exclude": ["**/*.spec.ts", "**/*.test.ts"],
"include": ["**/*.ts"] "include": ["**/*.ts"]
} }

View File

@ -5,5 +5,5 @@
"module": "commonjs", "module": "commonjs",
"types": ["jest", "node"] "types": ["jest", "node"]
}, },
"include": ["**/*.spec.ts", "**/*.d.ts"] "include": ["**/*.spec.ts", "**/*.test.ts", "**/*.d.ts"]
} }

View File

@ -14,5 +14,8 @@ module.exports = {
'jest-preset-angular/build/serializers/ng-snapshot', 'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment' 'jest-preset-angular/build/serializers/html-comment'
], ],
transform: { '^.+\\.(ts|js|html)$': 'jest-preset-angular' } transform: {
'^.+.(ts|mjs|js|html)$': 'jest-preset-angular'
},
transformIgnorePatterns: ['node_modules/(?!.*.mjs$)']
}; };

View File

@ -9,18 +9,27 @@
</header> </header>
<main role="main"> <main role="main">
<div *ngIf="canCreateAccount" class="container create-account-container"> <div
*ngIf="canCreateAccount || (info?.systemMessage && user)"
class="container info-message-container"
>
<div class="row"> <div class="row">
<div class="col-md-8 offset-md-2 text-center"> <div class="col-md-8 offset-md-2 text-center">
<a class="text-center" [routerLink]="['/']"> <a *ngIf="canCreateAccount" class="text-center" [routerLink]="['/']">
<div <div
class="create-account-box d-inline-block px-3 py-2" class="cursor-pointer d-inline-block info-message px-3 py-2"
(click)="onCreateAccount()" (click)="onCreateAccount()"
> >
<span i18n>You are using the Live Demo.</span> <span i18n>You are using the Live Demo.</span>
<a class="ml-2" href="#" i18n>Create Account</a> <a class="ml-2" href="#" i18n>Create Account</a>
</div></a </div></a
> >
<div
*ngIf="!canCreateAccount && info?.systemMessage && user"
class="d-inline-block info-message px-3 py-2"
>
{{ info.systemMessage }}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -8,14 +8,13 @@
min-height: 100vh; min-height: 100vh;
padding-top: 5rem; padding-top: 5rem;
.create-account-container { .info-message-container {
height: 3.5rem; height: 3.5rem;
margin-top: -0.5rem; margin-top: -0.5rem;
.create-account-box { .info-message {
background-color: rgba(0, 0, 0, $alpha-hover); background-color: rgba(0, 0, 0, $alpha-hover);
border-radius: 2rem; border-radius: 2rem;
cursor: pointer;
font-size: 80%; font-size: 80%;
a { a {

View File

@ -15,6 +15,24 @@
>(Default)</span >(Default)</span
> >
</td> </td>
<td *matFooterCellDef class="px-1" mat-footer-cell>Total</td>
</ng-container>
<ng-container matColumnDef="currency">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell px-1"
i18n
mat-header-cell
>
Currency
</th>
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
{{ element.currency }}
</td>
<td *matFooterCellDef class="d-none d-lg-table-cell px-1" mat-footer-cell>
{{ baseCurrency }}
</td>
</ng-container> </ng-container>
<ng-container matColumnDef="platform"> <ng-container matColumnDef="platform">
@ -37,6 +55,11 @@
<span>{{ element.Platform?.name }}</span> <span>{{ element.Platform?.name }}</span>
</div> </div>
</td> </td>
<td
*matFooterCellDef
class="d-none d-lg-table-cell px-1"
mat-footer-cell
></td>
</ng-container> </ng-container>
<ng-container matColumnDef="transactions"> <ng-container matColumnDef="transactions">
@ -45,7 +68,12 @@
<span class="d-none d-sm-block" i18n>Transactions</span> <span class="d-none d-sm-block" i18n>Transactions</span>
</th> </th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell> <td *matCellDef="let element" class="px-1 text-right" mat-cell>
{{ element.transactionCount }} <ng-container *ngIf="element.accountType === 'SECURITIES'">{{
element.transactionCount
}}</ng-container>
</td>
<td *matFooterCellDef class="px-1 text-right" mat-footer-cell>
{{ transactionCount }}
</td> </td>
</ng-container> </ng-container>
@ -56,9 +84,39 @@
<td *matCellDef="let element" class="px-1 text-right" mat-cell> <td *matCellDef="let element" class="px-1 text-right" mat-cell>
<gf-value <gf-value
class="d-inline-block justify-content-end" class="d-inline-block justify-content-end"
[currency]="element.currency" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[value]="element.balance" [value]="element.convertedBalance"
></gf-value>
</td>
<td *matFooterCellDef class="px-1 text-right" mat-footer-cell>
<gf-value
class="d-inline-block justify-content-end"
[isCurrency]="true"
[locale]="locale"
[value]="totalBalance"
></gf-value>
</td>
</ng-container>
<ng-container matColumnDef="value">
<th *matHeaderCellDef class="px-1 text-right" i18n mat-header-cell>
Value
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
<gf-value
class="d-inline-block justify-content-end"
[isCurrency]="true"
[locale]="locale"
[value]="element.value"
></gf-value>
</td>
<td *matFooterCellDef class="px-1 text-right" mat-footer-cell>
<gf-value
class="d-inline-block justify-content-end"
[isCurrency]="true"
[locale]="locale"
[value]="totalValue"
></gf-value> ></gf-value>
</td> </td>
</ng-container> </ng-container>
@ -88,10 +146,16 @@
</button> </button>
</mat-menu> </mat-menu>
</td> </td>
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
</ng-container> </ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr> <tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr> <tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
<tr
*matFooterRowDef="displayedColumns"
mat-footer-row
[ngClass]="{ 'd-none': isLoading }"
></tr>
</table> </table>
<ngx-skeleton-loader <ngx-skeleton-loader

View File

@ -10,6 +10,16 @@
} }
.mat-table { .mat-table {
td {
&.mat-footer-cell {
border-top: 1px solid
rgba(
var(--palette-foreground-divider),
var(--palette-foreground-divider-alpha)
);
}
}
th { th {
::ng-deep { ::ng-deep {
.mat-sort-header-container { .mat-sort-header-container {
@ -21,7 +31,14 @@
} }
:host-context(.is-dark-theme) { :host-context(.is-dark-theme) {
.mat-form-field { .mat-table {
color: rgba(var(--light-primary-text)); td {
&.mat-footer-cell {
border-top-color: rgba(
var(--palette-foreground-divider-dark),
var(--palette-foreground-divider-dark-alpha)
);
}
}
} }
} }

View File

@ -24,6 +24,9 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
@Input() deviceType: string; @Input() deviceType: string;
@Input() locale: string; @Input() locale: string;
@Input() showActions: boolean; @Input() showActions: boolean;
@Input() totalBalance: number;
@Input() totalValue: number;
@Input() transactionCount: number;
@Output() accountDeleted = new EventEmitter<string>(); @Output() accountDeleted = new EventEmitter<string>();
@Output() accountToUpdate = new EventEmitter<AccountModel>(); @Output() accountToUpdate = new EventEmitter<AccountModel>();
@ -41,7 +44,14 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
public ngOnInit() {} public ngOnInit() {}
public ngOnChanges() { public ngOnChanges() {
this.displayedColumns = ['account', 'platform', 'transactions', 'balance']; this.displayedColumns = [
'account',
'currency',
'platform',
'transactions',
'balance',
'value'
];
if (this.showActions) { if (this.showActions) {
this.displayedColumns.push('actions'); this.displayedColumns.push('actions');

View File

@ -0,0 +1,31 @@
<div class="py-2">
<div *ngFor="let itemByMonth of marketDataByMonth | keyvalue" class="d-flex">
<div class="date px-1 text-nowrap">{{ itemByMonth.key }}</div>
<div class="align-items-center d-flex flex-grow-1 px-1">
<div
*ngFor="let dayItem of days; let i = index"
class="day"
[ngClass]="{
'cursor-pointer valid': isDateOfInterest(
itemByMonth.key + '-' + (i + 1 < 10 ? '0' + (i + 1) : i + 1)
),
available:
marketDataByMonth[itemByMonth.key][
i + 1 < 10 ? '0' + (i + 1) : i + 1
]?.day ===
i + 1
}"
[title]="
(itemByMonth.key + '-' + (i + 1 < 10 ? '0' + (i + 1) : i + 1)
| date: defaultDateFormat) ?? ''
"
(click)="
onOpenMarketDataDetail({
day: i + 1 < 10 ? '0' + (i + 1) : i + 1,
yearMonth: itemByMonth.key
})
"
></div>
</div>
</div>
</div>

View File

@ -0,0 +1,25 @@
@import '~apps/client/src/styles/ghostfolio-style';
:host {
display: block;
font-size: 0.9rem;
.date {
font-feature-settings: 'tnum';
font-variant-numeric: tabular-nums;
}
.day {
height: 0.5rem;
margin-right: 0.25rem;
width: 0.5rem;
&.valid {
background-color: var(--danger);
}
&.available {
background-color: var(--success);
}
}
}

View File

@ -0,0 +1,103 @@
import {
ChangeDetectionStrategy,
Component,
Input,
OnChanges,
OnInit
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { DataSource, MarketData } from '@prisma/client';
import { format, isBefore, isValid, parse } from 'date-fns';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, takeUntil } from 'rxjs';
import { MarketDataDetailDialog } from './market-data-detail-dialog/market-data-detail-dialog.component';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-admin-market-data-detail',
styleUrls: ['./admin-market-data-detail.component.scss'],
templateUrl: './admin-market-data-detail.component.html'
})
export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
@Input() dataSource: DataSource;
@Input() marketData: MarketData[];
@Input() symbol: string;
public days = Array(31);
public defaultDateFormat = DEFAULT_DATE_FORMAT;
public deviceType: string;
public marketDataByMonth: {
[yearMonth: string]: { [day: string]: MarketData & { day: number } };
} = {};
private unsubscribeSubject = new Subject<void>();
public constructor(
private deviceService: DeviceDetectorService,
private dialog: MatDialog
) {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
}
public ngOnInit() {}
public ngOnChanges() {
this.marketDataByMonth = {};
for (const marketDataItem of this.marketData) {
const currentDay = parseInt(format(marketDataItem.date, 'd'), 10);
const key = format(marketDataItem.date, 'yyyy-MM');
if (!this.marketDataByMonth[key]) {
this.marketDataByMonth[key] = {};
}
this.marketDataByMonth[key][
currentDay < 10 ? `0${currentDay}` : currentDay
] = {
...marketDataItem,
day: currentDay
};
}
}
public isDateOfInterest(aDateString: string) {
// Date is valid and in the past
const date = parse(aDateString, DATE_FORMAT, new Date());
return isValid(date) && isBefore(date, new Date());
}
public onOpenMarketDataDetail({
day,
yearMonth
}: {
day: string;
yearMonth: string;
}) {
const marketPrice = this.marketDataByMonth[yearMonth]?.[day]?.marketPrice;
const dialogRef = this.dialog.open(MarketDataDetailDialog, {
data: {
marketPrice,
dataSource: this.dataSource,
date: new Date(`${yearMonth}-${day}`),
symbol: this.symbol
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -0,0 +1,14 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { AdminMarketDataDetailComponent } from './admin-market-data-detail.component';
import { GfMarketDataDetailDialogModule } from './market-data-detail-dialog/market-data-detail-dialog.module';
@NgModule({
declarations: [AdminMarketDataDetailComponent],
exports: [AdminMarketDataDetailComponent],
imports: [CommonModule, GfMarketDataDetailDialogModule],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfAdminMarketDataDetailModule {}

View File

@ -0,0 +1,8 @@
import { DataSource } from '@prisma/client';
export interface MarketDataDetailDialogParams {
dataSource: DataSource;
date: Date;
marketPrice: number;
symbol: string;
}

View File

@ -0,0 +1,57 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Inject,
OnDestroy
} from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { MarketData } from '@prisma/client';
import { Subject, takeUntil } from 'rxjs';
import { MarketDataDetailDialogParams } from './interfaces/interfaces';
@Component({
host: { class: 'h-100' },
selector: 'gf-market-data-detail-dialog',
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrls: ['./market-data-detail-dialog.scss'],
templateUrl: 'market-data-detail-dialog.html'
})
export class MarketDataDetailDialog implements OnDestroy {
private unsubscribeSubject = new Subject<void>();
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
public dialogRef: MatDialogRef<MarketDataDetailDialog>,
@Inject(MAT_DIALOG_DATA) public data: MarketDataDetailDialogParams
) {}
public ngOnInit() {}
public onCancel(): void {
this.dialogRef.close();
}
public onGatherData() {
this.adminService
.gatherSymbol({
dataSource: this.data.dataSource,
date: this.data.date,
symbol: this.data.symbol
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((marketData: MarketData) => {
this.data.marketPrice = marketData.marketPrice;
this.changeDetectorRef.markForCheck();
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -0,0 +1,42 @@
<form class="d-flex flex-column h-100">
<h1 mat-dialog-title i18n>Details for {{ data.symbol }}</h1>
<div class="flex-grow-1" mat-dialog-content>
<div class="mb-3">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Date</mat-label>
<input
disabled
matInput
name="date"
[matDatepicker]="date"
[(ngModel)]="data.date"
/>
<mat-datepicker-toggle matSuffix [for]="date">
<ion-icon
class="text-muted"
matDatepickerToggleIcon
name="calendar-clear-outline"
></ion-icon>
</mat-datepicker-toggle>
<mat-datepicker #date disabled="true"></mat-datepicker>
</mat-form-field>
</div>
<div class="align-items-center d-flex">
<mat-form-field appearance="outline" class="flex-grow-1 mr-2">
<mat-label i18n>Market Price</mat-label>
<input
matInput
name="marketPrice"
readonly
[(ngModel)]="data.marketPrice"
/>
</mat-form-field>
<button color="accent" i18n mat-flat-button (click)="onGatherData()">
Gather Data
</button>
</div>
</div>
<div class="justify-content-end" mat-dialog-actions>
<button i18n mat-button (click)="onCancel()">Cancel</button>
</div>
</form>

View File

@ -0,0 +1,28 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MarketDataDetailDialog } from './market-data-detail-dialog.component';
@NgModule({
declarations: [MarketDataDetailDialog],
exports: [],
imports: [
CommonModule,
FormsModule,
MatButtonModule,
MatDatepickerModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfMarketDataDetailDialogModule {}

View File

@ -0,0 +1,23 @@
:host {
display: block;
.mat-dialog-content {
max-height: unset;
.mat-form-field-appearance-outline {
::ng-deep {
.mat-form-field-suffix {
top: -0.3rem;
}
.mat-form-field-wrapper {
padding-bottom: 0;
}
}
ion-icon {
font-size: 130%;
}
}
}
}

View File

@ -0,0 +1,97 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
OnDestroy,
OnInit
} from '@angular/core';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
import { DataSource, MarketData } from '@prisma/client';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-admin-market-data',
styleUrls: ['./admin-market-data.scss'],
templateUrl: './admin-market-data.html'
})
export class AdminMarketDataComponent implements OnDestroy, OnInit {
public currentSymbol: string;
public defaultDateFormat = DEFAULT_DATE_FORMAT;
public marketData: AdminMarketDataItem[] = [];
public marketDataDetails: MarketData[] = [];
private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService
) {}
/**
* Initializes the controller
*/
public ngOnInit() {
this.fetchAdminMarketData();
}
public onGatherSymbol({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
this.adminService
.gatherSymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {});
}
public setCurrentSymbol(aSymbol: string) {
this.marketDataDetails = [];
if (this.currentSymbol === aSymbol) {
this.currentSymbol = '';
} else {
this.currentSymbol = aSymbol;
this.fetchAdminMarketDataBySymbol(this.currentSymbol);
}
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private fetchAdminMarketData() {
this.dataService
.fetchAdminMarketData()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ marketData }) => {
this.marketData = marketData;
this.changeDetectorRef.markForCheck();
});
}
private fetchAdminMarketDataBySymbol(aSymbol: string) {
this.dataService
.fetchAdminMarketDataBySymbol(aSymbol)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ marketData }) => {
this.marketDataDetails = marketData;
this.changeDetectorRef.markForCheck();
});
}
}

View File

@ -0,0 +1,61 @@
<div class="container">
<div class="row">
<div class="col">
<table class="gf-table w-100">
<thead>
<tr class="mat-header-row">
<th class="mat-header-cell px-1 py-2 text-right" i18n>#</th>
<th class="mat-header-cell px-1 py-2" i18n>Symbol</th>
<th class="mat-header-cell px-1 py-2" i18n>Data Source</th>
<th class="mat-header-cell px-1 py-2" i18n>First Transaction</th>
<th class="mat-header-cell px-1 py-2"></th>
</tr>
</thead>
<tbody>
<ng-container *ngFor="let item of marketData; let i = index">
<tr
class="cursor-pointer mat-row"
(click)="setCurrentSymbol(item.symbol)"
>
<td class="mat-cell px-1 py-2 text-right">{{ i + 1 }}</td>
<td class="mat-cell px-1 py-2">{{ item.symbol }}</td>
<td class="mat-cell px-1 py-2">{{ item.dataSource}}</td>
<td class="mat-cell px-1 py-2">
{{ (item.date | date: defaultDateFormat) ?? '' }}
</td>
<td class="mat-cell px-1 py-2">
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="accountMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-vertical"></ion-icon>
</button>
<mat-menu #accountMenu="matMenu" xPosition="before">
<button
i18n
mat-menu-item
(click)="onGatherSymbol({dataSource: item.dataSource, symbol: item.symbol})"
>
Gather Data
</button>
</mat-menu>
</td>
</tr>
<tr *ngIf="currentSymbol === item.symbol" class="mat-row">
<td></td>
<td colspan="4">
<gf-admin-market-data-detail
[dataSource]="item.dataSource"
[marketData]="marketDataDetails"
[symbol]="item.symbol"
></gf-admin-market-data-detail>
</td>
</tr>
</ng-container>
</tbody>
</table>
</div>
</div>
</div>

View File

@ -0,0 +1,19 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { GfAdminMarketDataDetailModule } from '@ghostfolio/client/components/admin-market-data-detail/admin-market-data-detail.module';
import { AdminMarketDataComponent } from './admin-market-data.component';
@NgModule({
declarations: [AdminMarketDataComponent],
imports: [
CommonModule,
GfAdminMarketDataDetailModule,
MatButtonModule,
MatMenuModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfAdminMarketDataModule {}

View File

@ -0,0 +1,5 @@
@import '~apps/client/src/styles/ghostfolio-style';
:host {
display: block;
}

View File

@ -0,0 +1,302 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatSlideToggleChange } from '@angular/material/slide-toggle';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { CacheService } from '@ghostfolio/client/services/cache.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
DEFAULT_DATE_FORMAT,
PROPERTY_COUPONS,
PROPERTY_CURRENCIES,
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_SYSTEM_MESSAGE
} from '@ghostfolio/common/config';
import { Coupon, InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import {
differenceInSeconds,
formatDistanceToNowStrict,
isValid,
parseISO
} from 'date-fns';
import { uniq } from 'lodash';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
selector: 'gf-admin-overview',
styleUrls: ['./admin-overview.scss'],
templateUrl: './admin-overview.html'
})
export class AdminOverviewComponent implements OnDestroy, OnInit {
public coupons: Coupon[];
public customCurrencies: string[];
public dataGatheringInProgress: boolean;
public dataGatheringProgress: number;
public defaultDateFormat = DEFAULT_DATE_FORMAT;
public exchangeRates: { label1: string; label2: string; value: number }[];
public hasPermissionForSubscription: boolean;
public hasPermissionForSystemMessage: boolean;
public hasPermissionToToggleReadOnlyMode: boolean;
public info: InfoItem;
public lastDataGathering: string;
public transactionCount: number;
public userCount: number;
public user: User;
private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor(
private adminService: AdminService,
private cacheService: CacheService,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private userService: UserService
) {
this.info = this.dataService.fetchInfo();
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.hasPermissionForSubscription = hasPermission(
this.info.globalPermissions,
permissions.enableSubscription
);
this.hasPermissionForSystemMessage = hasPermission(
this.info.globalPermissions,
permissions.enableSystemMessage
);
this.hasPermissionToToggleReadOnlyMode = hasPermission(
this.user.permissions,
permissions.toggleReadOnlyMode
);
}
});
}
/**
* Initializes the controller
*/
public ngOnInit() {
this.fetchAdminData();
}
public formatDistanceToNow(aDateString: string) {
if (aDateString) {
const distanceString = formatDistanceToNowStrict(parseISO(aDateString), {
addSuffix: true
});
return Math.abs(differenceInSeconds(parseISO(aDateString), new Date())) <
60
? 'just now'
: distanceString;
}
return '';
}
public onAddCoupon() {
const coupons = [...this.coupons, { code: this.generateCouponCode(16) }];
this.putCoupons(coupons);
}
public onAddCurrency() {
const currency = prompt('Please add a currency:');
if (currency) {
const currencies = uniq([...this.customCurrencies, currency]);
this.putCurrencies(currencies);
}
}
public onDeleteCoupon(aCouponCode: string) {
const confirmation = confirm('Do you really want to delete this coupon?');
if (confirmation) {
const coupons = this.coupons.filter((coupon) => {
return coupon.code !== aCouponCode;
});
this.putCoupons(coupons);
}
}
public onDeleteCurrency(aCurrency: string) {
const confirmation = confirm('Do you really want to delete this currency?');
if (confirmation) {
const currencies = this.customCurrencies.filter((currency) => {
return currency !== aCurrency;
});
this.putCurrencies(currencies);
}
}
public onDeleteSystemMessage() {
this.putSystemMessage('');
}
public onFlushCache() {
this.cacheService
.flush()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
}
public onGatherMax() {
const confirmation = confirm(
'This action may take some time. Do you want to proceed?'
);
if (confirmation === true) {
this.adminService
.gatherMax()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
}
}
public onGatherProfileData() {
this.adminService
.gatherProfileData()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {});
}
public onReadOnlyModeChange(aEvent: MatSlideToggleChange) {
this.setReadOnlyMode(aEvent.checked);
}
public onSetSystemMessage() {
const systemMessage = prompt('Please set your system message:');
if (systemMessage) {
this.putSystemMessage(systemMessage);
}
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private fetchAdminData() {
this.dataService
.fetchAdminData()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(
({
dataGatheringProgress,
exchangeRates,
lastDataGathering,
settings,
transactionCount,
userCount
}) => {
this.coupons = (settings[PROPERTY_COUPONS] as Coupon[]) ?? [];
this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[];
this.dataGatheringProgress = dataGatheringProgress;
this.exchangeRates = exchangeRates;
if (isValid(parseISO(lastDataGathering?.toString()))) {
this.lastDataGathering = formatDistanceToNowStrict(
new Date(lastDataGathering),
{
addSuffix: true
}
);
} else if (lastDataGathering === 'IN_PROGRESS') {
this.dataGatheringInProgress = true;
} else {
this.lastDataGathering = 'Starting soon...';
}
this.transactionCount = transactionCount;
this.userCount = userCount;
this.changeDetectorRef.markForCheck();
}
);
}
private generateCouponCode(aLength: number) {
const characters = 'ABCDEFGHJKLMNPQRSTUVWXYZ123456789';
let couponCode = '';
for (let i = 0; i < aLength; i++) {
couponCode += characters.charAt(
Math.floor(Math.random() * characters.length)
);
}
return couponCode;
}
private putCoupons(aCoupons: Coupon[]) {
this.dataService
.putAdminSetting(PROPERTY_COUPONS, {
value: JSON.stringify(aCoupons)
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
}
private putCurrencies(aCurrencies: string[]) {
this.dataService
.putAdminSetting(PROPERTY_CURRENCIES, {
value: JSON.stringify(aCurrencies)
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
}
private putSystemMessage(aSystemMessage: string) {
this.dataService
.putAdminSetting(PROPERTY_SYSTEM_MESSAGE, {
value: aSystemMessage
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
}
private setReadOnlyMode(aValue: boolean) {
this.dataService
.putAdminSetting(PROPERTY_IS_READ_ONLY_MODE, {
value: aValue ? 'true' : ''
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
}
}

View File

@ -0,0 +1,184 @@
<div class="container">
<div class="mb-5 row">
<div class="col">
<mat-card class="mb-3">
<mat-card-content>
<div class="d-flex my-3">
<div class="w-50" i18n>User Count</div>
<div class="w-50">{{ userCount }}</div>
</div>
<div class="d-flex my-3">
<div class="w-50" i18n>Transaction Count</div>
<div class="w-50">
<ng-container *ngIf="transactionCount">
{{ transactionCount }} ({{ transactionCount / userCount | number
: '1.2-2' }} <span i18n>per User</span>)
</ng-container>
</div>
</div>
<div class="d-flex my-3">
<div class="w-50" i18n>Data Gathering</div>
<div class="w-50">
<div>
<ng-container *ngIf="lastDataGathering"
>{{ lastDataGathering }}</ng-container
>
<ng-container *ngIf="dataGatheringInProgress" i18n
>In Progress ({{ dataGatheringProgress | percent : '1.2-2'
}})</ng-container
>
</div>
<div class="mt-2 overflow-hidden">
<div class="mb-2">
<button
color="accent"
mat-flat-button
(click)="onFlushCache()"
>
<ion-icon
class="mr-1"
name="close-circle-outline"
></ion-icon>
<span i18n>Reset Data Gathering</span>
</button>
</div>
<div class="mb-2">
<button
color="warn"
mat-flat-button
[disabled]="dataGatheringInProgress"
(click)="onGatherMax()"
>
<ion-icon class="mr-1" name="warning-outline"></ion-icon>
<span i18n>Gather All Data</span>
</button>
</div>
<div>
<button
class="mb-2 mr-2"
color="accent"
mat-flat-button
[disabled]="dataGatheringInProgress"
(click)="onGatherProfileData()"
>
<ion-icon
class="mr-1"
name="cloud-download-outline"
></ion-icon>
<span i18n>Gather Profile Data</span>
</button>
</div>
</div>
</div>
</div>
<div class="align-items-start d-flex my-3">
<div class="w-50" i18n>Exchange Rates</div>
<div class="w-50">
<table>
<tr *ngFor="let exchangeRate of exchangeRates">
<td class="d-flex">
<gf-value
[locale]="user?.settings?.locale"
[value]="1"
></gf-value>
</td>
<td class="pl-1">{{ exchangeRate.label1 }}</td>
<td class="px-1">=</td>
<td class="d-flex justify-content-end">
<gf-value
[locale]="user?.settings?.locale"
[precision]="4"
[value]="exchangeRate.value"
></gf-value>
</td>
<td class="pl-1">{{ exchangeRate.label2 }}</td>
<td>
<button
*ngIf="customCurrencies.includes(exchangeRate.label2)"
class="mini-icon mx-1 no-min-width px-2"
mat-button
[disabled]="dataGatheringInProgress"
(click)="onDeleteCurrency(exchangeRate.label2)"
>
<ion-icon name="trash-outline"></ion-icon>
</button>
</td>
</tr>
</table>
<div class="mt-2">
<button
color="primary"
mat-flat-button
[disabled]="dataGatheringInProgress"
(click)="onAddCurrency()"
>
<ion-icon class="mr-1" name="add-outline"></ion-icon>
<span i18n>Add Currency</span>
</button>
</div>
</div>
</div>
<div *ngIf="hasPermissionForSystemMessage" class="d-flex my-3">
<div class="w-50" i18n>System Message</div>
<div class="w-50">
<div *ngIf="info.systemMessage">
<span>{{ info.systemMessage }}</span>
<button
class="mini-icon mx-1 no-min-width px-2"
mat-button
[disabled]="dataGatheringInProgress"
(click)="onDeleteSystemMessage()"
>
<ion-icon name="trash-outline"></ion-icon>
</button>
</div>
<button
*ngIf="!info.systemMessage"
color="accent"
mat-flat-button
(click)="onSetSystemMessage()"
>
<ion-icon
class="mr-1"
name="information-circle-outline"
></ion-icon>
<span i18n>Set Message</span>
</button>
</div>
</div>
<div *ngIf="hasPermissionToToggleReadOnlyMode" class="d-flex my-3">
<div class="w-50" i18n>Read-only Mode</div>
<div class="w-50">
<mat-slide-toggle
color="primary"
[checked]="info?.isReadOnlyMode"
(change)="onReadOnlyModeChange($event)"
></mat-slide-toggle>
</div>
</div>
<div *ngIf="hasPermissionForSubscription" class="d-flex my-3">
<div class="w-50" i18n>Coupons</div>
<div class="w-50">
<div *ngFor="let coupon of coupons">
<span>{{ coupon.code }}</span>
<button
class="mini-icon mx-1 no-min-width px-2"
mat-button
(click)="onDeleteCoupon(coupon.code)"
>
<ion-icon name="trash-outline"></ion-icon>
</button>
</div>
<div class="mt-2">
<button color="primary" mat-flat-button (click)="onAddCoupon()">
<ion-icon class="mr-1" name="add-outline"></ion-icon>
<span i18n>Add Coupon</span>
</button>
</div>
</div>
</div>
</mat-card-content>
</mat-card>
</div>
</div>
</div>

View File

@ -0,0 +1,24 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { CacheService } from '@ghostfolio/client/services/cache.service';
import { GfValueModule } from '@ghostfolio/ui/value';
import { AdminOverviewComponent } from './admin-overview.component';
@NgModule({
declarations: [AdminOverviewComponent],
exports: [],
imports: [
CommonModule,
GfValueModule,
MatButtonModule,
MatCardModule,
MatSlideToggleModule
],
providers: [CacheService],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfAdminOverviewModule {}

View File

@ -0,0 +1,23 @@
@import '~apps/client/src/styles/ghostfolio-style';
:host {
display: block;
.mat-button {
&.mini-icon {
line-height: 1.5;
}
}
.mat-flat-button {
::ng-deep {
.mat-button-wrapper {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
}
}
}
}

View File

@ -0,0 +1,82 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { AdminData } from '@ghostfolio/common/interfaces';
import {
differenceInSeconds,
formatDistanceToNowStrict,
parseISO
} from 'date-fns';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
selector: 'gf-admin-users',
styleUrls: ['./admin-users.scss'],
templateUrl: './admin-users.html'
})
export class AdminUsersComponent implements OnDestroy, OnInit {
public users: AdminData['users'];
private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService
) {}
/**
* Initializes the controller
*/
public ngOnInit() {
this.fetchAdminData();
}
public formatDistanceToNow(aDateString: string) {
if (aDateString) {
const distanceString = formatDistanceToNowStrict(parseISO(aDateString), {
addSuffix: true
});
return Math.abs(differenceInSeconds(parseISO(aDateString), new Date())) <
60
? 'just now'
: distanceString;
}
return '';
}
public onDeleteUser(aId: string) {
const confirmation = confirm('Do you really want to delete this user?');
if (confirmation) {
this.dataService
.deleteUser(aId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.fetchAdminData();
}
});
}
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private fetchAdminData() {
this.dataService
.fetchAdminData()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ users }) => {
this.users = users;
this.changeDetectorRef.markForCheck();
});
}
}

View File

@ -0,0 +1,86 @@
<div class="container">
<div class="row">
<div class="col">
<div class="users">
<table class="gf-table">
<thead>
<tr class="mat-header-row">
<th class="mat-header-cell px-1 py-2 text-right" i18n>#</th>
<th class="mat-header-cell px-1 py-2" i18n>User</th>
<th class="mat-header-cell px-1 py-2 text-right" i18n>
Registration
</th>
<th class="mat-header-cell px-1 py-2 text-right" i18n>
Accounts
</th>
<th class="mat-header-cell px-1 py-2 text-right" i18n>
Transactions
</th>
<th class="mat-header-cell px-1 py-2 text-right" i18n>
Engagement per Day
</th>
<th class="mat-header-cell px-1 py-2" i18n>Last Activitiy</th>
<th class="mat-header-cell px-1 py-2"></th>
</tr>
</thead>
<tbody>
<tr *ngFor="let userItem of users; let i = index" class="mat-row">
<td class="mat-cell px-1 py-2 text-right">{{ i + 1 }}</td>
<td class="mat-cell px-1 py-2">
<div class="d-flex align-items-center">
<span class="d-none d-sm-inline-block"
>{{ userItem.alias || userItem.id }}</span
>
<span class="d-inline-block d-sm-none"
>{{ userItem.alias || (userItem.id | slice:0:5) +
'...' }}</span
>
<ion-icon
*ngIf="userItem?.subscription?.type === 'Premium'"
class="ml-1 text-muted"
name="diamond-outline"
></ion-icon>
</div>
</td>
<td class="mat-cell px-1 py-2 text-right">
{{ formatDistanceToNow(userItem.createdAt) }}
</td>
<td class="mat-cell px-1 py-2 text-right">
{{ userItem.accountCount }}
</td>
<td class="mat-cell px-1 py-2 text-right">
{{ userItem.transactionCount }}
</td>
<td class="mat-cell px-1 py-2 text-right">
{{ userItem.engagement | number: '1.0-0' }}
</td>
<td class="mat-cell px-1 py-2">
{{ formatDistanceToNow(userItem.lastActivity) }}
</td>
<td class="mat-cell px-1 py-2">
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="accountMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-vertical"></ion-icon>
</button>
<mat-menu #accountMenu="matMenu" xPosition="before">
<button
i18n
mat-menu-item
[disabled]="userItem.id === user?.id"
(click)="onDeleteUser(userItem.id)"
>
Delete
</button>
</mat-menu>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,14 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { AdminUsersComponent } from './admin-users.component';
@NgModule({
declarations: [AdminUsersComponent],
exports: [],
imports: [CommonModule, MatButtonModule, MatMenuModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfAdminUsersModule {}

View File

@ -0,0 +1,18 @@
@import '~apps/client/src/styles/ghostfolio-style';
:host {
display: block;
.users {
overflow-x: auto;
table {
min-width: 100%;
.mat-row,
.mat-header-row {
width: 100%;
}
}
}
}

View File

@ -1,4 +1,8 @@
<span class="flex-grow-1 text-truncate">{{ title }}</span> <span
class="flex-grow-1 text-truncate"
[ngClass]="{ 'text-center': position === 'center' }"
>{{ title }}</span
>
<button <button
*ngIf="deviceType !== 'mobile'" *ngIf="deviceType !== 'mobile'"
class="no-min-width px-0" class="no-min-width px-0"

View File

@ -16,6 +16,7 @@ import {
}) })
export class DialogHeaderComponent implements OnInit { export class DialogHeaderComponent implements OnInit {
@Input() deviceType: string; @Input() deviceType: string;
@Input() position: 'center' | 'left' = 'left';
@Input() title: string; @Input() title: string;
@Output() closeButtonClicked = new EventEmitter<void>(); @Output() closeButtonClicked = new EventEmitter<void>();

View File

@ -1,13 +1,13 @@
<div class="align-items-center d-flex flex-row"> <div class="align-items-center d-flex flex-row">
<div class="h3 mb-0 mr-2">{{ fearAndGreedIndexEmoji }}</div> <div class="h2 mb-0 mr-2">{{ fearAndGreedIndexEmoji }}</div>
<div> <div>
<div class="h3 mb-0"> <div class="h4 mb-0">
<span class="mr-2">{{ fearAndGreedIndexText }}</span> <span class="mr-2">{{ fearAndGreedIndexText }}</span>
<small class="text-muted" <small class="text-muted"
><strong>{{ fearAndGreedIndex }}</strong ><strong>{{ fearAndGreedIndex }}</strong
>/100</small >/100</small
> >
</div> </div>
<small class="d-block" i18n>Market Mood</small> <small class="d-block" i18n>Current Market Mood</small>
</div> </div>
</div> </div>

View File

@ -2,7 +2,7 @@
<ng-container *ngIf="user"> <ng-container *ngIf="user">
<a <a
[routerLink]="['/']" [routerLink]="['/']"
class="align-items-center d-flex h-100 mx-2 no-min-width px-2 rounded-0" class="align-items-center d-flex h-100 no-min-width px-2 rounded-0"
mat-button mat-button
> >
<gf-logo></gf-logo> <gf-logo></gf-logo>
@ -270,6 +270,7 @@
Sign In Sign In
</button> </button>
<a <a
*ngIf="currentRoute !== 'register' && !info?.isReadOnlyMode"
class="d-none d-sm-block" class="d-none d-sm-block"
color="primary" color="primary"
i18n i18n

View File

@ -0,0 +1,84 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import {
RANGE,
SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { Position, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
selector: 'gf-home-holdings',
styleUrls: ['./home-holdings.scss'],
templateUrl: './home-holdings.html'
})
export class HomeHoldingsComponent implements OnDestroy, OnInit {
public dateRange: DateRange;
public deviceType: string;
public hasPermissionToCreateOrder: boolean;
public positions: Position[];
public user: User;
private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private settingsStorageService: SettingsStorageService,
private userService: UserService
) {
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.hasPermissionToCreateOrder = hasPermission(
this.user.permissions,
permissions.createOrder
);
this.changeDetectorRef.markForCheck();
}
});
}
/**
* Initializes the controller
*/
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.dateRange =
<DateRange>this.settingsStorageService.getSetting(RANGE) || 'max';
this.update();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private update() {
this.dataService
.fetchPositions({ range: this.dateRange })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => {
this.positions = response.positions;
this.changeDetectorRef.markForCheck();
});
this.changeDetectorRef.markForCheck();
}
}

View File

@ -0,0 +1,26 @@
<div class="container justify-content-center pb-3 px-3">
<div class="row">
<div class="align-items-center col-xs-12 col-md-8 offset-md-2">
<mat-card class="p-0">
<mat-card-content>
<gf-positions
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[locale]="user?.settings?.locale"
[positions]="positions"
[range]="dateRange"
></gf-positions>
</mat-card-content>
</mat-card>
<div *ngIf="hasPermissionToCreateOrder" class="text-center">
<a
class="mt-3"
i18n
mat-button
[routerLink]="['/portfolio', 'transactions']"
>Manage Transactions...</a
>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,23 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { RouterModule } from '@angular/router';
import { GfPositionsModule } from '@ghostfolio/client/components/positions/positions.module';
import { HomeHoldingsComponent } from './home-holdings.component';
@NgModule({
declarations: [HomeHoldingsComponent],
exports: [],
imports: [
CommonModule,
GfPositionsModule,
MatButtonModule,
MatCardModule,
RouterModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfHomeHoldingsModule {}

View File

@ -0,0 +1,5 @@
@import '~apps/client/src/styles/ghostfolio-style';
:host {
display: block;
}

View File

@ -0,0 +1,85 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
import { resetHours } from '@ghostfolio/common/helper';
import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DataSource } from '@prisma/client';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
selector: 'gf-home-market',
styleUrls: ['./home-market.scss'],
templateUrl: './home-market.html'
})
export class HomeMarketComponent implements OnDestroy, OnInit {
public fearAndGreedIndex: number;
public hasPermissionToAccessFearAndGreedIndex: boolean;
public historicalData: HistoricalDataItem[];
public isLoading = true;
public user: User;
private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private userService: UserService
) {
this.isLoading = true;
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
this.user.permissions,
permissions.accessFearAndGreedIndex
);
if (this.hasPermissionToAccessFearAndGreedIndex) {
this.dataService
.fetchSymbolItem({
dataSource: DataSource.RAKUTEN,
includeHistoricalData: true,
symbol: ghostfolioFearAndGreedIndexSymbol
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ historicalData, marketPrice }) => {
this.fearAndGreedIndex = marketPrice;
this.historicalData = [
...historicalData,
{
date: resetHours(new Date()).toISOString(),
value: marketPrice
}
];
this.isLoading = false;
this.changeDetectorRef.markForCheck();
});
}
this.changeDetectorRef.markForCheck();
}
});
}
/**
* Initializes the controller
*/
public ngOnInit() {}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -0,0 +1,34 @@
<div
class="
align-items-center
container
d-flex
flex-grow-1
h-100
justify-content-center
w-100
"
>
<div class="no-gutters row w-100">
<div class="col-xs-12 col-md-8 offset-md-2">
<div class="mb-2 text-center text-muted">
<small i18n>Last 10 Days</small>
</div>
<gf-line-chart
class="mb-5"
yMax="100"
yMaxLabel="Greed"
yMin="0"
yMinLabel="Fear"
[historicalDataItems]="historicalData"
[showXAxis]="true"
[showYAxis]="true"
></gf-line-chart>
<gf-fear-and-greed-index
class="d-flex justify-content-center"
[fearAndGreedIndex]="fearAndGreedIndex"
[hidden]="isLoading"
></gf-fear-and-greed-index>
</div>
</div>
</div>

View File

@ -0,0 +1,15 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { GfFearAndGreedIndexModule } from '@ghostfolio/client/components/fear-and-greed-index/fear-and-greed-index.module';
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
import { HomeMarketComponent } from './home-market.component';
@NgModule({
declarations: [HomeMarketComponent],
exports: [],
imports: [CommonModule, GfFearAndGreedIndexModule, GfLineChartModule],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfHomeMarketModule {}

View File

@ -0,0 +1,9 @@
@import '~apps/client/src/styles/ghostfolio-style';
:host {
display: block;
gf-line-chart {
aspect-ratio: 16 / 9;
}
}

View File

@ -0,0 +1,127 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { ToggleOption } from '@ghostfolio/client/components/toggle/interfaces/toggle-option.type';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import {
RANGE,
SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { PortfolioPerformance, User } from '@ghostfolio/common/interfaces';
import { DateRange } from '@ghostfolio/common/types';
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
selector: 'gf-home-overview',
styleUrls: ['./home-overview.scss'],
templateUrl: './home-overview.html'
})
export class HomeOverviewComponent implements OnDestroy, OnInit {
public dateRange: DateRange;
public dateRangeOptions: ToggleOption[] = [
{ label: 'Today', value: '1d' },
{ label: 'YTD', value: 'ytd' },
{ label: '1Y', value: '1y' },
{ label: '5Y', value: '5y' },
{ label: 'Max', value: 'max' }
];
public deviceType: string;
public hasImpersonationId: boolean;
public historicalDataItems: LineChartItem[];
public isAllTimeHigh: boolean;
public isAllTimeLow: boolean;
public isLoadingPerformance = true;
public performance: PortfolioPerformance;
public user: User;
private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private impersonationStorageService: ImpersonationStorageService,
private settingsStorageService: SettingsStorageService,
private userService: UserService
) {
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.changeDetectorRef.markForCheck();
}
});
}
/**
* Initializes the controller
*/
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((aId) => {
this.hasImpersonationId = !!aId;
this.changeDetectorRef.markForCheck();
});
this.dateRange =
<DateRange>this.settingsStorageService.getSetting(RANGE) || 'max';
this.update();
}
public onChangeDateRange(aDateRange: DateRange) {
this.dateRange = aDateRange;
this.settingsStorageService.setSetting(RANGE, this.dateRange);
this.update();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private update() {
this.isLoadingPerformance = true;
this.dataService
.fetchChart({ range: this.dateRange })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((chartData) => {
this.historicalDataItems = chartData.chart.map((chartDataItem) => {
return {
date: chartDataItem.date,
value: chartDataItem.value
};
});
this.isAllTimeHigh = chartData.isAllTimeHigh;
this.isAllTimeLow = chartData.isAllTimeLow;
this.changeDetectorRef.markForCheck();
});
this.dataService
.fetchPortfolioPerformance({ range: this.dateRange })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => {
this.performance = response;
this.isLoadingPerformance = false;
this.changeDetectorRef.markForCheck();
});
this.changeDetectorRef.markForCheck();
}
}

Some files were not shown because too many files have changed in this diff Show More