Compare commits

..

104 Commits

Author SHA1 Message Date
207859cc22 Release 1.175.0 (#1107) 2022-07-29 18:34:05 +02:00
77181aaaff Feature/setup faq page (#1106)
* Set up FAQ page

* Update changelog
2022-07-29 18:32:26 +02:00
412039badf Bugfix/show symbols of activities in account detail dialog (#1105)
* Show symbols

* Update changelog
2022-07-29 17:04:30 +02:00
7619442895 Feature/add savings rate to investment timeline (#1104)
* Add line for savings rate

* Update changelog
2022-07-29 17:03:23 +02:00
61ecd66e0f Release 1.174.0 (#1098) 2022-07-27 21:09:03 +02:00
81217b35ef Feature/add comment to activity (#1097)
* Add comment to activity

* Update changelog
2022-07-27 21:07:27 +02:00
678f1f0051 Release 1.173.0 (#1095) 2022-07-23 20:38:53 +02:00
71c7e37b5a Bugfix/fix currency inconsistency with usx (#1094)
* Support USX

* Update changelog
2022-07-23 20:37:26 +02:00
80459371f3 Release 1.172.0 (#1093) 2022-07-23 12:09:38 +02:00
35f1f348a8 Feature/add blog post ghostfolio meets internet identity (#1092)
* Add blog post: Ghostfolio meets Internet Identity

* Update changelog
2022-07-23 12:06:49 +02:00
0bb0b12991 Release 1.171.0 (#1091) 2022-07-22 19:57:04 +02:00
d887de50d2 Feature/setup internet identity (#1080)
* Setup Internet Identity as social login provider

* Update changelog
2022-07-22 19:55:33 +02:00
2571e5b8c0 Feature/improve empty states (#1090)
* Improve empty states

* Update changelog
2022-07-22 19:33:06 +02:00
e444d717e5 Add tests for investments by month (#1089)
* Fix investments by month

* Add tests for investments and investments by month

* Update changelog
2022-07-22 19:00:36 +02:00
1866e26c1d Fix distorted tooltip (#1088)
* Fix distorted tooltip

* Update changelog
2022-07-21 20:36:16 +02:00
9923074e04 Add script to open prisma studio with prod env variables (#1087) 2022-07-20 09:16:02 +02:00
c367e61b85 Release 1.170.0 (#1086) 2022-07-19 20:30:59 +02:00
364f1ad9b9 Feature/add support for ust usd (#1085)
* Add UST

* Update changelog
2022-07-19 20:29:42 +02:00
2394cbd6fe Feature/support tags in create and update order dto (#1084)
* Support tags in create or update order

* Update changelog
2022-07-19 20:24:01 +02:00
a74d5cce20 Feature/remove activities import limit for premium users (#1082)
* Remove activities import limit for premium users

* Update changelog
2022-07-17 20:44:28 +02:00
95bcc3f32d Feature/remove alias from user interface (#1083)
* Remove alias from user interface

* Update changelog
2022-07-17 11:05:23 +02:00
e9dbd4a55d Add blog post to resources (#1081) 2022-07-17 10:09:11 +02:00
d440b09dc9 Improve quotes (#1078) 2022-07-15 09:04:44 +02:00
cc16ba5dc8 Release 1.169.0 (#1077) 2022-07-14 16:31:03 +02:00
d10227bc39 Feature/add support for luna2 and songbird cryptocurrencies (#1075)
* Add LUNA2 and SGB1

* Update changelog
2022-07-14 16:28:50 +02:00
4e214c32e8 Feature/update cryptocurrencies.json 20220714 (#1074)
* Update cryptocurrencies.json

* Update changelog
2022-07-14 16:27:32 +02:00
49e2862e03 Feature/add blog post about personal finances (#1073)
* Add blog post

* Update changelog
2022-07-14 16:16:07 +02:00
34e33a2400 Feature/upgrade date fns to version 2.28.0 (#1070)
* Upgrade date-fns

* Update changelog
2022-07-11 19:39:03 +02:00
ec9bc984af Release 1.168.0 (#1071) 2022-07-10 22:25:58 +02:00
2388c494df Feature/handle currency pair inconsistency in yahoo finance service (#1069)
* Handle occasional currency pair inconsistency: GBP=X instead of USDGBP=X

* Update changelog
2022-07-10 22:24:27 +02:00
d71ab10eed Bugfix/fix content height of account detail dialog (#1068)
* Fix height

* Update changelog
2022-07-10 21:44:23 +02:00
0e0592180f Add current month (#1067) 2022-07-10 09:41:48 +02:00
60e2aff488 Extend investment timeline by month (#1066)
* Extend investment timeline grouped by month

* Update changelog
2022-07-09 21:18:05 +02:00
7b5454e7de Release 1.167.0 (#1065) 2022-07-07 21:08:32 +02:00
30835ced88 Bugfix/fix holdings for basic users (#1064)
* Fix holdings for basic users

* Update changelog
2022-07-07 21:06:12 +02:00
8897f32bc5 Clean up modules (#1063) 2022-07-07 07:04:23 +02:00
abaa6b5f27 Feature/improve create account link in live demo (#1061)
* Improve router link

* Update changelog
2022-07-06 17:49:19 +02:00
2060fcaf0b Feature/add markets to public pages (#1062)
* Add Markets to public pages

* Update changelog
2022-07-05 21:45:27 +02:00
fd2408dd62 fix: add git when building docker image (#1052) 2022-07-03 19:57:04 +02:00
31cca024f1 Feature/upgrade ngx markdown to version 14.0.1 (#1055)
* Upgrade ngx-markdown

* Update changelog
2022-07-02 10:58:46 +02:00
b535122945 Make use of demo route (#1060) 2022-07-01 20:07:49 +02:00
5113e4e3ad Release 1.166.0 (#1059) 2022-06-30 21:09:58 +02:00
35e039748f Feature/refactor demo account as route (#1058)
* Refactor demo account as route

* Update changelog
2022-06-30 21:07:35 +02:00
c6b9e0aa5b Feature/upgrade zone.js to version 0.11.6 (#1054)
* Upgrade zone.js

* Update changelog
2022-06-30 19:38:33 +02:00
b250491ca5 Feature/upgrade yahoo finance2 to version 2.3.3 (#1053)
* Upgrade yahoo-finance2 to version 2.3.3

* Update changelog
2022-06-30 08:04:39 +02:00
61e501c659 Feature/fix version of @angular/cli (#1056)
* Fix version

* Update yarn.lock
2022-06-29 21:07:55 +02:00
c0f19d56ec Feature/add account detail dialog (#1047)
* Add account detail dialog

* Update changelog
2022-06-28 21:08:34 +02:00
8e2b235b1f Feature/improve search label (#1048)
* Improve search label

* Update changelog
2022-06-28 13:33:59 +02:00
c3407e9b34 Feature/upgrade prisma to version 3.15.2 (#1046)
* Upgrade prisma to version 3.15.2

* Update changelog
2022-06-25 19:04:01 +02:00
74193e4ee2 Feature/upgrade nestjs dependencies to version 8.4.7 (#1045)
* Upgrade nestjs dependencies to version 8.4.7

* Update changelog
2022-06-25 19:03:25 +02:00
3fe8f9c882 Release 1.165.0 (#1044) 2022-06-25 17:34:06 +02:00
d130efad47 Clean up comments (#1043)
* Clean up comments
2022-06-25 17:30:43 +02:00
109f0ebd70 Feature/move positions table to holdings section (#1042)
* Move positions table to holdings section

* Update changelog
2022-06-25 14:47:20 +02:00
069ddcc6b2 Feature/add reusable premium indicator component (#1041)
* Add premium indicator component

* Update changelog
2022-06-25 12:38:15 +02:00
f7bf6e652b Feature/add icon and name to positions table (#1040)
* Add icon and name

* Update changelog
2022-06-25 11:33:59 +02:00
eb059a024a Feature/delete data in data gathering by symbol (#1039)
* Delete market data

* Update changelog
2022-06-25 11:15:01 +02:00
ad88acff1c Release 1.164.0 (#1038) 2022-06-23 19:25:46 +02:00
1ff736537c Feature/add positions table to public page (#1037)
* Add positions table

* Update changelog
2022-06-23 19:24:24 +02:00
1fa65e1efd Release 1.163.0 (#1036) 2022-06-22 20:32:06 +02:00
df6bb489c2 Feature/improve onboarding for ios (#1035)
* Improve onboarding for iOS

* Update changelog
2022-06-22 20:30:03 +02:00
928a13310d Improve title (#1031) 2022-06-21 17:58:13 +02:00
2384861953 Remove experimental (#1032) 2022-06-20 20:32:51 +02:00
fe90bda6fb Release 1.162.0 (#1029) 2022-06-18 17:48:54 +02:00
d4b29ff11c Feature/add privacy policy page (#1028)
* Add privacy policy page

* Update changelog
2022-06-18 17:46:51 +02:00
a0a26cfa58 Feature/simplify header (#1027)
* Hide pricing page link for Premium users

* Harmonize content

* Update changelog
2022-06-18 11:57:27 +02:00
1610150427 Bugfix/fix currency conversion of ila to ils (#1026)
* Fix currency conversion: ILA to ILS

* Update changelog
2022-06-17 20:34:41 +02:00
cff8acd7b1 Clean up (#1022) 2022-06-17 20:04:52 +02:00
0f36d6cbdb Release 1.161.1 (#1025) 2022-06-16 17:20:44 +02:00
046e28b521 Release 1.161.0 (#1024) 2022-06-16 16:51:57 +02:00
aba562cb35 Bugfix/fix error handling for missing market prices (#1023)
* Add fallback for missing market price

* Update changelog
2022-06-16 16:49:29 +02:00
03f2f33344 Feature/restructure landing page (#1021)
* Restructure landing page

* Update changelog
2022-06-16 16:29:35 +02:00
a996dd7ed5 Feature/add vertical hover line to performance chart (#1020)
* Add vertical hover line

* Update changelog
2022-06-16 14:16:53 +02:00
002b883668 Feature/upgrade to angular 14 (#1019)
* Upgrade to angular 14

* Migrate UntypedFormControl to FormControl

* Update changelog
2022-06-16 10:28:23 +02:00
0b06823893 Release 1.160.0 (#1018) 2022-06-15 20:18:13 +02:00
2dfd779444 Bugfix/fix no data provider has been found in search (#1017)
* Fix default value of DATA_SOURCES

* Update changelog
2022-06-15 20:16:38 +02:00
1824413379 Release 1.159.0 (#1015) 2022-06-15 13:24:07 +02:00
3332ade3d3 Feature/filter public endpoint by equity asset class (#1014)
* Filter by ASSET_CLASS: EQUITY

* Update changelog
2022-06-15 13:21:47 +02:00
8d2e110e3d Feature/change default host (#1013)
* Change default host to 0.0.0.0

* Update changelog
2022-06-14 17:43:42 +02:00
a8fcf09380 Fix link (#1012) 2022-06-13 17:44:55 +02:00
1071f446a8 Release 1.158.1 (#1010) 2022-06-12 19:21:02 +02:00
03b050d1ac Release 1.158.0 (#1009) 2022-06-12 17:53:09 +02:00
58eeff7001 Feature/expose the environment variable host (#1007)
* Expose environment variable: HOST

* Update changelog
2022-06-12 17:51:26 +02:00
76fb8825e4 Feature/restructure self hosting documentation (#1008)
* Restructure self-hosting documentation

* Update changelog
2022-06-12 17:50:45 +02:00
0f9d142afe Feature/decrease number of attempts of queue jobs (#1006)
* Decrease number of attempts

* Update changelog
2022-06-12 17:39:55 +02:00
bd33855a27 Feature/improve message for data provider errors (#1003)
* Add title

* Update changelog
2022-06-12 17:39:02 +02:00
5329e45e2c Feature/add data dialog to queue jobs view (#1005)
* Add data dialog

* Update changelog
2022-06-12 17:30:26 +02:00
e990ecd12c Feature/improve cash balance label (#1002)
* Improve label

* Update changelog
2022-06-12 10:06:18 +02:00
a4fcf64f13 Release 1.157.0 (#1000) 2022-06-11 13:41:39 +02:00
557e3a0676 Feature/migrate historical market data gathering to queue design pattern (#991)
* Migrate historical market data gathering to queue

* Filter and delete jobs

* Detect duplicate jobs

* Update changelog
2022-06-11 13:40:15 +02:00
2abe399ebd Bugfix/force reload accounts of user after change (#994)
* Force reload of accounts after change

* Update changelog
2022-06-11 12:56:34 +02:00
74fe90906a Bugfix/exclude emtpy items in activities filter (#999)
* Exclude empty items

* Update changelog
2022-06-11 12:55:32 +02:00
4cb9a3b142 Feature/refresh cryptocurrencies list (#998)
* Use cryptocurrencies list instead of outdated npm package

* Update changelog
2022-06-11 12:54:58 +02:00
0da9368e0c Fix date (#992) 2022-06-10 17:42:56 +02:00
d2f8e3d645 Feature/increase fear and greed index to 180 days (#993)
* Increase number of days to 180

* Update changelog
2022-06-09 18:56:29 +02:00
5263fba64e Feature/upgrade envalid to version 7.3.1 (#990)
* Upgrade envalid to version 7.3.1

* Update changelog
2022-06-06 17:42:32 +02:00
e3689c48f8 Feature/upgrade chart.js to version 3.8.0 (#989)
* Upgrade chart.js to version 3.8.0

* Update changelog
2022-06-06 08:27:34 +02:00
787efdb33b Release 1.156.0 (#988) 2022-06-05 19:03:11 +02:00
e63578d8ce Feature/add guards to local comparison (#986)
* Add guards

* Improve labels
2022-06-05 19:00:45 +02:00
7cf0cdc4ce Feature/add jobs of queue to admin control panel (#987)
* Add jobs of queue to admin control panel

* Update changelog
2022-06-05 19:00:20 +02:00
14a0eeab29 Bugfix/fix docker compose files to resolve variables correctly (#983)
* Fix variable resolving

* Update changelog
2022-06-04 10:02:38 +02:00
6774c48dff Feature/restructure fire page (#982)
* Restructure fire page

* Update changelog
2022-06-04 09:06:53 +02:00
565947e752 Feature/upgrade simplewebauthn browser and server to version 5.2.1 (#981)
* Upgrade @simplewebauthn/browser and @simplewebauthn/server

* Update changelog
2022-06-03 18:44:53 +02:00
2cc7c6fa1c Feature/add user id to account page (#980)
* Add user id

* Update changelog
2022-06-03 06:51:08 +02:00
023a7147e2 Feature/simplify feature page (#978)
* Simplify page

* Update changelog
2022-05-31 18:01:29 +02:00
291 changed files with 17655 additions and 7765 deletions

1
.env
View File

@ -14,4 +14,3 @@ ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
ALPHA_VANTAGE_API_KEY= ALPHA_VANTAGE_API_KEY=
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer
JWT_SECRET_KEY=<INSERT_RANDOM_STRING> JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
PORT=3333

3
.gitignore vendored
View File

@ -24,15 +24,16 @@
# misc # misc
/.angular/cache /.angular/cache
.env.prod
/.sass-cache /.sass-cache
/connect.lock /connect.lock
/coverage /coverage
/dist /dist
/libpeerconnection.log /libpeerconnection.log
npm-debug.log npm-debug.log
yarn-error.log
testem.log testem.log
/typings /typings
yarn-error.log
# System Files # System Files
.DS_Store .DS_Store

View File

@ -5,6 +5,252 @@ 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.175.0 - 29.07.2022
### Added
- Set up a Frequently Asked Questions (FAQ) page
- Added the savings rate to the investment timeline grouped by month
### Fixed
- Added the symbols to the activities in the account detail dialog
## 1.174.0 - 27.07.2022
### Added
- Support a note for activities
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.173.0 - 23.07.2022
### Fixed
- Fixed an issue with the currency inconsistency in the _Yahoo Finance_ service (convert from `USX` to `USD`)
## 1.172.0 - 23.07.2022
### Added
- Added a blog post: _Ghostfolio meets Internet Identity_
## 1.171.0 - 22.07.2022
### Added
- Added _Internet Identity_ as a new social login provider
### Changed
- Improved the empty state of the
- _Analysis_ section
- _Holdings_ section
- performance chart on the home page
### Fixed
- Fixed the distorted tooltip in the performance chart on the home page
- Fixed a calculation issue of the current month in the investment timeline grouped by month
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.170.0 - 19.07.2022
### Added
- Added support for the tags in the create or edit transaction dialog
- Added support for the cryptocurrency _TerraUSD_ (`UST-USD`)
### Changed
- Removed the alias from the user interface as a preparation to remove it from the `User` database schema
- Removed the activities import limit for users with a subscription
### Todo
- Rename the environment variable from `MAX_ORDERS_TO_IMPORT` to `MAX_ACTIVITIES_TO_IMPORT`
## 1.169.0 - 14.07.2022
### Added
- Added support for the cryptocurrency _Songbird_ (`SGB1-USD`)
- Added support for the cryptocurrency _Terra 2.0_ (`LUNA2-USD`)
- Added a blog post
### Changed
- Refreshed the cryptocurrencies list to support more coins by default
- Upgraded `date-fns` from version `2.22.1` to `2.28.0`
## 1.168.0 - 10.07.2022
### Added
- Extended the investment timeline grouped by month
### Changed
- Handled an occasional currency pair inconsistency in the _Yahoo Finance_ service (`GBP=X` instead of `USDGBP=X`)
### Fixed
- Fixed the content height of the account detail dialog
## 1.167.0 - 07.07.2022
### Added
- Added _Markets_ to the public pages
### Changed
- Improved the _Create Account_ link in the _Live Demo_
- Upgraded `ngx-markdown` from version `13.0.0` to `14.0.1`
### Fixed
- Fixed an issue in the _Holdings_ section for users without a subscription
## 1.166.0 - 30.06.2022
### Added
- Added an account detail dialog
### Changed
- Improved the label of the (symbol) search
- Refactored the demo account as a route (`/demo`)
- Upgraded `nestjs` from version `8.2.3` to `8.4.7`
- Upgraded `prisma` from version `3.14.0` to `3.15.2`
- Upgraded `yahoo-finance2` from version `2.3.2` to `2.3.3`
- Upgraded `zone.js` from version `0.11.4` to `0.11.6`
## 1.165.0 - 25.06.2022
### Added
- Added an icon and name column to the positions table
- Added a reusable premium indicator component
### Changed
- Moved the positions table to a dedicated section (_Holdings_)
- Changed the data gathering by symbol endpoint to delete data first
## 1.164.0 - 23.06.2022
### Added
- Added the positions table including performance to the public page
## 1.163.0 - 22.06.2022
### Changed
- Improved the onboarding for iOS
## 1.162.0 - 18.06.2022
### Added
- Added a _Privacy Policy_ page
### Changed
- Simplified the header
### Fixed
- Fixed an issue with the currency inconsistency in the _Yahoo Finance_ service (convert from `ILA` to `ILS`)
## 1.161.1 - 16.06.2022
### Added
- Added the vertical hover line to inspect data points in the performance chart on the home page
### Changed
- Improved the landing page
- Upgraded `angular` from version `13.3.6` to `14.0.2`
- Upgraded `Nx` from version `14.1.4` to `14.3.5`
- Upgraded `storybook` from version `6.4.22` to `6.5.9`
### Fixed
- Improved the error handling of missing market prices
## 1.160.0 - 15.06.2022
### Fixed
- Fixed the `No data provider has been found` error in the search (regression after `envalid` upgrade to `7.3.1` in Ghostfolio `1.157.0`)
## 1.159.0 - 15.06.2022
### Changed
- Changed the default `HOST` to `0.0.0.0`
- Refactored the endpoint of the public page (filter by equity)
## 1.158.1 - 12.06.2022
### Added
- Extended the queue jobs view in the admin control panel by a data dialog
### Changed
- Exposed the environment variable `HOST`
- Decreased the number of attempts of queue jobs from `20` to `10` (fail earlier)
- Improved the message for data provider errors in the client
- Changed the label from _Balance_ to _Cash Balance_ in the account dialog
- Restructured the documentation for self-hosting
## 1.157.0 - 11.06.2022
### Added
- Extended the queue jobs view in the admin control panel by the number of attempts and the status
### Changed
- Migrated the historical market data gathering to the queue design pattern
- Refreshed the cryptocurrencies list to support more coins by default
- Increased the historical data chart of the _Fear & Greed Index_ (market mood) to 180 days
- Upgraded `chart.js` from version `3.7.0` to `3.8.0`
- Upgraded `envalid` from version `7.2.1` to `7.3.1`
### Fixed
- Reloaded the accounts of a user after creating, editing or deleting one
- Excluded empty items in the activities filter
## 1.156.0 - 05.06.2022
### Added
- Added the user id to the account page
- Added a new view with jobs of the queue to the admin control panel
### Changed
- Simplified the features page
- Restructured the _FIRE_ section
- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `4.1.0` to `5.2.1`
### Fixed
- Fixed the `docker-compose` files to resolve variables correctly
## 1.155.0 - 29.05.2022 ## 1.155.0 - 29.05.2022
### Added ### Added

View File

@ -12,7 +12,7 @@ COPY ./package.json package.json
COPY ./yarn.lock yarn.lock COPY ./yarn.lock yarn.lock
COPY ./prisma/schema.prisma prisma/schema.prisma COPY ./prisma/schema.prisma prisma/schema.prisma
RUN apk add --no-cache python3 g++ make openssl RUN apk add --no-cache python3 g++ make openssl git
RUN yarn install RUN yarn install
# See https://github.com/nrwl/nx/issues/6586 for further details # See https://github.com/nrwl/nx/issues/6586 for further details
@ -22,7 +22,7 @@ RUN node decorate-angular-cli.js
COPY ./angular.json angular.json COPY ./angular.json angular.json
COPY ./nx.json nx.json COPY ./nx.json nx.json
COPY ./replace.build.js replace.build.js COPY ./replace.build.js replace.build.js
COPY ./jest.preset.ts jest.preset.ts COPY ./jest.preset.js jest.preset.js
COPY ./jest.config.ts jest.config.ts COPY ./jest.config.ts jest.config.ts
COPY ./tsconfig.base.json tsconfig.base.json COPY ./tsconfig.base.json tsconfig.base.json
COPY ./libs libs COPY ./libs libs

View File

@ -12,7 +12,7 @@
<strong>Open Source Wealth Management Software</strong> <strong>Open Source Wealth Management Software</strong>
</p> </p>
<p> <p>
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/blog"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a> <a href="https://ghostfol.io"><strong>Ghostfol.io</strong></a> | <a href="https://ghostfol.io/demo"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/blog"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
</p> </p>
<p> <p>
<a href="#contributing"> <a href="#contributing">
@ -35,7 +35,7 @@
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs. Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
If you prefer to run Ghostfolio on your own infrastructure (self-hosting), please find further instructions in the section [Run with Docker](#run-with-docker-self-hosting). If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the [Self-hosting](#self-hosting) section.
## Why Ghostfolio? ## Why Ghostfolio?
@ -79,47 +79,50 @@ The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://
The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com). The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
## Run with Docker (self-hosting) ## Self-hosting
### Prerequisites ### Run with Docker Compose
- [Docker](https://www.docker.com/products/docker-desktop) #### Prerequisites
- A local copy of this Git repository (clone)
### a. Run environment - Basic knowledge of Docker
- Installation of [Docker](https://www.docker.com/products/docker-desktop)
- Local copy of this Git repository (clone)
#### a. Run environment
Run the following command to start the Docker images from [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio): Run the following command to start the Docker images from [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio):
```bash ```bash
docker-compose -f docker/docker-compose.yml up -d docker-compose --env-file ./.env -f docker/docker-compose.yml up -d
``` ```
#### Setup Database ##### Setup Database
Run the following command to setup the database once Ghostfolio is running: Run the following command to setup the database once Ghostfolio is running:
```bash ```bash
docker-compose -f docker/docker-compose.yml exec ghostfolio yarn database:setup docker-compose --env-file ./.env -f docker/docker-compose.yml exec ghostfolio yarn database:setup
``` ```
### b. Build and run environment #### b. Build and run environment
Run the following commands to build and start the Docker images: Run the following commands to build and start the Docker images:
```bash ```bash
docker-compose -f docker/docker-compose.build.yml build docker-compose --env-file ./.env -f docker/docker-compose.build.yml build
docker-compose -f docker/docker-compose.build.yml up -d docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
``` ```
#### Setup Database ##### Setup Database
Run the following command to setup the database once Ghostfolio is running: Run the following command to setup the database once Ghostfolio is running:
```bash ```bash
docker-compose -f docker/docker-compose.build.yml exec ghostfolio yarn database:setup docker-compose --env-file ./.env -f docker/docker-compose.build.yml exec ghostfolio yarn database:setup
``` ```
### Fetch Historical Data #### Fetch Historical Data
Open http://localhost:3333 in your browser and accomplish these steps: Open http://localhost:3333 in your browser and accomplish these steps:
@ -127,13 +130,13 @@ Open http://localhost:3333 in your browser and accomplish these steps:
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data 1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
1. Click _Sign out_ and check out the _Live Demo_ 1. Click _Sign out_ and check out the _Live Demo_
### Upgrade Version #### Upgrade Version
1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml` 1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
1. Run the following command to start the new Docker image: `docker-compose -f docker/docker-compose.yml up -d` 1. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d`
1. Then, run the following command to keep your database schema in sync: `docker-compose -f docker/docker-compose.yml exec ghostfolio yarn database:migrate` 1. Then, run the following command to keep your database schema in sync: `docker-compose --env-file ./.env -f docker/docker-compose.yml exec ghostfolio yarn database:migrate`
## Run with _Unraid_ (self-hosting) ### Run with _Unraid_ (Community)
Please follow the instructions of the Ghostfolio [Unraid Community App](https://unraid.net/community/apps?q=ghostfolio). Please follow the instructions of the Ghostfolio [Unraid Community App](https://unraid.net/community/apps?q=ghostfolio).
@ -149,7 +152,7 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
### Setup ### Setup
1. Run `yarn install` 1. Run `yarn install`
1. Run `docker-compose -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io) 1. Run `docker-compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data 1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data
1. Start the server and the client (see [_Development_](#Development)) 1. Start the server and the client (see [_Development_](#Development))
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`) 1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
@ -183,7 +186,7 @@ yarn database:push
Run `yarn test` Run `yarn test`
## Public API (experimental) ## Public API
### Import Activities ### Import Activities

View File

@ -2,6 +2,7 @@
"version": 1, "version": 1,
"projects": { "projects": {
"api": { "api": {
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"root": "apps/api", "root": "apps/api",
"sourceRoot": "apps/api/src", "sourceRoot": "apps/api/src",
"projectType": "application", "projectType": "application",
@ -56,6 +57,7 @@
"tags": [] "tags": []
}, },
"client": { "client": {
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application", "projectType": "application",
"schematics": { "schematics": {
"@schematics/angular:component": { "@schematics/angular:component": {
@ -113,7 +115,7 @@
} }
], ],
"styles": ["apps/client/src/styles.scss"], "styles": ["apps/client/src/styles.scss"],
"scripts": ["node_modules/marked/lib/marked.js"], "scripts": ["node_modules/marked/marked.min.js"],
"vendorChunk": true, "vendorChunk": true,
"extractLicenses": false, "extractLicenses": false,
"buildOptimizer": false, "buildOptimizer": false,
@ -189,6 +191,7 @@
"tags": [] "tags": []
}, },
"client-e2e": { "client-e2e": {
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"root": "apps/client-e2e", "root": "apps/client-e2e",
"sourceRoot": "apps/client-e2e/src", "sourceRoot": "apps/client-e2e/src",
"projectType": "application", "projectType": "application",
@ -211,6 +214,7 @@
"implicitDependencies": ["client"] "implicitDependencies": ["client"]
}, },
"common": { "common": {
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"root": "libs/common", "root": "libs/common",
"sourceRoot": "libs/common/src", "sourceRoot": "libs/common/src",
"projectType": "library", "projectType": "library",
@ -233,6 +237,7 @@
"tags": [] "tags": []
}, },
"ui": { "ui": {
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "library", "projectType": "library",
"schematics": { "schematics": {
"@schematics/angular:component": { "@schematics/angular:component": {
@ -258,14 +263,12 @@
} }
}, },
"storybook": { "storybook": {
"builder": "@nrwl/storybook:storybook", "builder": "@storybook/angular:start-storybook",
"options": { "options": {
"uiFramework": "@storybook/angular",
"port": 4400, "port": 4400,
"config": { "configDir": "libs/ui/.storybook",
"configFolder": "libs/ui/.storybook" "browserTarget": "ui:build-storybook",
}, "compodoc": false
"projectBuildConfig": "ui:build-storybook"
}, },
"configurations": { "configurations": {
"ci": { "ci": {
@ -274,15 +277,13 @@
} }
}, },
"build-storybook": { "build-storybook": {
"builder": "@nrwl/storybook:build", "builder": "@storybook/angular:build-storybook",
"outputs": ["{options.outputPath}"], "outputs": ["{options.outputPath}"],
"options": { "options": {
"uiFramework": "@storybook/angular", "outputDir": "dist/storybook/ui",
"outputPath": "dist/storybook/ui", "configDir": "libs/ui/.storybook",
"config": { "browserTarget": "ui:build-storybook",
"configFolder": "libs/ui/.storybook" "compodoc": false
},
"projectBuildConfig": "ui:build-storybook"
}, },
"configurations": { "configurations": {
"ci": { "ci": {
@ -294,6 +295,7 @@
"tags": [] "tags": []
}, },
"ui-e2e": { "ui-e2e": {
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"root": "apps/ui-e2e", "root": "apps/ui-e2e",
"sourceRoot": "apps/ui-e2e/src", "sourceRoot": "apps/ui-e2e/src",
"projectType": "application", "projectType": "application",

View File

@ -1,4 +1,4 @@
module.exports = { export default {
displayName: 'api', displayName: 'api',
globals: { globals: {
@ -13,5 +13,5 @@ module.exports = {
coverageDirectory: '../../coverage/apps/api', coverageDirectory: '../../coverage/apps/api',
testTimeout: 10000, testTimeout: 10000,
testEnvironment: 'node', testEnvironment: 'node',
preset: '../../jest.preset.ts' preset: '../../jest.preset.js'
}; };

View File

@ -7,7 +7,10 @@ import {
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { Accounts } from '@ghostfolio/common/interfaces'; import { Accounts } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type {
AccountWithValue,
RequestWithUser
} from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
@ -123,13 +126,45 @@ export class AccountController {
@Get(':id') @Get(':id')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getAccountById(@Param('id') id: string): Promise<AccountModel> { public async getAccountById(
return this.accountService.account({ @Headers('impersonation-id') impersonationId,
id_userId: { @Param('id') id: string
id, ): Promise<AccountWithValue> {
userId: this.request.user.id const impersonationUserId =
} await this.impersonationService.validateImpersonationId(
}); impersonationId,
this.request.user.id
);
let accountsWithAggregations =
await this.portfolioService.getAccountsWithAggregations(
impersonationUserId || this.request.user.id,
[{ id, type: 'ACCOUNT' }]
);
if (
impersonationUserId ||
this.userService.isRestrictedView(this.request.user)
) {
accountsWithAggregations = {
...nullifyValuesInObject(accountsWithAggregations, [
'totalBalanceInBaseCurrency',
'totalValueInBaseCurrency'
]),
accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [
'balance',
'balanceInBaseCurrency',
'convertedBalance',
'fee',
'quantity',
'unitPrice',
'value',
'valueInBaseCurrency'
])
};
}
return accountsWithAggregations.accounts[0];
} }
@Post() @Post()

View File

@ -2,8 +2,8 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se
import { MarketDataService } from '@ghostfolio/api/services/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto'; import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import { import {
DATA_GATHERING_QUEUE, GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
AdminData, AdminData,
@ -12,7 +12,6 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { InjectQueue } from '@nestjs/bull';
import { import {
Body, Body,
Controller, Controller,
@ -28,7 +27,6 @@ import {
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 { DataSource, MarketData } from '@prisma/client';
import { Queue } from 'bull';
import { isDate } from 'date-fns'; import { isDate } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -39,8 +37,6 @@ import { UpdateMarketDataDto } from './update-market-data.dto';
export class AdminController { export class AdminController {
public constructor( public constructor(
private readonly adminService: AdminService, private readonly adminService: AdminService,
@InjectQueue(DATA_GATHERING_QUEUE)
private readonly dataGatheringQueue: Queue,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
@ -64,6 +60,24 @@ export class AdminController {
return this.adminService.get(); return this.adminService.get();
} }
@Post('gather')
@UseGuards(AuthGuard('jwt'))
public async gather7Days(): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
this.dataGatheringService.gather7Days();
}
@Post('gather/max') @Post('gather/max')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async gatherMax(): Promise<void> { public async gatherMax(): Promise<void> {
@ -82,10 +96,14 @@ export class AdminController {
const uniqueAssets = await this.dataGatheringService.getUniqueAssets(); const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
for (const { dataSource, symbol } of uniqueAssets) { for (const { dataSource, symbol } of uniqueAssets) {
await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, { await this.dataGatheringService.addJobToQueue(
dataSource, GATHER_ASSET_PROFILE_PROCESS,
symbol {
}); dataSource,
symbol
},
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
);
} }
this.dataGatheringService.gatherMax(); this.dataGatheringService.gatherMax();
@ -109,10 +127,14 @@ export class AdminController {
const uniqueAssets = await this.dataGatheringService.getUniqueAssets(); const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
for (const { dataSource, symbol } of uniqueAssets) { for (const { dataSource, symbol } of uniqueAssets) {
await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, { await this.dataGatheringService.addJobToQueue(
dataSource, GATHER_ASSET_PROFILE_PROCESS,
symbol {
}); dataSource,
symbol
},
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
);
} }
} }
@ -134,10 +156,14 @@ export class AdminController {
); );
} }
await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, { await this.dataGatheringService.addJobToQueue(
dataSource, GATHER_ASSET_PROFILE_PROCESS,
symbol {
}); dataSource,
symbol
},
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
);
} }
@Post('gather/:dataSource/:symbol') @Post('gather/:dataSource/:symbol')

View File

@ -11,6 +11,7 @@ import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller'; import { AdminController } from './admin.controller';
import { AdminService } from './admin.service'; import { AdminService } from './admin.service';
import { QueueModule } from './queue/queue.module';
@Module({ @Module({
imports: [ imports: [
@ -21,6 +22,7 @@ import { AdminService } from './admin.service';
MarketDataModule, MarketDataModule,
PrismaModule, PrismaModule,
PropertyModule, PropertyModule,
QueueModule,
SubscriptionModule, SubscriptionModule,
SymbolProfileModule SymbolProfileModule
], ],

View File

@ -1,6 +1,5 @@
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 { 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 { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
@ -24,7 +23,6 @@ export class AdminService {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataGatheringService: DataGatheringService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
@ -42,8 +40,6 @@ export class AdminService {
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) => {
@ -60,7 +56,6 @@ export class AdminService {
) )
}; };
}), }),
lastDataGathering: await this.getLastDataGathering(),
settings: await this.propertyService.get(), 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(),
@ -161,30 +156,11 @@ export class AdminService {
if (key === PROPERTY_CURRENCIES) { if (key === PROPERTY_CURRENCIES) {
await this.exchangeRateDataService.initialize(); await this.exchangeRateDataService.initialize();
await this.dataGatheringService.reset();
} }
return response; return response;
} }
private async getLastDataGathering() {
const lastDataGathering =
await this.dataGatheringService.getLastDataGathering();
if (lastDataGathering) {
return lastDataGathering;
}
const dataGatheringInProgress =
await this.dataGatheringService.getIsInProgress();
if (dataGatheringInProgress) {
return 'IN_PROGRESS';
}
return undefined;
}
private async getUsersWithAnalytics(): Promise<AdminData['users']> { private async getUsersWithAnalytics(): Promise<AdminData['users']> {
const usersWithAnalytics = await this.prismaService.user.findMany({ const usersWithAnalytics = await this.prismaService.user.findMany({
orderBy: { orderBy: {
@ -196,7 +172,6 @@ export class AdminService {
_count: { _count: {
select: { Account: true, Order: true } select: { Account: true, Order: true }
}, },
alias: true,
Analytics: { Analytics: {
select: { select: {
activityCount: true, activityCount: true,
@ -216,7 +191,7 @@ export class AdminService {
}); });
return usersWithAnalytics.map( return usersWithAnalytics.map(
({ _count, alias, Analytics, createdAt, id, Subscription }) => { ({ _count, Analytics, createdAt, id, Subscription }) => {
const daysSinceRegistration = const daysSinceRegistration =
differenceInDays(new Date(), createdAt) + 1; differenceInDays(new Date(), createdAt) + 1;
const engagement = Analytics.activityCount / daysSinceRegistration; const engagement = Analytics.activityCount / daysSinceRegistration;
@ -228,7 +203,6 @@ export class AdminService {
: undefined; : undefined;
return { return {
alias,
createdAt, createdAt,
engagement, engagement,
id, id,

View File

@ -0,0 +1,87 @@
import { AdminJobs } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Delete,
Get,
HttpException,
Inject,
Param,
Query,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { JobStatus } from 'bull';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { QueueService } from './queue.service';
@Controller('admin/queue')
export class QueueController {
public constructor(
private readonly queueService: QueueService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Delete('job')
@UseGuards(AuthGuard('jwt'))
public async deleteJobs(
@Query('status') filterByStatus?: string
): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
return this.queueService.deleteJobs({ status });
}
@Get('job')
@UseGuards(AuthGuard('jwt'))
public async getJobs(
@Query('status') filterByStatus?: string
): Promise<AdminJobs> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
return this.queueService.getJobs({ status });
}
@Delete('job/:id')
@UseGuards(AuthGuard('jwt'))
public async deleteJob(@Param('id') id: string): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.queueService.deleteJob(id);
}
}

View File

@ -0,0 +1,12 @@
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { Module } from '@nestjs/common';
import { QueueController } from './queue.controller';
import { QueueService } from './queue.service';
@Module({
controllers: [QueueController],
imports: [DataGatheringModule],
providers: [QueueService]
})
export class QueueModule {}

View File

@ -0,0 +1,65 @@
import {
DATA_GATHERING_QUEUE,
QUEUE_JOB_STATUS_LIST
} from '@ghostfolio/common/config';
import { AdminJobs } from '@ghostfolio/common/interfaces';
import { InjectQueue } from '@nestjs/bull';
import { Injectable, Logger } from '@nestjs/common';
import { JobStatus, Queue } from 'bull';
@Injectable()
export class QueueService {
public constructor(
@InjectQueue(DATA_GATHERING_QUEUE)
private readonly dataGatheringQueue: Queue
) {}
public async deleteJob(aId: string) {
return (await this.dataGatheringQueue.getJob(aId))?.remove();
}
public async deleteJobs({
status = QUEUE_JOB_STATUS_LIST
}: {
status?: JobStatus[];
}) {
const jobs = await this.dataGatheringQueue.getJobs(status);
for (const job of jobs) {
try {
await job.remove();
} catch (error) {
Logger.warn(error, 'QueueService');
}
}
}
public async getJobs({
limit = 1000,
status = QUEUE_JOB_STATUS_LIST
}: {
limit?: number;
status?: JobStatus[];
}): Promise<AdminJobs> {
const jobs = await this.dataGatheringQueue.getJobs(status);
const jobsWithState = await Promise.all(
jobs.slice(0, limit).map(async (job) => {
return {
attemptsMade: job.attemptsMade + 1,
data: job.data,
finishedOn: job.finishedOn,
id: job.id,
name: job.name,
stacktrace: job.stacktrace,
state: await job.getState(),
timestamp: job.timestamp
};
})
);
return {
jobs: jobsWithState
};
}
}

View File

@ -1,26 +1,6 @@
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { Controller } from '@nestjs/common'; import { Controller } from '@nestjs/common';
import { RedisCacheService } from './redis-cache/redis-cache.service';
@Controller() @Controller()
export class AppController { export class AppController {
public constructor( public constructor() {}
private readonly dataGatheringService: DataGatheringService,
private readonly redisCacheService: RedisCacheService
) {
this.initialize();
}
private async initialize() {
this.redisCacheService.reset();
const isDataGatheringInProgress =
await this.dataGatheringService.getIsInProgress();
if (isDataGatheringInProgress) {
// Prepare for automatical data gathering, if hung up in progress state
await this.dataGatheringService.reset();
}
}
} }

View File

@ -1,5 +1,6 @@
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { OAuthResponse } from '@ghostfolio/common/interfaces';
import { import {
Body, Body,
Controller, Controller,
@ -31,7 +32,9 @@ export class AuthController {
) {} ) {}
@Get('anonymous/:accessToken') @Get('anonymous/:accessToken')
public async accessTokenLogin(@Param('accessToken') accessToken: string) { public async accessTokenLogin(
@Param('accessToken') accessToken: string
): Promise<OAuthResponse> {
try { try {
const authToken = await this.authService.validateAnonymousLogin( const authToken = await this.authService.validateAnonymousLogin(
accessToken accessToken
@ -65,6 +68,23 @@ export class AuthController {
} }
} }
@Get('internet-identity/:principalId')
public async internetIdentityLogin(
@Param('principalId') principalId: string
): Promise<OAuthResponse> {
try {
const authToken = await this.authService.validateInternetIdentityLogin(
principalId
);
return { authToken };
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
}
@Get('webauthn/generate-registration-options') @Get('webauthn/generate-registration-options')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async generateRegistrationOptions() { public async generateRegistrationOptions() {

View File

@ -2,6 +2,7 @@ import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { Provider } from '@prisma/client';
import { ValidateOAuthLoginParams } from './interfaces/interfaces'; import { ValidateOAuthLoginParams } from './interfaces/interfaces';
@ -13,7 +14,7 @@ export class AuthService {
private readonly userService: UserService private readonly userService: UserService
) {} ) {}
public async validateAnonymousLogin(accessToken: string) { public async validateAnonymousLogin(accessToken: string): Promise<string> {
return new Promise(async (resolve, reject) => { return new Promise(async (resolve, reject) => {
try { try {
const hashedAccessToken = this.userService.createAccessToken( const hashedAccessToken = this.userService.createAccessToken(
@ -26,7 +27,7 @@ export class AuthService {
}); });
if (user) { if (user) {
const jwt: string = this.jwtService.sign({ const jwt = this.jwtService.sign({
id: user.id id: user.id
}); });
@ -40,6 +41,33 @@ export class AuthService {
}); });
} }
public async validateInternetIdentityLogin(principalId: string) {
try {
const provider: Provider = 'INTERNET_IDENTITY';
let [user] = await this.userService.users({
where: { provider, thirdPartyId: principalId }
});
if (!user) {
// Create new user if not found
user = await this.userService.createUser({
provider,
thirdPartyId: principalId
});
}
return this.jwtService.sign({
id: user.id
});
} catch (error) {
throw new InternalServerErrorException(
'validateInternetIdentityLogin',
error.message
);
}
}
public async validateOAuthLogin({ public async validateOAuthLogin({
provider, provider,
thirdPartyId thirdPartyId
@ -57,13 +85,14 @@ export class AuthService {
}); });
} }
const jwt: string = this.jwtService.sign({ return this.jwtService.sign({
id: user.id id: user.id
}); });
} catch (error) {
return jwt; throw new InternalServerErrorException(
} catch (err) { 'validateOAuthLogin',
throw new InternalServerErrorException('validateOAuthLogin', err.message); error.message
);
} }
} }
} }

View File

@ -3,8 +3,7 @@ import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interc
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_BENCHMARKS } from '@ghostfolio/common/config'; import { PROPERTY_BENCHMARKS } from '@ghostfolio/common/config';
import { BenchmarkResponse, UniqueAsset } from '@ghostfolio/common/interfaces'; import { BenchmarkResponse, UniqueAsset } from '@ghostfolio/common/interfaces';
import { Controller, Get, UseGuards, UseInterceptors } from '@nestjs/common'; import { Controller, Get, UseInterceptors } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { BenchmarkService } from './benchmark.service'; import { BenchmarkService } from './benchmark.service';
@ -16,7 +15,6 @@ export class BenchmarkController {
) {} ) {}
@Get() @Get()
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getBenchmark(): Promise<BenchmarkResponse> { public async getBenchmark(): Promise<BenchmarkResponse> {

View File

@ -1,25 +1,39 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Inject, Post, UseGuards } from '@nestjs/common'; import {
Controller,
HttpException,
Inject,
Post,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@Controller('cache') @Controller('cache')
export class CacheController { export class CacheController {
public constructor( public constructor(
private readonly cacheService: CacheService,
private readonly redisCacheService: RedisCacheService, private readonly redisCacheService: RedisCacheService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) { ) {}
this.redisCacheService.reset();
}
@Post('flush') @Post('flush')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async flushCache(): Promise<void> { public async flushCache(): Promise<void> {
this.redisCacheService.reset(); if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.cacheService.flush(); return this.redisCacheService.reset();
} }
} }

View File

@ -1,4 +1,3 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
@ -11,7 +10,6 @@ import { Module } from '@nestjs/common';
import { CacheController } from './cache.controller'; import { CacheController } from './cache.controller';
@Module({ @Module({
exports: [CacheService],
controllers: [CacheController], controllers: [CacheController],
imports: [ imports: [
ConfigurationModule, ConfigurationModule,
@ -21,7 +19,6 @@ import { CacheController } from './cache.controller';
PrismaModule, PrismaModule,
RedisCacheModule, RedisCacheModule,
SymbolProfileModule SymbolProfileModule
], ]
providers: [CacheService]
}) })
export class CacheModule {} export class CacheModule {}

View File

@ -1,15 +0,0 @@
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { Injectable } from '@nestjs/common';
@Injectable()
export class CacheService {
public constructor(
private readonly dataGaterhingService: DataGatheringService
) {}
public async flush(): Promise<void> {
await this.dataGaterhingService.reset();
return;
}
}

View File

@ -18,6 +18,7 @@ export class ExportService {
orderBy: { date: 'desc' }, orderBy: { date: 'desc' },
select: { select: {
accountId: true, accountId: true,
comment: true,
date: true, date: true,
fee: true, fee: true,
id: true, id: true,
@ -40,6 +41,7 @@ export class ExportService {
activities: activities.map( activities: activities.map(
({ ({
accountId, accountId,
comment,
date, date,
fee, fee,
id, id,
@ -50,6 +52,7 @@ export class ExportService {
}) => { }) => {
return { return {
accountId, accountId,
comment,
fee, fee,
id, id,
quantity, quantity,

View File

@ -34,8 +34,20 @@ export class ImportController {
); );
} }
let maxActivitiesToImport = this.configurationService.get(
'MAX_ACTIVITIES_TO_IMPORT'
);
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Premium'
) {
maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
}
try { try {
return await this.importService.import({ return await this.importService.import({
maxActivitiesToImport,
activities: importData.activities, activities: importData.activities,
userId: this.request.user.id userId: this.request.user.id
}); });

View File

@ -17,9 +17,11 @@ export class ImportService {
public async import({ public async import({
activities, activities,
maxActivitiesToImport,
userId userId
}: { }: {
activities: Partial<CreateOrderDto>[]; activities: Partial<CreateOrderDto>[];
maxActivitiesToImport: number;
userId: string; userId: string;
}): Promise<void> { }): Promise<void> {
for (const activity of activities) { for (const activity of activities) {
@ -32,7 +34,11 @@ export class ImportService {
} }
} }
await this.validateActivities({ activities, userId }); await this.validateActivities({
activities,
maxActivitiesToImport,
userId
});
const accountIds = (await this.accountService.getAccounts(userId)).map( const accountIds = (await this.accountService.getAccounts(userId)).map(
(account) => { (account) => {
@ -42,6 +48,7 @@ export class ImportService {
for (const { for (const {
accountId, accountId,
comment,
currency, currency,
dataSource, dataSource,
date, date,
@ -52,6 +59,7 @@ export class ImportService {
unitPrice unitPrice
} of activities) { } of activities) {
await this.orderService.createOrder({ await this.orderService.createOrder({
comment,
fee, fee,
quantity, quantity,
type, type,
@ -81,19 +89,15 @@ export class ImportService {
private async validateActivities({ private async validateActivities({
activities, activities,
maxActivitiesToImport,
userId userId
}: { }: {
activities: Partial<CreateOrderDto>[]; activities: Partial<CreateOrderDto>[];
maxActivitiesToImport: number;
userId: string; userId: string;
}) { }) {
if ( if (activities?.length > maxActivitiesToImport) {
activities?.length > this.configurationService.get('MAX_ORDERS_TO_IMPORT') throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
) {
throw new Error(
`Too many activities (${this.configurationService.get(
'MAX_ORDERS_TO_IMPORT'
)} at most)`
);
} }
const existingActivities = await this.orderService.orders({ const existingActivities = await this.orderService.orders({

View File

@ -63,6 +63,8 @@ export class InfoService {
} else { } else {
info.fearAndGreedDataSource = ghostfolioFearAndGreedIndexDataSource; info.fearAndGreedDataSource = ghostfolioFearAndGreedIndexDataSource;
} }
globalPermissions.push(permissions.enableFearAndGreedIndex);
} }
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) { if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
@ -106,7 +108,6 @@ export class InfoService {
baseCurrency: this.configurationService.get('BASE_CURRENCY'), baseCurrency: this.configurationService.get('BASE_CURRENCY'),
currencies: this.exchangeRateDataService.getCurrencies(), currencies: this.exchangeRateDataService.getCurrencies(),
demoAuthToken: this.getDemoAuthToken(), demoAuthToken: this.getDemoAuthToken(),
lastDataGathering: await this.getLastDataGathering(),
statistics: await this.getStatistics(), statistics: await this.getStatistics(),
subscriptions: await this.getSubscriptions(), subscriptions: await this.getSubscriptions(),
tags: await this.tagService.get() tags: await this.tagService.get()
@ -215,13 +216,6 @@ export class InfoService {
}); });
} }
private async getLastDataGathering() {
const lastDataGathering =
await this.dataGatheringService.getLastDataGathering();
return lastDataGathering ?? null;
}
private async getStatistics() { private async getStatistics() {
if (!this.configurationService.get('ENABLE_FEATURE_STATISTICS')) { if (!this.configurationService.get('ENABLE_FEATURE_STATISTICS')) {
return undefined; return undefined;

View File

@ -1,30 +1,46 @@
import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client';
import { import {
AssetClass,
AssetSubClass,
DataSource,
Tag,
Type
} from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer';
import {
IsArray,
IsEnum, IsEnum,
IsISO8601, IsISO8601,
IsNumber, IsNumber,
IsOptional, IsOptional,
IsString IsString
} from 'class-validator'; } from 'class-validator';
import { isString } from 'lodash';
export class CreateOrderDto { export class CreateOrderDto {
@IsString()
@IsOptional() @IsOptional()
@IsString()
accountId?: string; accountId?: string;
@IsEnum(AssetClass, { each: true })
@IsOptional() @IsOptional()
@IsEnum(AssetClass, { each: true })
assetClass?: AssetClass; assetClass?: AssetClass;
@IsEnum(AssetSubClass, { each: true })
@IsOptional() @IsOptional()
@IsEnum(AssetSubClass, { each: true })
assetSubClass?: AssetSubClass; assetSubClass?: AssetSubClass;
@IsOptional()
@IsString()
@Transform(({ value }: TransformFnParams) =>
isString(value) ? value.trim() : value
)
comment?: string;
@IsString() @IsString()
currency: string; currency: string;
@IsEnum(DataSource, { each: true })
@IsOptional() @IsOptional()
@IsEnum(DataSource, { each: true })
dataSource?: DataSource; dataSource?: DataSource;
@IsISO8601() @IsISO8601()
@ -39,6 +55,10 @@ export class CreateOrderDto {
@IsString() @IsString()
symbol: string; symbol: string;
@IsArray()
@IsOptional()
tags?: Tag[];
@IsEnum(Type, { each: true }) @IsEnum(Type, { each: true })
type: Type; type: Type;

View File

@ -4,6 +4,7 @@ import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { Filter } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
@ -17,6 +18,7 @@ import {
Param, Param,
Post, Post,
Put, Put,
Query,
UseGuards, UseGuards,
UseInterceptors UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
@ -66,8 +68,36 @@ export class OrderController {
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAllOrders( public async getAllOrders(
@Headers('impersonation-id') impersonationId @Headers('impersonation-id') impersonationId,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('tags') filterByTags?: string
): Promise<Activities> { ): Promise<Activities> {
const accountIds = filterByAccounts?.split(',') ?? [];
const assetClasses = filterByAssetClasses?.split(',') ?? [];
const tagIds = filterByTags?.split(',') ?? [];
const filters: Filter[] = [
...accountIds.map((accountId) => {
return <Filter>{
id: accountId,
type: 'ACCOUNT'
};
}),
...assetClasses.map((assetClass) => {
return <Filter>{
id: assetClass,
type: 'ASSET_CLASS'
};
}),
...tagIds.map((tagId) => {
return <Filter>{
id: tagId,
type: 'TAG'
};
})
];
const impersonationUserId = const impersonationUserId =
await this.impersonationService.validateImpersonationId( await this.impersonationService.validateImpersonationId(
impersonationId, impersonationId,
@ -76,6 +106,7 @@ export class OrderController {
const userCurrency = this.request.user.Settings.currency; const userCurrency = this.request.user.Settings.currency;
let activities = await this.orderService.getOrders({ let activities = await this.orderService.getOrders({
filters,
userCurrency, userCurrency,
includeDrafts: true, includeDrafts: true,
userId: impersonationUserId || this.request.user.id userId: impersonationUserId || this.request.user.id

View File

@ -1,16 +1,14 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CacheService } from '@ghostfolio/api/app/cache/cache.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 { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { import {
DATA_GATHERING_QUEUE, GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { Filter } from '@ghostfolio/common/interfaces'; import { Filter } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { InjectQueue } from '@nestjs/bull';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { import {
AssetClass, AssetClass,
@ -18,12 +16,12 @@ import {
DataSource, DataSource,
Order, Order,
Prisma, Prisma,
Tag,
Type as TypeOfOrder Type as TypeOfOrder
} from '@prisma/client'; } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { Queue } from 'bull';
import { endOfToday, isAfter } from 'date-fns'; import { endOfToday, isAfter } from 'date-fns';
import { groupBy } from 'lodash'; import { groupBy, isString } from 'lodash';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { Activity } from './interfaces/activities.interface'; import { Activity } from './interfaces/activities.interface';
@ -32,11 +30,8 @@ import { Activity } from './interfaces/activities.interface';
export class OrderService { export class OrderService {
public constructor( public constructor(
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly cacheService: CacheService,
@InjectQueue(DATA_GATHERING_QUEUE)
private readonly dataGatheringQueue: Queue,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) {} ) {}
@ -77,6 +72,7 @@ export class OrderService {
currency?: string; currency?: string;
dataSource?: DataSource; dataSource?: DataSource;
symbol?: string; symbol?: string;
tags?: Tag[];
userId: string; userId: string;
} }
): Promise<Order> { ): Promise<Order> {
@ -86,6 +82,8 @@ export class OrderService {
return account.isDefault === true; return account.isDefault === true;
}); });
const tags = data.tags ?? [];
let Account = { let Account = {
connect: { connect: {
id_userId: { id_userId: {
@ -120,10 +118,14 @@ export class OrderService {
data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase(); data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase();
} }
await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, { await this.dataGatheringService.addJobToQueue(
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource, GATHER_ASSET_PROFILE_PROCESS,
symbol: data.SymbolProfile.connectOrCreate.create.symbol {
}); dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
symbol: data.SymbolProfile.connectOrCreate.create.symbol
},
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
);
const isDraft = isAfter(data.date as Date, endOfToday()); const isDraft = isAfter(data.date as Date, endOfToday());
@ -138,14 +140,18 @@ export class OrderService {
]); ]);
} }
await this.cacheService.flush();
delete data.accountId; delete data.accountId;
delete data.assetClass; delete data.assetClass;
delete data.assetSubClass; delete data.assetSubClass;
if (!data.comment) {
delete data.comment;
}
delete data.currency; delete data.currency;
delete data.dataSource; delete data.dataSource;
delete data.symbol; delete data.symbol;
delete data.tags;
delete data.userId; delete data.userId;
const orderData: Prisma.OrderCreateInput = data; const orderData: Prisma.OrderCreateInput = data;
@ -154,7 +160,12 @@ export class OrderService {
data: { data: {
...orderData, ...orderData,
Account, Account,
isDraft isDraft,
tags: {
connect: tags.map(({ id }) => {
return { id };
})
}
} }
}); });
} }
@ -302,6 +313,7 @@ export class OrderService {
currency?: string; currency?: string;
dataSource?: DataSource; dataSource?: DataSource;
symbol?: string; symbol?: string;
tags?: Tag[];
}; };
where: Prisma.OrderWhereUniqueInput; where: Prisma.OrderWhereUniqueInput;
}): Promise<Order> { }): Promise<Order> {
@ -309,6 +321,12 @@ export class OrderService {
delete data.Account; delete data.Account;
} }
if (!data.comment) {
data.comment = null;
}
const tags = data.tags ?? [];
let isDraft = false; let isDraft = false;
if (data.type === 'ITEM') { if (data.type === 'ITEM') {
@ -330,18 +348,22 @@ export class OrderService {
} }
} }
await this.cacheService.flush();
delete data.assetClass; delete data.assetClass;
delete data.assetSubClass; delete data.assetSubClass;
delete data.currency; delete data.currency;
delete data.dataSource; delete data.dataSource;
delete data.symbol; delete data.symbol;
delete data.tags;
return this.prismaService.order.update({ return this.prismaService.order.update({
data: { data: {
...data, ...data,
isDraft isDraft,
tags: {
connect: tags.map(({ id }) => {
return { id };
})
}
}, },
where where
}); });

View File

@ -1,11 +1,20 @@
import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client';
import { import {
AssetClass,
AssetSubClass,
DataSource,
Tag,
Type
} from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer';
import {
IsArray,
IsEnum, IsEnum,
IsISO8601, IsISO8601,
IsNumber, IsNumber,
IsOptional, IsOptional,
IsString IsString
} from 'class-validator'; } from 'class-validator';
import { isString } from 'lodash';
export class UpdateOrderDto { export class UpdateOrderDto {
@IsOptional() @IsOptional()
@ -20,6 +29,13 @@ export class UpdateOrderDto {
@IsOptional() @IsOptional()
assetSubClass?: AssetSubClass; assetSubClass?: AssetSubClass;
@IsOptional()
@IsString()
@Transform(({ value }: TransformFnParams) =>
isString(value) ? value.trim() : value
)
comment?: string;
@IsString() @IsString()
currency: string; currency: string;
@ -41,6 +57,10 @@ export class UpdateOrderDto {
@IsString() @IsString()
symbol: string; symbol: string;
@IsArray()
@IsOptional()
tags?: Tag[];
@IsString() @IsString()
type: Type; type: Type;

View File

@ -62,6 +62,10 @@ describe('PortfolioCalculator', () => {
parseDate('2021-11-22') parseDate('2021-11-22')
); );
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
spy.mockRestore(); spy.mockRestore();
expect(currentPositions).toEqual({ expect(currentPositions).toEqual({
@ -91,6 +95,15 @@ describe('PortfolioCalculator', () => {
], ],
totalInvestment: new Big('0') totalInvestment: new Big('0')
}); });
expect(investments).toEqual([
{ date: '2021-11-22', investment: new Big('285.8') },
{ date: '2021-11-30', investment: new Big('0') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2021-11-01', investment: new Big('12.6') }
]);
}); });
}); });
}); });

View File

@ -51,6 +51,10 @@ describe('PortfolioCalculator', () => {
parseDate('2021-11-30') parseDate('2021-11-30')
); );
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
spy.mockRestore(); spy.mockRestore();
expect(currentPositions).toEqual({ expect(currentPositions).toEqual({
@ -80,6 +84,14 @@ describe('PortfolioCalculator', () => {
], ],
totalInvestment: new Big('273.2') totalInvestment: new Big('273.2')
}); });
expect(investments).toEqual([
{ date: '2021-11-30', investment: new Big('273.2') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2021-11-01', investment: new Big('273.2') }
]);
}); });
}); });
}); });

View File

@ -39,6 +39,10 @@ describe('PortfolioCalculator', () => {
new Date() new Date()
); );
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
spy.mockRestore(); spy.mockRestore();
expect(currentPositions).toEqual({ expect(currentPositions).toEqual({
@ -51,6 +55,10 @@ describe('PortfolioCalculator', () => {
positions: [], positions: [],
totalInvestment: new Big(0) totalInvestment: new Big(0)
}); });
expect(investments).toEqual([]);
expect(investmentsByMonth).toEqual([]);
}); });
}); });
}); });

View File

@ -62,6 +62,10 @@ describe('PortfolioCalculator', () => {
parseDate('2022-03-07') parseDate('2022-03-07')
); );
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
spy.mockRestore(); spy.mockRestore();
expect(currentPositions).toEqual({ expect(currentPositions).toEqual({
@ -91,6 +95,16 @@ describe('PortfolioCalculator', () => {
], ],
totalInvestment: new Big('75.80') totalInvestment: new Big('75.80')
}); });
expect(investments).toEqual([
{ date: '2022-03-07', investment: new Big('151.6') },
{ date: '2022-04-08', investment: new Big('75.8') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2022-03-01', investment: new Big('151.6') },
{ date: '2022-04-01', investment: new Big('-85.73') }
]);
}); });
}); });
}); });

View File

@ -14,8 +14,11 @@ import {
format, format,
isAfter, isAfter,
isBefore, isBefore,
isSameMonth,
isSameYear,
max, max,
min min,
set
} from 'date-fns'; } from 'date-fns';
import { first, flatten, isNumber, sortBy } from 'lodash'; import { first, flatten, isNumber, sortBy } from 'lodash';
@ -56,7 +59,7 @@ export class PortfolioCalculator {
this.currentRateService = currentRateService; this.currentRateService = currentRateService;
this.orders = orders; this.orders = orders;
this.orders.sort((a, b) => a.date.localeCompare(b.date)); this.orders.sort((a, b) => a.date?.localeCompare(b.date));
} }
public computeTransactionPoints() { public computeTransactionPoints() {
@ -125,7 +128,7 @@ export class PortfolioCalculator {
(transactionPointItem) => transactionPointItem.symbol !== order.symbol (transactionPointItem) => transactionPointItem.symbol !== order.symbol
); );
newItems.push(currentTransactionPointItem); newItems.push(currentTransactionPointItem);
newItems.sort((a, b) => a.symbol.localeCompare(b.symbol)); newItems.sort((a, b) => a.symbol?.localeCompare(b.symbol));
if (lastDate !== currentDate || lastTransactionPoint === null) { if (lastDate !== currentDate || lastTransactionPoint === null) {
lastTransactionPoint = { lastTransactionPoint = {
date: currentDate, date: currentDate,
@ -323,6 +326,53 @@ export class PortfolioCalculator {
}); });
} }
public getInvestmentsByMonth(): { date: string; investment: Big }[] {
if (this.orders.length === 0) {
return [];
}
const investments = [];
let currentDate: Date;
let investmentByMonth = new Big(0);
for (const [index, order] of this.orders.entries()) {
if (
isSameMonth(parseDate(order.date), currentDate) &&
isSameYear(parseDate(order.date), currentDate)
) {
// Same month: Add up investments
investmentByMonth = investmentByMonth.plus(
order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
);
} else {
// New month: Store previous month and reset
if (currentDate) {
investments.push({
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
investment: investmentByMonth
});
}
currentDate = parseDate(order.date);
investmentByMonth = order.quantity
.mul(order.unitPrice)
.mul(this.getFactor(order.type));
}
if (index === this.orders.length - 1) {
// Store current month (latest order)
investments.push({
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
investment: investmentByMonth
});
}
}
return investments;
}
public async calculateTimeline( public async calculateTimeline(
timelineSpecification: TimelineSpecification[], timelineSpecification: TimelineSpecification[],
endDate: string endDate: string

View File

@ -20,7 +20,12 @@ import {
PortfolioReport, PortfolioReport,
PortfolioSummary PortfolioSummary
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types'; import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import type {
DateRange,
GroupBy,
RequestWithUser
} from '@ghostfolio/common/types';
import { import {
Controller, Controller,
Get, Get,
@ -190,21 +195,35 @@ export class PortfolioController {
} }
} }
const isBasicUser = let hasDetails = true;
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
this.request.user.subscription.type === 'Basic'; hasDetails = this.request.user.subscription.type === 'Premium';
}
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
holdings[symbol] = {
...portfolioPosition,
assetClass: hasDetails ? portfolioPosition.assetClass : undefined,
assetSubClass: hasDetails ? portfolioPosition.assetSubClass : undefined,
countries: hasDetails ? portfolioPosition.countries : [],
currency: hasDetails ? portfolioPosition.currency : undefined,
markets: hasDetails ? portfolioPosition.markets : undefined,
sectors: hasDetails ? portfolioPosition.sectors : []
};
}
return { return {
accounts, accounts,
hasError, hasError,
holdings: isBasicUser ? {} : holdings holdings
}; };
} }
@Get('investments') @Get('investments')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getInvestments( public async getInvestments(
@Headers('impersonation-id') impersonationId: string @Headers('impersonation-id') impersonationId: string,
@Query('groupBy') groupBy?: GroupBy
): Promise<PortfolioInvestments> { ): Promise<PortfolioInvestments> {
if ( if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
@ -216,9 +235,16 @@ export class PortfolioController {
); );
} }
let investments = await this.portfolioService.getInvestments( let investments: InvestmentItem[];
impersonationId
); if (groupBy === 'month') {
investments = await this.portfolioService.getInvestments(
impersonationId,
'month'
);
} else {
investments = await this.portfolioService.getInvestments(impersonationId);
}
if ( if (
impersonationId || impersonationId ||
@ -316,7 +342,9 @@ export class PortfolioController {
const { holdings } = await this.portfolioService.getDetails( const { holdings } = await this.portfolioService.getDetails(
access.userId, access.userId,
access.userId access.userId,
'max',
[{ id: 'EQUITY', type: 'ASSET_CLASS' }]
); );
const portfolioPublicDetails: PortfolioPublicDetails = { const portfolioPublicDetails: PortfolioPublicDetails = {
@ -325,9 +353,6 @@ export class PortfolioController {
}; };
const totalValue = Object.values(holdings) const totalValue = Object.values(holdings)
.filter((holding) => {
return holding.assetClass === 'EQUITY';
})
.map((portfolioPosition) => { .map((portfolioPosition) => {
return this.exchangeRateDataService.toCurrency( return this.exchangeRateDataService.toCurrency(
portfolioPosition.quantity * portfolioPosition.marketPrice, portfolioPosition.quantity * portfolioPosition.marketPrice,
@ -338,17 +363,18 @@ export class PortfolioController {
.reduce((a, b) => a + b, 0); .reduce((a, b) => a + b, 0);
for (const [symbol, portfolioPosition] of Object.entries(holdings)) { for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
if (portfolioPosition.assetClass === 'EQUITY') { portfolioPublicDetails.holdings[symbol] = {
portfolioPublicDetails.holdings[symbol] = { allocationCurrent: portfolioPosition.value / totalValue,
allocationCurrent: portfolioPosition.allocationCurrent, countries: hasDetails ? portfolioPosition.countries : [],
countries: hasDetails ? portfolioPosition.countries : [], currency: hasDetails ? portfolioPosition.currency : undefined,
currency: portfolioPosition.currency, markets: hasDetails ? portfolioPosition.markets : undefined,
markets: portfolioPosition.markets, name: portfolioPosition.name,
name: portfolioPosition.name, netPerformancePercent: portfolioPosition.netPerformancePercent,
sectors: hasDetails ? portfolioPosition.sectors : [], sectors: hasDetails ? portfolioPosition.sectors : [],
value: portfolioPosition.value / totalValue symbol: portfolioPosition.symbol,
}; url: portfolioPosition.url,
} value: portfolioPosition.value / totalValue
};
} }
return portfolioPublicDetails; return portfolioPublicDetails;

View File

@ -41,6 +41,7 @@ import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.in
import type { import type {
AccountWithValue, AccountWithValue,
DateRange, DateRange,
GroupBy,
Market, Market,
OrderWithAccount, OrderWithAccount,
RequestWithUser RequestWithUser
@ -50,6 +51,7 @@ import { REQUEST } from '@nestjs/core';
import { import {
AssetClass, AssetClass,
DataSource, DataSource,
Prisma,
Tag, Tag,
Type as TypeOfOrder Type as TypeOfOrder
} from '@prisma/client'; } from '@prisma/client';
@ -63,6 +65,7 @@ import {
max, max,
parse, parse,
parseISO, parseISO,
set,
setDayOfYear, setDayOfYear,
startOfDay, startOfDay,
subDays, subDays,
@ -100,14 +103,23 @@ export class PortfolioService {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY'); this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
} }
public async getAccounts(aUserId: string): Promise<AccountWithValue[]> { public async getAccounts(
aUserId: string,
aFilters?: Filter[]
): Promise<AccountWithValue[]> {
const where: Prisma.AccountWhereInput = { userId: aUserId };
if (aFilters?.[0].id && aFilters?.[0].type === 'ACCOUNT') {
where.id = aFilters[0].id;
}
const [accounts, details] = await Promise.all([ const [accounts, details] = await Promise.all([
this.accountService.accounts({ this.accountService.accounts({
where,
include: { Order: true, Platform: true }, include: { Order: true, Platform: true },
orderBy: { name: 'asc' }, orderBy: { name: 'asc' }
where: { userId: aUserId }
}), }),
this.getDetails(aUserId, aUserId) this.getDetails(aUserId, aUserId, undefined, aFilters)
]); ]);
const userCurrency = this.request.user.Settings.currency; const userCurrency = this.request.user.Settings.currency;
@ -145,8 +157,11 @@ export class PortfolioService {
}); });
} }
public async getAccountsWithAggregations(aUserId: string): Promise<Accounts> { public async getAccountsWithAggregations(
const accounts = await this.getAccounts(aUserId); aUserId: string,
aFilters?: Filter[]
): Promise<Accounts> {
const accounts = await this.getAccounts(aUserId, aFilters);
let totalBalanceInBaseCurrency = new Big(0); let totalBalanceInBaseCurrency = new Big(0);
let totalValueInBaseCurrency = new Big(0); let totalValueInBaseCurrency = new Big(0);
let transactionCount = 0; let transactionCount = 0;
@ -170,7 +185,8 @@ export class PortfolioService {
} }
public async getInvestments( public async getInvestments(
aImpersonationId: string aImpersonationId: string,
groupBy?: GroupBy
): Promise<InvestmentItem[]> { ): Promise<InvestmentItem[]> {
const userId = await this.getUserId(aImpersonationId, this.request.user.id); const userId = await this.getUserId(aImpersonationId, this.request.user.id);
@ -191,28 +207,57 @@ export class PortfolioService {
return []; return [];
} }
const investments = portfolioCalculator.getInvestments().map((item) => { let investments: InvestmentItem[];
return {
date: item.date,
investment: item.investment.toNumber()
};
});
// Add investment of today if (groupBy === 'month') {
const investmentOfToday = investments.filter((investment) => { investments = portfolioCalculator.getInvestmentsByMonth().map((item) => {
return investment.date === format(new Date(), DATE_FORMAT); return {
}); date: item.date,
investment: item.investment.toNumber()
if (investmentOfToday.length <= 0) { };
const pastInvestments = investments.filter((investment) => {
return isBefore(parseDate(investment.date), new Date());
}); });
const lastInvestment = pastInvestments[pastInvestments.length - 1];
investments.push({ // Add investment of current month
date: format(new Date(), DATE_FORMAT), const dateOfCurrentMonth = format(
investment: lastInvestment?.investment ?? 0 set(new Date(), { date: 1 }),
DATE_FORMAT
);
const investmentOfCurrentMonth = investments.filter(({ date }) => {
return date === dateOfCurrentMonth;
}); });
if (investmentOfCurrentMonth.length <= 0) {
investments.push({
date: dateOfCurrentMonth,
investment: 0
});
}
} else {
investments = portfolioCalculator
.getInvestments()
.map(({ date, investment }) => {
return {
date,
investment: investment.toNumber()
};
});
// Add investment of today
const investmentOfToday = investments.filter(({ date }) => {
return date === format(new Date(), DATE_FORMAT);
});
if (investmentOfToday.length <= 0) {
const pastInvestments = investments.filter(({ date }) => {
return isBefore(parseDate(date), new Date());
});
const lastInvestment = pastInvestments[pastInvestments.length - 1];
investments.push({
date: format(new Date(), DATE_FORMAT),
investment: lastInvestment?.investment ?? 0
});
}
} }
return sortBy(investments, (investment) => { return sortBy(investments, (investment) => {
@ -273,7 +318,6 @@ export class PortfolioService {
.filter((timelineItem) => timelineItem !== null) .filter((timelineItem) => timelineItem !== null)
.map((timelineItem) => ({ .map((timelineItem) => ({
date: timelineItem.date, date: timelineItem.date,
marketPrice: timelineItem.value,
value: timelineItem.netPerformance.toNumber() value: timelineItem.netPerformance.toNumber()
})); }));
@ -394,7 +438,7 @@ export class PortfolioService {
continue; continue;
} }
const value = item.quantity.mul(item.marketPrice); const value = item.quantity.mul(item.marketPrice ?? 0);
const symbolProfile = symbolProfileMap[item.symbol]; const symbolProfile = symbolProfileMap[item.symbol];
const dataProviderResponse = dataProviderResponses[item.symbol]; const dataProviderResponse = dataProviderResponses[item.symbol];
@ -442,6 +486,7 @@ export class PortfolioService {
sectors: symbolProfile.sectors, sectors: symbolProfile.sectors,
symbol: item.symbol, symbol: item.symbol,
transactionCount: item.transactionCount, transactionCount: item.transactionCount,
url: symbolProfile.url,
value: value.toNumber() value: value.toNumber()
}; };
} }
@ -658,7 +703,7 @@ export class PortfolioService {
netPerformancePercent: position.netPerformancePercentage?.toNumber(), netPerformancePercent: position.netPerformancePercentage?.toNumber(),
quantity: quantity.toNumber(), quantity: quantity.toNumber(),
value: this.exchangeRateDataService.toCurrency( value: this.exchangeRateDataService.toCurrency(
quantity.mul(marketPrice).toNumber(), quantity.mul(marketPrice ?? 0).toNumber(),
currency, currency,
userCurrency userCurrency
) )
@ -1290,6 +1335,10 @@ export class PortfolioService {
if (filters.length === 0) { if (filters.length === 0) {
currentAccounts = await this.accountService.getAccounts(userId); currentAccounts = await this.accountService.getAccounts(userId);
} else if (filters.length === 1 && filters[0].type === 'ACCOUNT') {
currentAccounts = await this.accountService.accounts({
where: { id: filters[0].id }
});
} else { } else {
const accountIds = uniq( const accountIds = uniq(
orders.map(({ accountId }) => { orders.map(({ accountId }) => {

View File

@ -46,7 +46,6 @@ export class SymbolController {
* Must be after /lookup * Must be after /lookup
*/ */
@Get(':dataSource/:symbol') @Get(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getSymbolData( public async getSymbolData(

View File

@ -12,6 +12,7 @@ import {
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/permissions';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Prisma, Role, User, ViewMode } from '@prisma/client'; import { Prisma, Role, User, ViewMode } from '@prisma/client';
import { sortBy } from 'lodash';
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';
@ -35,14 +36,7 @@ export class UserService {
} }
public async getUser( public async getUser(
{ { Account, id, permissions, Settings, subscription }: UserWithSettings,
Account,
alias,
id,
permissions,
Settings,
subscription
}: UserWithSettings,
aLocale = locale aLocale = locale
): Promise<IUser> { ): Promise<IUser> {
const access = await this.prismaService.access.findMany({ const access = await this.prismaService.access.findMany({
@ -62,7 +56,6 @@ export class UserService {
} }
return { return {
alias,
id, id,
permissions, permissions,
subscription, subscription,
@ -157,10 +150,6 @@ export class UserService {
let currentPermissions = getPermissions(user.role); let currentPermissions = getPermissions(user.role);
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
currentPermissions.push(permissions.accessFearAndGreedIndex);
}
if (user.subscription?.type === 'Premium') { if (user.subscription?.type === 'Premium') {
currentPermissions.push(permissions.reportDataGlitch); currentPermissions.push(permissions.reportDataGlitch);
} }
@ -185,6 +174,9 @@ export class UserService {
} }
} }
user.Account = sortBy(user.Account, (account) => {
return account.name;
});
user.permissions = currentPermissions.sort(); user.permissions = currentPermissions.sort();
return user; return user;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,7 @@
{
"LUNA1": "Terra",
"LUNA2": "Terra",
"SGB1": "Songbird",
"UNI1": "Uniswap",
"UST": "TerraUSD"
}

View File

@ -20,10 +20,11 @@ async function bootstrap() {
}) })
); );
const host = process.env.HOST || '0.0.0.0';
const port = process.env.PORT || 3333; const port = process.env.PORT || 3333;
await app.listen(port, () => { await app.listen(port, host, () => {
logLogo(); logLogo();
Logger.log(`Listening at http://localhost:${port}`); Logger.log(`Listening at http://${host}:${port}`);
Logger.log(''); Logger.log('');
}); });
} }

View File

@ -15,7 +15,7 @@ export class ConfigurationService {
BASE_CURRENCY: str({ default: 'USD' }), BASE_CURRENCY: str({ default: 'USD' }),
CACHE_TTL: num({ default: 1 }), CACHE_TTL: num({ default: 1 }),
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }), DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }), DATA_SOURCES: json({ default: [DataSource.YAHOO] }),
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 }),
@ -31,12 +31,13 @@ export class ConfigurationService {
GOOGLE_SHEETS_ACCOUNT: str({ default: '' }), GOOGLE_SHEETS_ACCOUNT: str({ default: '' }),
GOOGLE_SHEETS_ID: str({ default: '' }), GOOGLE_SHEETS_ID: str({ default: '' }),
GOOGLE_SHEETS_PRIVATE_KEY: str({ default: '' }), GOOGLE_SHEETS_PRIVATE_KEY: str({ default: '' }),
HOST: host({ default: '0.0.0.0' }),
JWT_SECRET_KEY: str({}), JWT_SECRET_KEY: str({}),
MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
MAX_ITEM_IN_CACHE: num({ default: 9999 }), MAX_ITEM_IN_CACHE: num({ default: 9999 }),
MAX_ORDERS_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
PORT: port({ default: 3333 }), PORT: port({ default: 3333 }),
RAKUTEN_RAPID_API_KEY: str({ default: '' }), RAKUTEN_RAPID_API_KEY: str({ default: '' }),
REDIS_HOST: str({ default: 'localhost' }), REDIS_HOST: host({ default: 'localhost' }),
REDIS_PASSWORD: str({ default: '' }), REDIS_PASSWORD: str({ default: '' }),
REDIS_PORT: port({ default: 6379 }), REDIS_PORT: port({ default: 6379 }),
ROOT_URL: str({ default: 'http://localhost:4200' }), ROOT_URL: str({ default: 'http://localhost:4200' }),

View File

@ -1,11 +1,9 @@
import { import {
DATA_GATHERING_QUEUE, GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { InjectQueue } from '@nestjs/bull';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule'; import { Cron, CronExpression } from '@nestjs/schedule';
import { Queue } from 'bull';
import { DataGatheringService } from './data-gathering.service'; import { DataGatheringService } from './data-gathering.service';
import { ExchangeRateDataService } from './exchange-rate-data.service'; import { ExchangeRateDataService } from './exchange-rate-data.service';
@ -14,15 +12,13 @@ import { TwitterBotService } from './twitter-bot/twitter-bot.service';
@Injectable() @Injectable()
export class CronService { export class CronService {
public constructor( public constructor(
@InjectQueue(DATA_GATHERING_QUEUE)
private readonly dataGatheringQueue: Queue,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly twitterBotService: TwitterBotService private readonly twitterBotService: TwitterBotService
) {} ) {}
@Cron(CronExpression.EVERY_MINUTE) @Cron(CronExpression.EVERY_HOUR)
public async runEveryMinute() { public async runEveryHour() {
await this.dataGatheringService.gather7Days(); await this.dataGatheringService.gather7Days();
} }
@ -41,10 +37,14 @@ export class CronService {
const uniqueAssets = await this.dataGatheringService.getUniqueAssets(); const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
for (const { dataSource, symbol } of uniqueAssets) { for (const { dataSource, symbol } of uniqueAssets) {
await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, { await this.dataGatheringService.addJobToQueue(
dataSource, GATHER_ASSET_PROFILE_PROCESS,
symbol {
}); dataSource,
symbol
},
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
);
} }
} }
} }

View File

@ -1,8 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
const cryptocurrencies = require('cryptocurrencies'); const cryptocurrencies = require('../../assets/cryptocurrencies/cryptocurrencies.json');
const customCryptocurrencies = require('../../assets/cryptocurrencies/custom.json');
const customCryptocurrencies = require('./custom-cryptocurrencies.json');
@Injectable() @Injectable()
export class CryptocurrencyService { export class CryptocurrencyService {
@ -18,7 +17,7 @@ export class CryptocurrencyService {
private getCryptocurrencies() { private getCryptocurrencies() {
if (!this.combinedCryptocurrencies) { if (!this.combinedCryptocurrencies) {
this.combinedCryptocurrencies = [ this.combinedCryptocurrencies = [
...cryptocurrencies.symbols(), ...Object.keys(cryptocurrencies),
...Object.keys(customCryptocurrencies) ...Object.keys(customCryptocurrencies)
]; ];
} }

View File

@ -1,14 +0,0 @@
{
"1INCH": "1inch",
"ALGO": "Algorand",
"ATOM": "Cosmos",
"AVAX": "Avalanche",
"DOT": "Polkadot",
"LUNA1": "Terra",
"MATIC": "Polygon",
"MINA": "Mina Protocol",
"RUNE": "THORChain",
"SHIB": "Shiba Inu",
"SOL": "Solana",
"UNI3": "Uniswap"
}

View File

@ -6,20 +6,27 @@ import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { DATA_GATHERING_QUEUE } from '@ghostfolio/common/config'; import { DATA_GATHERING_QUEUE } from '@ghostfolio/common/config';
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import ms from 'ms';
import { DataGatheringProcessor } from './data-gathering.processor'; import { DataGatheringProcessor } from './data-gathering.processor';
import { ExchangeRateDataModule } from './exchange-rate-data.module'; import { ExchangeRateDataModule } from './exchange-rate-data.module';
import { MarketDataModule } from './market-data.module';
import { SymbolProfileModule } from './symbol-profile.module'; import { SymbolProfileModule } from './symbol-profile.module';
@Module({ @Module({
imports: [ imports: [
BullModule.registerQueue({ BullModule.registerQueue({
limiter: {
duration: ms('5 seconds'),
max: 1
},
name: DATA_GATHERING_QUEUE name: DATA_GATHERING_QUEUE
}), }),
ConfigurationModule, ConfigurationModule,
DataEnhancerModule, DataEnhancerModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
MarketDataModule,
PrismaModule, PrismaModule,
SymbolProfileModule SymbolProfileModule
], ],

View File

@ -1,19 +1,34 @@
import { import {
DATA_GATHERING_QUEUE, DATA_GATHERING_QUEUE,
GATHER_ASSET_PROFILE_PROCESS GATHER_ASSET_PROFILE_PROCESS,
GATHER_HISTORICAL_MARKET_DATA_PROCESS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { Process, Processor } from '@nestjs/bull'; import { Process, Processor } from '@nestjs/bull';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Job } from 'bull'; import { Job } from 'bull';
import {
format,
getDate,
getMonth,
getYear,
isBefore,
parseISO
} from 'date-fns';
import { DataGatheringService } from './data-gathering.service'; import { DataGatheringService } from './data-gathering.service';
import { DataProviderService } from './data-provider/data-provider.service';
import { IDataGatheringItem } from './interfaces/interfaces';
import { PrismaService } from './prisma.service';
@Injectable() @Injectable()
@Processor(DATA_GATHERING_QUEUE) @Processor(DATA_GATHERING_QUEUE)
export class DataGatheringProcessor { export class DataGatheringProcessor {
public constructor( public constructor(
private readonly dataGatheringService: DataGatheringService private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService,
private readonly prismaService: PrismaService
) {} ) {}
@Process(GATHER_ASSET_PROFILE_PROCESS) @Process(GATHER_ASSET_PROFILE_PROCESS)
@ -21,7 +36,93 @@ export class DataGatheringProcessor {
try { try {
await this.dataGatheringService.gatherAssetProfiles([job.data]); await this.dataGatheringService.gatherAssetProfiles([job.data]);
} catch (error) { } catch (error) {
Logger.error(error, 'DataGatheringProcessor'); Logger.error(
error,
`DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS})`
);
throw new Error(error);
}
}
@Process(GATHER_HISTORICAL_MARKET_DATA_PROCESS)
public async gatherHistoricalMarketData(job: Job<IDataGatheringItem>) {
try {
const { dataSource, date, symbol } = job.data;
const historicalData = await this.dataProviderService.getHistoricalRaw(
[{ dataSource, symbol }],
parseISO(<string>(<unknown>date)),
new Date()
);
let currentDate = parseISO(<string>(<unknown>date));
let lastMarketPrice: number;
while (
isBefore(
currentDate,
new Date(
Date.UTC(
getYear(new Date()),
getMonth(new Date()),
getDate(new Date()),
0
)
)
)
) {
if (
historicalData[symbol]?.[format(currentDate, DATE_FORMAT)]
?.marketPrice
) {
lastMarketPrice =
historicalData[symbol]?.[format(currentDate, DATE_FORMAT)]
?.marketPrice;
}
if (lastMarketPrice) {
try {
await this.prismaService.marketData.create({
data: {
dataSource,
symbol,
date: new Date(
Date.UTC(
getYear(currentDate),
getMonth(currentDate),
getDate(currentDate),
0
)
),
marketPrice: lastMarketPrice
}
});
} catch {}
}
// Count month one up for iteration
currentDate = new Date(
Date.UTC(
getYear(currentDate),
getMonth(currentDate),
getDate(currentDate) + 1,
0
)
);
}
Logger.log(
`Historical market data gathering has been completed for ${symbol} (${dataSource}).`,
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS})`
);
} catch (error) {
Logger.error(
error,
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS})`
);
throw new Error(error);
} }
} }
} }

View File

@ -1,191 +1,72 @@
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { import {
PROPERTY_LAST_DATA_GATHERING, DATA_GATHERING_QUEUE,
PROPERTY_LOCKED_DATA_GATHERING GATHER_HISTORICAL_MARKET_DATA_PROCESS,
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
QUEUE_JOB_STATUS_LIST
} 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 { UniqueAsset } from '@ghostfolio/common/interfaces'; import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { InjectQueue } from '@nestjs/bull';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { import { JobOptions, Queue } from 'bull';
differenceInHours, import { format, subDays } from 'date-fns';
format,
getDate,
getMonth,
getYear,
isBefore,
subDays
} from 'date-fns';
import { DataProviderService } from './data-provider/data-provider.service'; import { DataProviderService } from './data-provider/data-provider.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';
import { MarketDataService } from './market-data.service';
import { PrismaService } from './prisma.service'; import { PrismaService } from './prisma.service';
@Injectable() @Injectable()
export class DataGatheringService { export class DataGatheringService {
private dataGatheringProgress: number;
public constructor( public constructor(
@Inject('DataEnhancers') @Inject('DataEnhancers')
private readonly dataEnhancers: DataEnhancerInterface[], private readonly dataEnhancers: DataEnhancerInterface[],
@InjectQueue(DATA_GATHERING_QUEUE)
private readonly dataGatheringQueue: Queue,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) {} ) {}
public async gather7Days() { public async addJobToQueue(name: string, data: any, options?: JobOptions) {
const isDataGatheringNeeded = await this.isDataGatheringNeeded(); const hasJob = await this.hasJob(name, data);
if (isDataGatheringNeeded) {
Logger.log('7d data gathering has been started.', 'DataGatheringService');
console.time('data-gathering-7d');
await this.prismaService.property.create({
data: {
key: PROPERTY_LOCKED_DATA_GATHERING,
value: new Date().toISOString()
}
});
const symbols = await this.getSymbols7D();
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, 'DataGatheringService');
}
await this.prismaService.property.delete({
where: {
key: PROPERTY_LOCKED_DATA_GATHERING
}
});
if (hasJob) {
Logger.log( Logger.log(
'7d data gathering has been completed.', `Job ${name} with data ${JSON.stringify(data)} already exists.`,
'DataGatheringService' 'DataGatheringService'
); );
console.timeEnd('data-gathering-7d'); } else {
return this.dataGatheringQueue.add(name, data, options);
} }
} }
public async gather7Days() {
const dataGatheringItems = await this.getSymbols7D();
await this.gatherSymbols(dataGatheringItems);
}
public async gatherMax() { public async gatherMax() {
const isDataGatheringLocked = await this.prismaService.property.findUnique({ const dataGatheringItems = await this.getSymbolsMax();
where: { key: PROPERTY_LOCKED_DATA_GATHERING } await this.gatherSymbols(dataGatheringItems);
});
if (!isDataGatheringLocked) {
Logger.log(
'Max data gathering has been started.',
'DataGatheringService'
);
console.time('data-gathering-max');
await this.prismaService.property.create({
data: {
key: PROPERTY_LOCKED_DATA_GATHERING,
value: new Date().toISOString()
}
});
const symbols = await this.getSymbolsMax();
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, 'DataGatheringService');
}
await this.prismaService.property.delete({
where: {
key: PROPERTY_LOCKED_DATA_GATHERING
}
});
Logger.log(
'Max data gathering has been completed.',
'DataGatheringService'
);
console.timeEnd('data-gathering-max');
}
} }
public async gatherSymbol({ dataSource, symbol }: UniqueAsset) { public async gatherSymbol({ dataSource, symbol }: UniqueAsset) {
const isDataGatheringLocked = await this.prismaService.property.findUnique({ await this.marketDataService.deleteMany({ dataSource, symbol });
where: { key: PROPERTY_LOCKED_DATA_GATHERING }
const symbols = (await this.getSymbolsMax()).filter((dataGatheringItem) => {
return (
dataGatheringItem.dataSource === dataSource &&
dataGatheringItem.symbol === symbol
);
}); });
await this.gatherSymbols(symbols);
if (!isDataGatheringLocked) {
Logger.log(
`Symbol data gathering for ${symbol} has been started.`,
'DataGatheringService'
);
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, 'DataGatheringService');
}
await this.prismaService.property.delete({
where: {
key: PROPERTY_LOCKED_DATA_GATHERING
}
});
Logger.log(
`Symbol data gathering for ${symbol} has been completed.`,
'DataGatheringService'
);
console.timeEnd('data-gathering-symbol');
}
} }
public async gatherSymbolForDate({ public async gatherSymbolForDate({
@ -235,15 +116,6 @@ export class DataGatheringService {
uniqueAssets = await this.getUniqueAssets(); uniqueAssets = await this.getUniqueAssets();
} }
Logger.log(
`Asset profile data gathering has been started for ${uniqueAssets
.map(({ dataSource, symbol }) => {
return `${symbol} (${dataSource})`;
})
.join(',')}.`,
'DataGatheringService'
);
const assetProfiles = await this.dataProviderService.getAssetProfiles( const assetProfiles = await this.dataProviderService.getAssetProfiles(
uniqueAssets uniqueAssets
); );
@ -334,136 +206,21 @@ export class DataGatheringService {
} }
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) { public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
let hasError = false;
let symbolCounter = 0;
for (const { dataSource, date, symbol } of aSymbolsWithStartDate) { for (const { dataSource, date, symbol } of aSymbolsWithStartDate) {
if (dataSource === 'MANUAL') { if (dataSource === 'MANUAL') {
continue; continue;
} }
this.dataGatheringProgress = symbolCounter / aSymbolsWithStartDate.length; await this.addJobToQueue(
GATHER_HISTORICAL_MARKET_DATA_PROCESS,
try { {
const historicalData = await this.dataProviderService.getHistoricalRaw( dataSource,
[{ dataSource, symbol }],
date, date,
new Date() symbol
); },
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS
let currentDate = date; );
let lastMarketPrice: number;
while (
isBefore(
currentDate,
new Date(
Date.UTC(
getYear(new Date()),
getMonth(new Date()),
getDate(new Date()),
0
)
)
)
) {
if (
historicalData[symbol]?.[format(currentDate, DATE_FORMAT)]
?.marketPrice
) {
lastMarketPrice =
historicalData[symbol]?.[format(currentDate, DATE_FORMAT)]
?.marketPrice;
}
if (lastMarketPrice) {
try {
await this.prismaService.marketData.create({
data: {
dataSource,
symbol,
date: new Date(
Date.UTC(
getYear(currentDate),
getMonth(currentDate),
getDate(currentDate),
0
)
),
marketPrice: lastMarketPrice
}
});
} catch {}
} else {
Logger.warn(
`Failed to gather data for symbol ${symbol} from ${dataSource} at ${format(
currentDate,
DATE_FORMAT
)}.`,
'DataGatheringService'
);
}
// Count month one up for iteration
currentDate = new Date(
Date.UTC(
getYear(currentDate),
getMonth(currentDate),
getDate(currentDate) + 1,
0
)
);
}
} catch (error) {
hasError = true;
Logger.error(error, 'DataGatheringService');
}
if (symbolCounter > 0 && symbolCounter % 100 === 0) {
Logger.log(
`Data gathering progress: ${(
this.dataGatheringProgress * 100
).toFixed(2)}%`,
'DataGatheringService'
);
}
symbolCounter += 1;
} }
await this.exchangeRateDataService.initialize();
if (hasError) {
throw '';
}
}
public async getDataGatheringProgress() {
const isInProgress = await this.getIsInProgress();
if (isInProgress) {
return this.dataGatheringProgress;
}
return undefined;
}
public async getIsInProgress() {
return await this.prismaService.property.findUnique({
where: { key: PROPERTY_LOCKED_DATA_GATHERING }
});
}
public async getLastDataGathering() {
const lastDataGathering = await this.prismaService.property.findUnique({
where: { key: PROPERTY_LAST_DATA_GATHERING }
});
if (lastDataGathering?.value) {
return new Date(lastDataGathering.value);
}
return undefined;
} }
public async getSymbolsMax(): Promise<IDataGatheringItem[]> { public async getSymbolsMax(): Promise<IDataGatheringItem[]> {
@ -534,19 +291,6 @@ export class DataGatheringService {
}); });
} }
public async reset() {
Logger.log('Data gathering has been reset.', 'DataGatheringService');
await this.prismaService.property.deleteMany({
where: {
OR: [
{ key: PROPERTY_LAST_DATA_GATHERING },
{ key: PROPERTY_LOCKED_DATA_GATHERING }
]
}
});
}
private async getSymbols7D(): Promise<IDataGatheringItem[]> { private async getSymbols7D(): Promise<IDataGatheringItem[]> {
const startDate = subDays(resetHours(new Date()), 7); const startDate = subDays(resetHours(new Date()), 7);
@ -610,15 +354,17 @@ export class DataGatheringService {
return [...currencyPairsToGather, ...symbolProfilesToGather]; return [...currencyPairsToGather, ...symbolProfilesToGather];
} }
private async isDataGatheringNeeded() { private async hasJob(name: string, data: any) {
const lastDataGathering = await this.getLastDataGathering(); const jobs = await this.dataGatheringQueue.getJobs(
QUEUE_JOB_STATUS_LIST.filter((status) => {
return status !== 'completed';
})
);
const isDataGatheringLocked = await this.prismaService.property.findUnique({ return jobs.some((job) => {
where: { key: PROPERTY_LOCKED_DATA_GATHERING } return (
job.name === name && JSON.stringify(job.data) === JSON.stringify(data)
);
}); });
const diffInHours = differenceInHours(new Date(), lastDataGathering);
return (diffInHours >= 1 || !lastDataGathering) && !isDataGatheringLocked;
} }
} }

View File

@ -9,7 +9,7 @@ import { DATE_FORMAT } 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, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import { isAfter, isBefore, parse } from 'date-fns'; import { format, isAfter, isBefore, parse } from 'date-fns';
import { IAlphaVantageHistoricalResponse } from './interfaces/interfaces'; import { IAlphaVantageHistoricalResponse } from './interfaces/interfaces';
@ -76,9 +76,12 @@ export class AlphaVantageService implements DataProviderInterface {
return response; return response;
} catch (error) { } catch (error) {
Logger.error(error, 'AlphaVantageService'); throw new Error(
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
return {}; from,
DATE_FORMAT
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
);
} }
} }

View File

@ -72,10 +72,13 @@ export class EodHistoricalDataService implements DataProviderInterface {
{ [aSymbol]: {} } { [aSymbol]: {} }
); );
} catch (error) { } catch (error) {
Logger.error(error, 'EodHistoricalDataService'); throw new Error(
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
from,
DATE_FORMAT
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
);
} }
return {};
} }
public getName(): DataSource { public getName(): DataSource {

View File

@ -87,10 +87,13 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
} }
}; };
} catch (error) { } catch (error) {
Logger.error(error, 'GhostfolioScraperApiService'); throw new Error(
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
from,
DATE_FORMAT
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
);
} }
return {};
} }
public getName(): DataSource { public getName(): DataSource {

View File

@ -71,10 +71,13 @@ export class GoogleSheetsService implements DataProviderInterface {
[symbol]: historicalData [symbol]: historicalData
}; };
} catch (error) { } catch (error) {
Logger.error(error, 'GoogleSheetsService'); throw new Error(
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
from,
DATE_FORMAT
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
);
} }
return {};
} }
public getName(): DataSource { public getName(): DataSource {

View File

@ -90,7 +90,14 @@ export class RakutenRapidApiService implements DataProviderInterface {
} }
}; };
} }
} catch (error) {} } catch (error) {
throw new Error(
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
from,
DATE_FORMAT
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
);
}
return {}; return {};
} }

View File

@ -37,10 +37,15 @@ export class YahooFinanceService implements DataProviderInterface {
} }
public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) { public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) {
const symbol = aYahooFinanceSymbol.replace( let symbol = aYahooFinanceSymbol.replace(
new RegExp(`-${this.baseCurrency}$`), new RegExp(`-${this.baseCurrency}$`),
this.baseCurrency this.baseCurrency
); );
if (symbol.includes('=X') && !symbol.includes(this.baseCurrency)) {
symbol = `${this.baseCurrency}${symbol}`;
}
return symbol.replace('=X', ''); return symbol.replace('=X', '');
} }
@ -131,7 +136,13 @@ export class YahooFinanceService implements DataProviderInterface {
if (url) { if (url) {
response.url = url; response.url = url;
} }
} catch {} } catch (error) {
throw new Error(
`Could not get asset profile for ${aSymbol} (${this.getName()}): [${
error.name
}] ${error.message}`
);
}
return response; return response;
} }
@ -175,6 +186,9 @@ export class YahooFinanceService implements DataProviderInterface {
if (symbol === 'USDGBp') { if (symbol === 'USDGBp') {
// Convert GPB to GBp (pence) // Convert GPB to GBp (pence)
marketPrice = new Big(marketPrice).mul(100).toNumber(); marketPrice = new Big(marketPrice).mul(100).toNumber();
} else if (symbol === 'USDILA') {
// Convert ILS to ILA
marketPrice = new Big(marketPrice).mul(100).toNumber();
} }
response[symbol][format(historicalItem.date, DATE_FORMAT)] = { response[symbol][format(historicalItem.date, DATE_FORMAT)] = {
@ -185,12 +199,12 @@ export class YahooFinanceService implements DataProviderInterface {
return response; return response;
} catch (error) { } catch (error) {
Logger.warn( throw new Error(
`Skipping yahooFinance2.getHistorical("${aSymbol}"): [${error.name}] ${error.message}`, `Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
'YahooFinanceService' from,
DATE_FORMAT
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
); );
return {};
} }
} }
@ -237,9 +251,31 @@ export class YahooFinanceService implements DataProviderInterface {
.mul(100) .mul(100)
.toNumber() .toNumber()
}; };
} else if (
symbol === 'USDILS' &&
yahooFinanceSymbols.includes('USDILA=X')
) {
// Convert ILS to ILA
response['USDILA'] = {
...response[symbol],
currency: 'ILA',
marketPrice: new Big(response[symbol].marketPrice)
.mul(100)
.toNumber()
};
} }
} }
if (yahooFinanceSymbols.includes('USDUSX=X')) {
// Convert USD to USX (cent)
response['USDUSX'] = {
currency: 'USX',
dataSource: this.getName(),
marketPrice: new Big(1).mul(100).toNumber(),
marketState: 'open'
};
}
return response; return response;
} catch (error) { } catch (error) {
Logger.error(error, 'YahooFinanceService'); Logger.error(error, 'YahooFinanceService');

View File

@ -122,15 +122,6 @@ export class ExchangeRateDataService {
return 0; return 0;
} }
const hasNaN = Object.values(this.exchangeRates).some((exchangeRate) => {
return isNaN(exchangeRate);
});
if (hasNaN) {
// Reinitialize if data is not loaded correctly
this.initialize();
}
let factor = 1; let factor = 1;
if (aFromCurrency !== aToCurrency) { if (aFromCurrency !== aToCurrency) {

View File

@ -6,7 +6,7 @@ export interface Environment extends CleanedEnvAccessors {
BASE_CURRENCY: string; BASE_CURRENCY: string;
CACHE_TTL: number; CACHE_TTL: number;
DATA_SOURCE_PRIMARY: string; DATA_SOURCE_PRIMARY: string;
DATA_SOURCES: string | string[]; // string is not correct, error in envalid? DATA_SOURCES: string[];
ENABLE_FEATURE_BLOG: boolean; ENABLE_FEATURE_BLOG: boolean;
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean; ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean; ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
@ -23,8 +23,8 @@ export interface Environment extends CleanedEnvAccessors {
GOOGLE_SHEETS_ID: string; GOOGLE_SHEETS_ID: string;
GOOGLE_SHEETS_PRIVATE_KEY: string; GOOGLE_SHEETS_PRIVATE_KEY: string;
JWT_SECRET_KEY: string; JWT_SECRET_KEY: string;
MAX_ACTIVITIES_TO_IMPORT: number;
MAX_ITEM_IN_CACHE: number; MAX_ITEM_IN_CACHE: number;
MAX_ORDERS_TO_IMPORT: number;
PORT: number; PORT: number;
RAKUTEN_RAPID_API_KEY: string; RAKUTEN_RAPID_API_KEY: string;
REDIS_HOST: string; REDIS_HOST: string;

View File

@ -1,3 +1,4 @@
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { MarketState } from '@ghostfolio/common/types'; import { MarketState } from '@ghostfolio/common/types';
import { import {
Account, Account,
@ -32,8 +33,6 @@ export interface IDataProviderResponse {
marketState: MarketState; marketState: MarketState;
} }
export interface IDataGatheringItem { export interface IDataGatheringItem extends UniqueAsset {
dataSource: DataSource;
date?: Date; date?: Date;
symbol: string;
} }

View File

@ -1,4 +1,4 @@
module.exports = { export default {
displayName: 'client', displayName: 'client',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'], setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
@ -18,5 +18,5 @@ module.exports = {
'^.+.(ts|mjs|js|html)$': 'jest-preset-angular' '^.+.(ts|mjs|js|html)$': 'jest-preset-angular'
}, },
transformIgnorePatterns: ['node_modules/(?!.*.mjs$)'], transformIgnorePatterns: ['node_modules/(?!.*.mjs$)'],
preset: '../../jest.preset.ts' preset: '../../jest.preset.js'
}; };

View File

@ -5,9 +5,6 @@ import { getDateFormatString } from '@ghostfolio/common/helper';
import { format, parse } from 'date-fns'; import { format, parse } from 'date-fns';
export class CustomDateAdapter extends NativeDateAdapter { export class CustomDateAdapter extends NativeDateAdapter {
/**
* @constructor
*/
public constructor( public constructor(
@Inject(MAT_DATE_LOCALE) public locale: string, @Inject(MAT_DATE_LOCALE) public locale: string,
@Inject(forwardRef(() => MAT_DATE_LOCALE)) matDateLocale: string, @Inject(forwardRef(() => MAT_DATE_LOCALE)) matDateLocale: string,

View File

@ -16,6 +16,13 @@ const routes: Routes = [
(m) => m.ChangelogPageModule (m) => m.ChangelogPageModule
) )
}, },
{
path: 'about/privacy-policy',
loadChildren: () =>
import('./pages/about/privacy-policy/privacy-policy-page.module').then(
(m) => m.PrivacyPolicyPageModule
)
},
{ {
path: 'account', path: 'account',
loadChildren: () => loadChildren: () =>
@ -52,6 +59,11 @@ const routes: Routes = [
'./pages/blog/2021/07/hallo-ghostfolio/hallo-ghostfolio-page.module' './pages/blog/2021/07/hallo-ghostfolio/hallo-ghostfolio-page.module'
).then((m) => m.HalloGhostfolioPageModule) ).then((m) => m.HalloGhostfolioPageModule)
}, },
{
path: 'demo',
loadChildren: () =>
import('./pages/demo/demo-page.module').then((m) => m.DemoPageModule)
},
{ {
path: 'en/blog/2021/07/hello-ghostfolio', path: 'en/blog/2021/07/hello-ghostfolio',
loadChildren: () => loadChildren: () =>
@ -66,6 +78,25 @@ const routes: Routes = [
'./pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.module' './pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.module'
).then((m) => m.FirstMonthsInOpenSourcePageModule) ).then((m) => m.FirstMonthsInOpenSourcePageModule)
}, },
{
path: 'en/blog/2022/07/ghostfolio-meets-internet-identity',
loadChildren: () =>
import(
'./pages/blog/2022/07/ghostfolio-meets-internet-identity/ghostfolio-meets-internet-identity-page.module'
).then((m) => m.GhostfolioMeetsInternetIdentityPageModule)
},
{
path: 'en/blog/2022/07/how-do-i-get-my-finances-in-order',
loadChildren: () =>
import(
'./pages/blog/2022/07/how-do-i-get-my-finances-in-order/how-do-i-get-my-finances-in-order-page.module'
).then((m) => m.HowDoIGetMyFinancesInOrderPageModule)
},
{
path: 'faq',
loadChildren: () =>
import('./pages/faq/faq-page.module').then((m) => m.FaqPageModule)
},
{ {
path: 'features', path: 'features',
loadChildren: () => loadChildren: () =>
@ -78,6 +109,13 @@ const routes: Routes = [
loadChildren: () => loadChildren: () =>
import('./pages/home/home-page.module').then((m) => m.HomePageModule) import('./pages/home/home-page.module').then((m) => m.HomePageModule)
}, },
{
path: 'markets',
loadChildren: () =>
import('./pages/markets/markets-page.module').then(
(m) => m.MarketsPageModule
)
},
{ {
path: 'p', path: 'p',
loadChildren: () => loadChildren: () =>
@ -120,6 +158,13 @@ const routes: Routes = [
(m) => m.FirePageModule (m) => m.FirePageModule
) )
}, },
{
path: 'portfolio/holdings',
loadChildren: () =>
import('./pages/portfolio/holdings/holdings-page.module').then(
(m) => m.HoldingsPageModule
)
},
{ {
path: 'portfolio/report', path: 'portfolio/report',
loadChildren: () => loadChildren: () =>

View File

@ -15,13 +15,17 @@
> >
<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 *ngIf="canCreateAccount" class="text-center" [routerLink]="['/']"> <a
*ngIf="canCreateAccount"
class="text-center"
[routerLink]="['/register']"
>
<div <div
class="cursor-pointer d-inline-block info-message 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> <span class="a ml-2" i18n>Create Account</span>
</div></a </div></a
> >
<div <div

View File

@ -17,7 +17,7 @@
border-radius: 2rem; border-radius: 2rem;
font-size: 80%; font-size: 80%;
a { .a {
color: rgba(var(--palette-primary-500), 1); color: rgba(var(--palette-primary-500), 1);
font-weight: 500; font-weight: 500;
} }

View File

@ -1,6 +1,8 @@
import { Platform } from '@angular/cdk/platform'; import { Platform } from '@angular/cdk/platform';
import { HttpClientModule } from '@angular/common/http'; import { HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatChipsModule } from '@angular/material/chips';
import { import {
DateAdapter, DateAdapter,
MAT_DATE_FORMATS, MAT_DATE_FORMATS,
@ -38,6 +40,8 @@ export function NgxStripeFactory(): string {
GfHeaderModule, GfHeaderModule,
HttpClientModule, HttpClientModule,
MarkdownModule.forRoot(), MarkdownModule.forRoot(),
MatAutocompleteModule,
MatChipsModule,
MaterialCssVarsModule.forRoot({ MaterialCssVarsModule.forRoot({
darkThemeClass: 'is-dark-theme', darkThemeClass: 'is-dark-theme',
isAutoContrast: true, isAutoContrast: true,

View File

@ -10,7 +10,6 @@ import { AccessTableComponent } from './access-table.component';
declarations: [AccessTableComponent], declarations: [AccessTableComponent],
exports: [AccessTableComponent], exports: [AccessTableComponent],
imports: [CommonModule, MatButtonModule, MatMenuModule, MatTableModule], imports: [CommonModule, MatButtonModule, MatMenuModule, MatTableModule],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class GfPortfolioAccessTableModule {} export class GfPortfolioAccessTableModule {}

View File

@ -0,0 +1,7 @@
:host {
display: block;
.mat-dialog-content {
max-height: unset;
}
}

View File

@ -0,0 +1,112 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Inject,
OnDestroy,
OnInit
} from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { downloadAsFile } from '@ghostfolio/common/helper';
import { User } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { AccountType } from '@prisma/client';
import { format, parseISO } from 'date-fns';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { AccountDetailDialogParams } from './interfaces/interfaces';
@Component({
host: { class: 'd-flex flex-column h-100' },
selector: 'gf-account-detail-dialog',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: 'account-detail-dialog.html',
styleUrls: ['./account-detail-dialog.component.scss']
})
export class AccountDetailDialog implements OnDestroy, OnInit {
public accountType: AccountType;
public name: string;
public orders: OrderWithAccount[];
public platformName: string;
public user: User;
public valueInBaseCurrency: number;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: AccountDetailDialogParams,
private dataService: DataService,
public dialogRef: MatDialogRef<AccountDetailDialog>,
private userService: UserService
) {
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.changeDetectorRef.markForCheck();
}
});
}
public ngOnInit(): void {
this.dataService
.fetchAccount(this.data.accountId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ accountType, name, Platform, valueInBaseCurrency }) => {
this.accountType = accountType;
this.name = name;
this.platformName = Platform?.name;
this.valueInBaseCurrency = valueInBaseCurrency;
this.changeDetectorRef.markForCheck();
});
this.dataService
.fetchActivities({
filters: [{ id: this.data.accountId, type: 'ACCOUNT' }]
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ activities }) => {
this.orders = activities;
this.changeDetectorRef.markForCheck();
});
}
public onClose(): void {
this.dialogRef.close();
}
public onExport() {
this.dataService
.fetchExport(
this.orders.map((order) => {
return order.id;
})
)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
downloadAsFile({
content: data,
fileName: `ghostfolio-export-${this.name
.replace(/\s+/g, '-')
.toLowerCase()}-${format(
parseISO(data.meta.date),
'yyyyMMddHHmm'
)}.json`,
format: 'json'
});
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -0,0 +1,64 @@
<gf-dialog-header
mat-dialog-title
position="center"
[deviceType]="data.deviceType"
[title]="name"
(closeButtonClicked)="onClose()"
></gf-dialog-header>
<div class="flex-grow-1" mat-dialog-content>
<div class="container p-0">
<div class="row">
<div class="col-12 d-flex justify-content-center mb-3">
<gf-value
size="large"
[currency]="user?.settings?.baseCurrency"
[locale]="user?.settings?.locale"
[value]="valueInBaseCurrency"
></gf-value>
</div>
</div>
<div class="row">
<div class="col-6 mb-3">
<gf-value
label="Account Type"
size="medium"
[value]="accountType"
></gf-value>
</div>
<div class="col-6 mb-3">
<gf-value
label="Platform"
size="medium"
[value]="platformName"
></gf-value>
</div>
</div>
<div *ngIf="orders?.length > 0" class="row">
<div class="col mb-3">
<div class="h5 mb-0" i18n>Activities</div>
<gf-activities-table
[activities]="orders"
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="data.deviceType"
[hasPermissionToCreateActivity]="false"
[hasPermissionToExportActivities]="!hasImpersonationId"
[hasPermissionToFilter]="false"
[hasPermissionToImportActivities]="false"
[hasPermissionToOpenDetails]="false"
[locale]="user?.settings?.locale"
[showActions]="false"
(export)="onExport()"
></gf-activities-table>
</div>
</div>
</div>
</div>
<gf-dialog-footer
mat-dialog-actions
[deviceType]="data.deviceType"
(closeButtonClicked)="onClose()"
></gf-dialog-footer>

View File

@ -0,0 +1,27 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { AccountDetailDialog } from './account-detail-dialog.component';
@NgModule({
declarations: [AccountDetailDialog],
imports: [
CommonModule,
GfActivitiesTableModule,
GfDialogFooterModule,
GfDialogHeaderModule,
GfValueModule,
MatButtonModule,
MatDialogModule,
NgxSkeletonLoaderModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfAccountDetailDialogModule {}

View File

@ -0,0 +1,5 @@
export interface AccountDetailDialogParams {
accountId: string;
deviceType: string;
hasImpersonationId: boolean;
}

View File

@ -65,7 +65,7 @@
<ng-container matColumnDef="transactions"> <ng-container matColumnDef="transactions">
<th *matHeaderCellDef class="px-1 text-right" mat-header-cell> <th *matHeaderCellDef class="px-1 text-right" mat-header-cell>
<span class="d-block d-sm-none">#</span> <span class="d-block d-sm-none">#</span>
<span class="d-none d-sm-block" i18n>Transactions</span> <span class="d-none d-sm-block" i18n>Activities</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>
<ng-container *ngIf="element.accountType === 'SECURITIES'">{{ <ng-container *ngIf="element.accountType === 'SECURITIES'">{{
@ -212,7 +212,12 @@
</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"
class="cursor-pointer"
mat-row
(click)="onOpenAccountDetailDialog(row.id)"
></tr>
<tr <tr
*matFooterRowDef="displayedColumns" *matFooterRowDef="displayedColumns"
mat-footer-row mat-footer-row

View File

@ -9,6 +9,7 @@ import {
Output Output
} from '@angular/core'; } from '@angular/core';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { Router } from '@angular/router';
import { Account as AccountModel } from '@prisma/client'; import { Account as AccountModel } from '@prisma/client';
import { Subject, Subscription } from 'rxjs'; import { Subject, Subscription } from 'rxjs';
@ -39,7 +40,7 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor() {} public constructor(private router: Router) {}
public ngOnInit() {} public ngOnInit() {}
@ -75,6 +76,12 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
} }
} }
public onOpenAccountDetailDialog(accountId: string) {
this.router.navigate([], {
queryParams: { accountId, accountDetailDialog: true }
});
}
public onUpdateAccount(aAccount: AccountModel) { public onUpdateAccount(aAccount: AccountModel) {
this.accountToUpdate.emit(aAccount); this.accountToUpdate.emit(aAccount);
} }

View File

@ -25,7 +25,6 @@ import { AccountsTableComponent } from './accounts-table.component';
NgxSkeletonLoaderModule, NgxSkeletonLoaderModule,
RouterModule RouterModule
], ],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class GfAccountsTableModule {} export class GfAccountsTableModule {}

View File

@ -0,0 +1,110 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
OnDestroy,
OnInit
} from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { QUEUE_JOB_STATUS_LIST } from '@ghostfolio/common/config';
import { getDateWithTimeFormatString } from '@ghostfolio/common/helper';
import { AdminJobs, User } from '@ghostfolio/common/interfaces';
import { JobStatus } from 'bull';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-admin-jobs',
styleUrls: ['./admin-jobs.scss'],
templateUrl: './admin-jobs.html'
})
export class AdminJobsComponent implements OnDestroy, OnInit {
public defaultDateTimeFormat: string;
public filterForm: FormGroup;
public jobs: AdminJobs['jobs'] = [];
public statusFilterOptions = QUEUE_JOB_STATUS_LIST;
public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
private formBuilder: FormBuilder,
private userService: UserService
) {
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.defaultDateTimeFormat = getDateWithTimeFormatString(
this.user.settings.locale
);
}
});
}
public ngOnInit() {
this.filterForm = this.formBuilder.group({
status: []
});
this.filterForm.valueChanges
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
const currentFilter = this.filterForm.get('status').value;
this.fetchJobs(currentFilter ? [currentFilter] : undefined);
});
this.fetchJobs();
}
public onDeleteJob(aId: string) {
this.adminService
.deleteJob(aId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.fetchJobs();
});
}
public onDeleteJobs() {
const currentFilter = this.filterForm.get('status').value;
this.adminService
.deleteJobs({ status: currentFilter ? [currentFilter] : undefined })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.fetchJobs(currentFilter ? [currentFilter] : undefined);
});
}
public onViewData(aData: AdminJobs['jobs'][0]['data']) {
alert(JSON.stringify(aData, null, ' '));
}
public onViewStacktrace(aStacktrace: AdminJobs['jobs'][0]['stacktrace']) {
alert(JSON.stringify(aStacktrace, null, ' '));
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private fetchJobs(aStatus?: JobStatus[]) {
this.adminService
.fetchJobs({ status: aStatus })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ jobs }) => {
this.jobs = jobs;
this.changeDetectorRef.markForCheck();
});
}
}

View File

@ -0,0 +1,130 @@
<div class="container">
<div class="row">
<div class="col">
<form class="align-items-center d-flex" [formGroup]="filterForm">
<mat-form-field appearance="outline" class="flex-grow-1">
<mat-select formControlName="status">
<mat-option></mat-option>
<mat-option
*ngFor="let statusFilterOption of statusFilterOptions"
[value]="statusFilterOption"
>{{ statusFilterOption }}</mat-option
>
</mat-select>
</mat-form-field>
<button
class="ml-1"
color="warn"
mat-flat-button
(click)="onDeleteJobs()"
>
<span i18n>Delete Jobs</span>
</button>
</form>
<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>Type</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 text-right" i18n>Attempts</th>
<th class="mat-header-cell px-1 py-2" i18n>Created</th>
<th class="mat-header-cell px-1 py-2" i18n>Finished</th>
<th class="mat-header-cell px-1 py-2" i18n>Status</th>
<th class="mat-header-cell px-1 py-2"></th>
</tr>
</thead>
<tbody>
<ng-container *ngFor="let job of jobs">
<tr class="mat-row">
<td class="mat-cell px-1 py-2 text-right">{{ job.id }}</td>
<td class="mat-cell px-1 py-2">
<span class="align-items-center d-flex">
<ion-icon
class="mr-1"
name="arrow-down-circle-outline"
></ion-icon>
<ng-container *ngIf="job.name === 'GATHER_ASSET_PROFILE'">
<span i18n>Asset Profile</span>
</ng-container>
<ng-container
*ngIf="job.name === 'GATHER_HISTORICAL_MARKET_DATA'"
>
<span i18n>Historical Market Data</span>
</ng-container>
</span>
</td>
<td class="mat-cell px-1 py-2">{{ job.data?.symbol }}</td>
<td class="mat-cell px-1 py-2">{{ job.data?.dataSource }}</td>
<td class="mat-cell px-1 py-2 text-right">
{{ job.attemptsMade }}
</td>
<td class="mat-cell px-1 py-2">
{{ job.timestamp | date: defaultDateTimeFormat }}
</td>
<td class="mat-cell px-1 py-2">
{{ job.finishedOn | date: defaultDateTimeFormat }}
</td>
<td class="mat-cell px-1 py-2">
<ion-icon
*ngIf="job.state === 'active'"
name="play-outline"
></ion-icon>
<ion-icon
*ngIf="job.state === 'completed'"
class="text-success"
name="checkmark-circle-outline"
></ion-icon>
<ion-icon
*ngIf="job.state === 'delayed'"
name="time-outline"
[ngClass]="{ 'text-danger': job.stacktrace?.length > 0 }"
></ion-icon>
<ion-icon
*ngIf="job.state === 'failed'"
class="text-danger"
name="alert-circle-outline"
></ion-icon>
<ion-icon
*ngIf="job.state === 'paused'"
name="pause-outline"
></ion-icon>
<ion-icon
*ngIf="job.state === 'waiting'"
name="cafe-outline"
></ion-icon>
</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)="onViewData(job.data)">
View Data
</button>
<button
i18n
mat-menu-item
[disabled]="job.stacktrace?.length <= 0"
(click)="onViewStacktrace(job.stacktrace)"
>
View Stacktrace
</button>
<button i18n mat-menu-item (click)="onDeleteJob(job.id)">
Delete Job
</button>
</mat-menu>
</td>
</tr>
</ng-container>
</tbody>
</table>
</div>
</div>
</div>

View File

@ -0,0 +1,22 @@
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 { MatMenuModule } from '@angular/material/menu';
import { MatSelectModule } from '@angular/material/select';
import { AdminJobsComponent } from './admin-jobs.component';
@NgModule({
declarations: [AdminJobsComponent],
imports: [
CommonModule,
FormsModule,
MatButtonModule,
MatMenuModule,
MatSelectModule,
ReactiveFormsModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfAdminJobsModule {}

View File

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

View File

@ -9,7 +9,6 @@ import { GfMarketDataDetailDialogModule } from './market-data-detail-dialog/mark
declarations: [AdminMarketDataDetailComponent], declarations: [AdminMarketDataDetailComponent],
exports: [AdminMarketDataDetailComponent], exports: [AdminMarketDataDetailComponent],
imports: [CommonModule, GfLineChartModule, GfMarketDataDetailDialogModule], imports: [CommonModule, GfLineChartModule, GfMarketDataDetailDialogModule],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class GfAdminMarketDataDetailModule {} export class GfAdminMarketDataDetailModule {}

View File

@ -11,7 +11,6 @@ import { MarketDataDetailDialog } from './market-data-detail-dialog.component';
@NgModule({ @NgModule({
declarations: [MarketDataDetailDialog], declarations: [MarketDataDetailDialog],
exports: [],
imports: [ imports: [
CommonModule, CommonModule,
FormsModule, FormsModule,
@ -22,7 +21,6 @@ import { MarketDataDetailDialog } from './market-data-detail-dialog.component';
MatInputModule, MatInputModule,
ReactiveFormsModule ReactiveFormsModule
], ],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class GfMarketDataDetailDialogModule {} export class GfMarketDataDetailDialogModule {}

View File

@ -31,9 +31,6 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor( public constructor(
private adminService: AdminService, private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
@ -53,9 +50,6 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
}); });
} }
/**
* Initializes the controller
*/
public ngOnInit() { public ngOnInit() {
this.fetchAdminMarketData(); this.fetchAdminMarketData();
} }

View File

@ -15,7 +15,6 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { import {
differenceInSeconds, differenceInSeconds,
formatDistanceToNowStrict, formatDistanceToNowStrict,
isValid,
parseISO parseISO
} from 'date-fns'; } from 'date-fns';
import { uniq } from 'lodash'; import { uniq } from 'lodash';
@ -32,23 +31,17 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
public couponDuration: StringValue = '30 days'; public couponDuration: StringValue = '30 days';
public coupons: Coupon[]; public coupons: Coupon[];
public customCurrencies: string[]; public customCurrencies: string[];
public dataGatheringInProgress: boolean;
public dataGatheringProgress: number;
public exchangeRates: { label1: string; label2: string; value: number }[]; public exchangeRates: { label1: string; label2: string; value: number }[];
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
public hasPermissionForSystemMessage: boolean; public hasPermissionForSystemMessage: boolean;
public hasPermissionToToggleReadOnlyMode: boolean; public hasPermissionToToggleReadOnlyMode: boolean;
public info: InfoItem; public info: InfoItem;
public lastDataGathering: string;
public transactionCount: number; public transactionCount: number;
public userCount: number; public userCount: number;
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor( public constructor(
private adminService: AdminService, private adminService: AdminService,
private cacheService: CacheService, private cacheService: CacheService,
@ -82,9 +75,6 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
}); });
} }
/**
* Initializes the controller
*/
public ngOnInit() { public ngOnInit() {
this.fetchAdminData(); this.fetchAdminData();
} }
@ -128,7 +118,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
public onDeleteCoupon(aCouponCode: string) { public onDeleteCoupon(aCouponCode: string) {
const confirmation = confirm('Do you really want to delete this coupon?'); const confirmation = confirm('Do you really want to delete this coupon?');
if (confirmation) { if (confirmation === true) {
const coupons = this.coupons.filter((coupon) => { const coupons = this.coupons.filter((coupon) => {
return coupon.code !== aCouponCode; return coupon.code !== aCouponCode;
}); });
@ -139,7 +129,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
public onDeleteCurrency(aCurrency: string) { public onDeleteCurrency(aCurrency: string) {
const confirmation = confirm('Do you really want to delete this currency?'); const confirmation = confirm('Do you really want to delete this currency?');
if (confirmation) { if (confirmation === true) {
const currencies = this.customCurrencies.filter((currency) => { const currencies = this.customCurrencies.filter((currency) => {
return currency !== aCurrency; return currency !== aCurrency;
}); });
@ -152,8 +142,23 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
} }
public onFlushCache() { public onFlushCache() {
this.cacheService const confirmation = confirm('Do you really want to flush the cache?');
.flush()
if (confirmation === true) {
this.cacheService
.flush()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
}
}
public onGather7Days() {
this.adminService
.gather7Days()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => { .subscribe(() => {
setTimeout(() => { setTimeout(() => {
@ -163,20 +168,14 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
} }
public onGatherMax() { public onGatherMax() {
const confirmation = confirm( this.adminService
'This action may take some time. Do you want to proceed?' .gatherMax()
); .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
if (confirmation === true) { setTimeout(() => {
this.adminService window.location.reload();
.gatherMax() }, 300);
.pipe(takeUntil(this.unsubscribeSubject)) });
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
}
} }
public onGatherProfileData() { public onGatherProfileData() {
@ -207,39 +206,15 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
this.dataService this.dataService
.fetchAdminData() .fetchAdminData()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe( .subscribe(({ exchangeRates, settings, transactionCount, userCount }) => {
({ this.coupons = (settings[PROPERTY_COUPONS] as Coupon[]) ?? [];
dataGatheringProgress, this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[];
exchangeRates, this.exchangeRates = exchangeRates;
lastDataGathering, this.transactionCount = transactionCount;
settings, this.userCount = userCount;
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.changeDetectorRef.markForCheck();
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) { private generateCouponCode(aLength: number) {

View File

@ -19,37 +19,30 @@
<div class="d-flex my-3"> <div class="d-flex my-3">
<div class="w-50" i18n>Data Gathering</div> <div class="w-50" i18n>Data Gathering</div>
<div class="w-50"> <div class="w-50">
<div> <div class="overflow-hidden">
<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"> <div class="mb-2">
<button <button
color="accent" color="accent"
mat-flat-button mat-flat-button
(click)="onFlushCache()" (click)="onGather7Days()"
> >
<ion-icon <ion-icon
class="mr-1" class="mr-1"
name="close-circle-outline" name="cloud-download-outline"
></ion-icon> ></ion-icon>
<span i18n>Reset Data Gathering</span> <span i18n>Gather Recent Data</span>
</button> </button>
</div> </div>
<div class="mb-2"> <div class="mb-2">
<button <button
color="warn" color="accent"
mat-flat-button mat-flat-button
[disabled]="dataGatheringInProgress"
(click)="onGatherMax()" (click)="onGatherMax()"
> >
<ion-icon class="mr-1" name="warning-outline"></ion-icon> <ion-icon
class="mr-1"
name="cloud-download-outline"
></ion-icon>
<span i18n>Gather All Data</span> <span i18n>Gather All Data</span>
</button> </button>
</div> </div>
@ -58,7 +51,6 @@
class="mb-2 mr-2" class="mb-2 mr-2"
color="accent" color="accent"
mat-flat-button mat-flat-button
[disabled]="dataGatheringInProgress"
(click)="onGatherProfileData()" (click)="onGatherProfileData()"
> >
<ion-icon <ion-icon
@ -97,7 +89,6 @@
*ngIf="customCurrencies.includes(exchangeRate.label2)" *ngIf="customCurrencies.includes(exchangeRate.label2)"
class="mini-icon mx-1 no-min-width px-2" class="mini-icon mx-1 no-min-width px-2"
mat-button mat-button
[disabled]="dataGatheringInProgress"
(click)="onDeleteCurrency(exchangeRate.label2)" (click)="onDeleteCurrency(exchangeRate.label2)"
> >
<ion-icon name="trash-outline"></ion-icon> <ion-icon name="trash-outline"></ion-icon>
@ -109,7 +100,6 @@
<button <button
color="primary" color="primary"
mat-flat-button mat-flat-button
[disabled]="dataGatheringInProgress"
(click)="onAddCurrency()" (click)="onAddCurrency()"
> >
<ion-icon class="mr-1" name="add-outline"></ion-icon> <ion-icon class="mr-1" name="add-outline"></ion-icon>
@ -126,7 +116,6 @@
<button <button
class="mini-icon mx-1 no-min-width px-2" class="mini-icon mx-1 no-min-width px-2"
mat-button mat-button
[disabled]="dataGatheringInProgress"
(click)="onDeleteSystemMessage()" (click)="onDeleteSystemMessage()"
> >
<ion-icon name="trash-outline"></ion-icon> <ion-icon name="trash-outline"></ion-icon>
@ -197,6 +186,15 @@
</div> </div>
</div> </div>
</div> </div>
<div class="d-flex my-3">
<div class="w-50" i18n>Housekeeping</div>
<div class="w-50">
<button color="warn" mat-flat-button (click)="onFlushCache()">
<ion-icon class="mr-1" name="close-circle-outline"></ion-icon>
<span i18n>Flush Cache</span>
</button>
</div>
</div>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</div> </div>

View File

@ -21,9 +21,6 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
@ -38,9 +35,6 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
}); });
} }
/**
* Initializes the controller
*/
public ngOnInit() { public ngOnInit() {
this.fetchAdminData(); this.fetchAdminData();
} }

View File

@ -29,17 +29,15 @@
<td class="mat-cell px-1 py-2"> <td class="mat-cell px-1 py-2">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<span class="d-none d-sm-inline-block" <span class="d-none d-sm-inline-block"
>{{ userItem.alias || userItem.id }}</span >{{ userItem.id }}</span
> >
<span class="d-inline-block d-sm-none" <span class="d-inline-block d-sm-none"
>{{ userItem.alias || (userItem.id | slice:0:5) + >{{ (userItem.id | slice:0:5) + '...' }}</span
'...' }}</span
> >
<ion-icon <gf-premium-indicator
*ngIf="userItem?.subscription?.type === 'Premium'" *ngIf="userItem?.subscription?.type === 'Premium'"
class="ml-1 text-muted" class="ml-1"
name="diamond-outline" ></gf-premium-indicator>
></ion-icon>
</div> </div>
</td> </td>
<td class="mat-cell px-1 py-2 text-right"> <td class="mat-cell px-1 py-2 text-right">

View File

@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
import { GfValueModule } from '@ghostfolio/ui/value'; import { GfValueModule } from '@ghostfolio/ui/value';
import { AdminUsersComponent } from './admin-users.component'; import { AdminUsersComponent } from './admin-users.component';
@ -9,7 +10,13 @@ import { AdminUsersComponent } from './admin-users.component';
@NgModule({ @NgModule({
declarations: [AdminUsersComponent], declarations: [AdminUsersComponent],
exports: [], exports: [],
imports: [CommonModule, GfValueModule, MatButtonModule, MatMenuModule], imports: [
CommonModule,
GfPremiumIndicatorModule,
GfValueModule,
MatButtonModule,
MatMenuModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class GfAdminUsersModule {} export class GfAdminUsersModule {}

View File

@ -8,7 +8,6 @@ import { DialogFooterComponent } from './dialog-footer.component';
declarations: [DialogFooterComponent], declarations: [DialogFooterComponent],
exports: [DialogFooterComponent], exports: [DialogFooterComponent],
imports: [CommonModule, MatButtonModule], imports: [CommonModule, MatButtonModule],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class GfDialogFooterModule {} export class GfDialogFooterModule {}

View File

@ -8,7 +8,6 @@ import { DialogHeaderComponent } from './dialog-header.component';
declarations: [DialogHeaderComponent], declarations: [DialogHeaderComponent],
exports: [DialogHeaderComponent], exports: [DialogHeaderComponent],
imports: [CommonModule, MatButtonModule], imports: [CommonModule, MatButtonModule],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class GfDialogHeaderModule {} export class GfDialogHeaderModule {}

View File

@ -66,7 +66,9 @@
>Resources</a >Resources</a
> >
<a <a
*ngIf="hasPermissionForSubscription" *ngIf="
hasPermissionForSubscription && user?.subscription?.type === 'Basic'
"
class="d-none d-sm-block mx-1" class="d-none d-sm-block mx-1"
i18n i18n
mat-flat-button mat-flat-button
@ -122,13 +124,11 @@
: 'radio-button-on-outline' : 'radio-button-on-outline'
" "
></ion-icon> ></ion-icon>
<span *ngIf="user?.alias">{{ user.alias }}</span> <span i18n>Me</span>
<span *ngIf="!user?.alias" i18n><span></span>Me</span>
</button> </button>
<button <button
*ngFor="let accessItem of user?.access" *ngFor="let accessItem of user?.access"
class="align-items-center d-flex" class="align-items-center d-flex"
disabled="false"
mat-menu-item mat-menu-item
(click)="impersonateAccount(accessItem.id)" (click)="impersonateAccount(accessItem.id)"
> >
@ -203,7 +203,9 @@
>Resources</a >Resources</a
> >
<a <a
*ngIf="hasPermissionForSubscription" *ngIf="
hasPermissionForSubscription && user?.subscription?.type === 'Basic'
"
class="d-block d-sm-none" class="d-block d-sm-none"
i18n i18n
mat-menu-item mat-menu-item
@ -229,13 +231,7 @@
mat-button mat-button
[routerLink]="['/']" [routerLink]="['/']"
> >
<gf-logo <gf-logo [hideName]="currentRoute === 'register'"></gf-logo>
[hideName]="
!currentRoute ||
currentRoute === 'register' ||
currentRoute === 'start'
"
></gf-logo>
</a> </a>
<span class="spacer"></span> <span class="spacer"></span>
<a <a
@ -271,6 +267,18 @@
[routerLink]="['/pricing']" [routerLink]="['/pricing']"
>Pricing</a >Pricing</a
> >
<a
*ngIf="hasPermissionToAccessFearAndGreedIndex"
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === 'markets',
'text-decoration-underline': currentRoute === 'markets'
}"
[routerLink]="['/markets']"
>Markets</a
>
<a <a
class="d-none d-sm-block mx-1 no-min-width px-1" class="d-none d-sm-block mx-1 no-min-width px-1"
href="https://github.com/ghostfolio/ghostfolio" href="https://github.com/ghostfolio/ghostfolio"

View File

@ -37,6 +37,7 @@ export class HeaderComponent implements OnChanges {
public hasPermissionForSocialLogin: boolean; public hasPermissionForSocialLogin: boolean;
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
public hasPermissionToAccessAdminControl: boolean; public hasPermissionToAccessAdminControl: boolean;
public hasPermissionToAccessFearAndGreedIndex: boolean;
public impersonationId: string; public impersonationId: string;
public isMenuOpen: boolean; public isMenuOpen: boolean;
@ -73,6 +74,11 @@ export class HeaderComponent implements OnChanges {
this.user?.permissions, this.user?.permissions,
permissions.accessAdminControl permissions.accessAdminControl
); );
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
this.info?.globalPermissions,
permissions.enableFearAndGreedIndex
);
} }
public impersonateAccount(aId: string) { public impersonateAccount(aId: string) {

View File

@ -21,7 +21,6 @@ import { HeaderComponent } from './header.component';
MatToolbarModule, MatToolbarModule,
RouterModule RouterModule
], ],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class GfHeaderModule {} export class GfHeaderModule {}

View File

@ -36,9 +36,6 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
@ -81,9 +78,6 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
}); });
} }
/**
* Initializes the controller
*/
public ngOnInit() { public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType; this.deviceType = this.deviceService.getDeviceInfo().deviceType;

View File

@ -11,7 +11,6 @@ import { HomeHoldingsComponent } from './home-holdings.component';
@NgModule({ @NgModule({
declarations: [HomeHoldingsComponent], declarations: [HomeHoldingsComponent],
exports: [],
imports: [ imports: [
CommonModule, CommonModule,
GfPositionDetailDialogModule, GfPositionDetailDialogModule,
@ -21,7 +20,6 @@ import { HomeHoldingsComponent } from './home-holdings.component';
MatCardModule, MatCardModule,
RouterModule RouterModule
], ],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class GfHomeHoldingsModule {} export class GfHomeHoldingsModule {}

View File

@ -25,14 +25,11 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
public historicalData: HistoricalDataItem[]; public historicalData: HistoricalDataItem[];
public info: InfoItem; public info: InfoItem;
public isLoading = true; public isLoading = true;
public readonly numberOfDays = 90; public readonly numberOfDays = 180;
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
@ -47,52 +44,49 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
this.user.permissions,
permissions.accessFearAndGreedIndex
);
if (this.hasPermissionToAccessFearAndGreedIndex) {
this.dataService
.fetchSymbolItem({
dataSource: this.info.fearAndGreedDataSource,
includeHistoricalData: this.numberOfDays,
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.dataService
.fetchBenchmarks()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ benchmarks }) => {
this.benchmarks = benchmarks;
this.changeDetectorRef.markForCheck();
});
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
}); });
} }
/** public ngOnInit() {
* Initializes the controller this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
*/ this.info?.globalPermissions,
public ngOnInit() {} permissions.enableFearAndGreedIndex
);
if (this.hasPermissionToAccessFearAndGreedIndex) {
this.dataService
.fetchSymbolItem({
dataSource: this.info.fearAndGreedDataSource,
includeHistoricalData: this.numberOfDays,
symbol: ghostfolioFearAndGreedIndexSymbol
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ historicalData, marketPrice }) => {
this.fearAndGreedIndex = marketPrice;
this.historicalData = [
...historicalData,
{
date: resetHours(new Date()).toISOString(),
value: marketPrice
}
];
this.changeDetectorRef.markForCheck();
});
}
this.dataService
.fetchBenchmarks()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ benchmarks }) => {
this.benchmarks = benchmarks;
this.isLoading = false;
this.changeDetectorRef.markForCheck();
});
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();

View File

@ -28,16 +28,17 @@
<div class="mb-3 row"> <div class="mb-3 row">
<div class="col-xs-12 col-md-8 offset-md-2"> <div class="col-xs-12 col-md-8 offset-md-2">
<gf-benchmark <gf-benchmark
*ngFor="let benchmark of benchmarks" [benchmarks]="benchmarks"
class="py-2"
[benchmark]="benchmark"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
></gf-benchmark> ></gf-benchmark>
<gf-benchmark <ngx-skeleton-loader
*ngIf="!benchmarks" *ngIf="isLoading"
class="py-2" animation="pulse"
[benchmark]="undefined" [theme]="{
></gf-benchmark> height: '1.5rem',
width: '100%'
}"
></ngx-skeleton-loader>
</div> </div>
</div> </div>
</div> </div>

View File

@ -3,19 +3,20 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { GfFearAndGreedIndexModule } from '@ghostfolio/client/components/fear-and-greed-index/fear-and-greed-index.module'; import { GfFearAndGreedIndexModule } from '@ghostfolio/client/components/fear-and-greed-index/fear-and-greed-index.module';
import { GfBenchmarkModule } from '@ghostfolio/ui/benchmark/benchmark.module'; import { GfBenchmarkModule } from '@ghostfolio/ui/benchmark/benchmark.module';
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module'; import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { HomeMarketComponent } from './home-market.component'; import { HomeMarketComponent } from './home-market.component';
@NgModule({ @NgModule({
declarations: [HomeMarketComponent], declarations: [HomeMarketComponent],
exports: [], exports: [HomeMarketComponent],
imports: [ imports: [
CommonModule, CommonModule,
GfBenchmarkModule, GfBenchmarkModule,
GfFearAndGreedIndexModule, GfFearAndGreedIndexModule,
GfLineChartModule GfLineChartModule,
NgxSkeletonLoaderModule
], ],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class GfHomeMarketModule {} export class GfHomeMarketModule {}

View File

@ -42,9 +42,6 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
@ -69,9 +66,6 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
}); });
} }
/**
* Initializes the controller
*/
public ngOnInit() { public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType; this.deviceType = this.deviceService.getDeviceInfo().deviceType;

View File

@ -2,24 +2,29 @@
class="align-items-center container d-flex flex-column h-100 justify-content-center overview p-0 position-relative" class="align-items-center container d-flex flex-column h-100 justify-content-center overview p-0 position-relative"
> >
<div class="row w-100"> <div class="row w-100">
<div class="chart-container col"> <div class="col p-0">
<gf-line-chart <div class="chart-container mx-auto position-relative">
symbol="Performance" <div
[historicalDataItems]="historicalDataItems" *ngIf="hasPermissionToCreateOrder && historicalDataItems?.length === 0"
[locale]="user?.settings?.locale" class="align-items-center d-flex h-100 justify-content-center w-100"
[ngClass]="{ 'pr-3': deviceType === 'mobile' }" >
[showGradient]="true" <div class="d-flex justify-content-center">
[showLoader]="false" <gf-no-transactions-info-indicator></gf-no-transactions-info-indicator>
[showXAxis]="false" </div>
[showYAxis]="false"
></gf-line-chart>
<div
*ngIf="hasPermissionToCreateOrder && historicalDataItems?.length === 0"
class="align-items-center d-flex h-100 justify-content-center w-100"
>
<div class="d-flex justify-content-center">
<gf-no-transactions-info-indicator></gf-no-transactions-info-indicator>
</div> </div>
<gf-line-chart
class="position-absolute"
symbol="Performance"
[currency]="user?.settings?.baseCurrency"
[historicalDataItems]="historicalDataItems"
[hidden]="historicalDataItems?.length === 0"
[locale]="user?.settings?.locale"
[ngClass]="{ 'pr-3': deviceType === 'mobile' }"
[showGradient]="true"
[showLoader]="false"
[showXAxis]="false"
[showYAxis]="false"
></gf-line-chart>
</div> </div>
</div> </div>
</div> </div>

View File

@ -10,7 +10,6 @@ import { HomeOverviewComponent } from './home-overview.component';
@NgModule({ @NgModule({
declarations: [HomeOverviewComponent], declarations: [HomeOverviewComponent],
exports: [],
imports: [ imports: [
CommonModule, CommonModule,
GfLineChartModule, GfLineChartModule,
@ -19,7 +18,6 @@ import { HomeOverviewComponent } from './home-overview.component';
GfToggleModule, GfToggleModule,
RouterModule RouterModule
], ],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class GfHomeOverviewModule {} export class GfHomeOverviewModule {}

View File

@ -5,7 +5,8 @@
.chart-container { .chart-container {
aspect-ratio: 16 / 9; aspect-ratio: 16 / 9;
max-height: 50vh; height: auto;
max-width: 67rem;
// Fallback for aspect-ratio (using padding hack) // Fallback for aspect-ratio (using padding hack)
@supports not (aspect-ratio: 16 / 9) { @supports not (aspect-ratio: 16 / 9) {
@ -25,10 +26,8 @@
gf-line-chart { gf-line-chart {
bottom: 0; bottom: 0;
left: 0; left: 0;
position: absolute;
right: 0; right: 0;
top: 0; top: 0;
z-index: -1;
} }
} }
} }

View File

@ -21,9 +21,6 @@ export class HomeSummaryComponent implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
@ -46,9 +43,6 @@ export class HomeSummaryComponent implements OnDestroy, OnInit {
}); });
} }
/**
* Initializes the controller
*/
public ngOnInit() { public ngOnInit() {
this.impersonationStorageService this.impersonationStorageService
.onChangeHasImpersonation() .onChangeHasImpersonation()

View File

@ -8,14 +8,12 @@ import { HomeSummaryComponent } from './home-summary.component';
@NgModule({ @NgModule({
declarations: [HomeSummaryComponent], declarations: [HomeSummaryComponent],
exports: [],
imports: [ imports: [
CommonModule, CommonModule,
GfPortfolioSummaryModule, GfPortfolioSummaryModule,
MatCardModule, MatCardModule,
RouterModule RouterModule
], ],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class GfHomeSummaryModule {} export class GfHomeSummaryModule {}

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