Compare commits

...

71 Commits

Author SHA1 Message Date
f5df970685 Release 2.37.0 (#2858) 2024-01-11 18:57:59 +01:00
edfdc0c346 Feature/improve chart size in asset profile details dialog (#2849)
* Improve chart size

* Update changelog
2024-01-11 18:55:52 +01:00
fcfe7b1787 Clean up (#2844) 2024-01-11 18:55:34 +01:00
170b8acc65 Feature/Add git pre-commit hook for yarn format (#2840)
* Set up git pre-commit hook for yarn format

* Update changelog
2024-01-10 20:36:10 +01:00
a47829082e Bugfix/fix hidden fifth tab on mobile (#2848)
* Fix hidden fifth tab

* Update changelog
2024-01-09 08:28:01 +01:00
48ab5fcf08 Feature/update docker compose instructions to compose v2 (#2836)
* Update docker compose instructions

* Update changelog
2024-01-08 20:21:47 +01:00
dc8b60eeb1 Rename "Jobs" to "Job Queue" (#2847) 2024-01-08 20:21:13 +01:00
ee67432ffc Release 2.36.0 (#2842) 2024-01-07 17:15:30 +01:00
7755a6b655 Update translations (#2841) 2024-01-07 17:13:19 +01:00
d7f72819de Feature/extend assistant by tag selector (#2838)
* Extend assistant by tag selector

* Update changelog
2024-01-07 16:56:25 +01:00
2a4d7bf14f Feature/improve language localization for de 20240106 (#2837)
* Update translations

* Update changelog
2024-01-07 16:52:02 +01:00
d49287922f Feature/refresh cryptocurrencies list 20240106 (#2835)
* Update cryptocurrencies.json

* Update changelog
2024-01-06 19:29:03 +01:00
ac0f6f40cf Remove closing tags (#2816) 2024-01-06 19:28:35 +01:00
d91f947ab0 Feature/Add Coingecko api keys support (#2827)
* Add CoinGecko API keys support

* Update changelog
2024-01-06 19:06:07 +01:00
af71274ea9 Feature/remove account type enum (#2832)
* Remove AccountType enum

* Update changelog
2024-01-06 14:19:42 +01:00
0feba4b8d9 Release 2.35.0 (#2831) 2024-01-06 10:45:26 +01:00
62f85293e2 #2820 Grant private access (#2822)
* Grant private access

* Update changelog
2024-01-06 10:27:21 +01:00
6a048cee85 Feature/add hint for twr to portfolio summary (#2824)
* Add hint for TWR

* Add TWR to README.md

* Update changelog
2024-01-06 09:31:59 +01:00
0d93612d16 Feature/improve style of assistant (#2828)
* Minor style improvements

* Update changelog
2024-01-06 09:14:48 +01:00
9bf68b0d20 Feature/enable redis authentication in docker compose files (#2805)
* Enable redis authentication in docker-compose files

* Update changelog
2024-01-04 20:14:45 +01:00
371f1dc451 Feature/support rest api in scraper (#2810)
* Support REST APIs in scraper

* Update changelog
2024-01-03 21:59:45 +01:00
5cb2ec6411 Feature/Improve user interface of access table (#2821)
* Improve alignment

* Update changelog
2024-01-03 21:08:35 +01:00
3723a1d8b8 Release 2.34.0 (#2817) 2024-01-02 17:05:12 +01:00
4c30e9459d Feature/extend assistant by date range selector (#2815)
* Extend assistant by date range selector

* Update changelog
2024-01-02 17:02:15 +01:00
23d323073d Fix performance percentage for 1d (#2814)
* Fix performance percentage for 1d

* Improve response of positions endpoint

* Update changelog
2024-01-02 14:10:08 +01:00
0ad734262a Bugfix/improve tabs on ios (#2811)
* Improve tabs on iOS (Add to Home Screen)

* Update changelog
2024-01-02 10:06:13 +01:00
0649f9fd2c Clean up (#2787) 2024-01-02 10:05:31 +01:00
d089662dab Feature/improve the style of the top 3 and bottom 3 performers (#2807)
* Refactor to ordered list

* Update changelog
2024-01-02 09:44:15 +01:00
8c1c336fc6 Feature/upgrade nx to version 17.2.8 (#2809)
* Upgrade Nx to version 17.2.8

* Update changelog
2024-01-01 17:14:53 +01:00
43b4f14ace Feature/add button to test scraper configuration (#2808)
* Add button to test scraper configuration

* Update changelog

---------

Co-authored-by: Manushreshta B L <manushreshta27@gmail.com>
Co-authored-by: Hugo Persson <hugo.e.persson@gmail.com>
2024-01-01 11:53:42 +01:00
3717e38845 Update year (#2803) 2024-01-01 10:11:04 +01:00
265d4d0450 Release 2.33.0 (#2806) 2023-12-31 13:30:27 +01:00
726e727c7d Feature/benchmark currency correction (#2790)
* Convert benchmark performance to base currency

* Introduce getExchangeRates() for multiple dates

* Update changelog

---------

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2023-12-31 13:28:11 +01:00
cb664774c0 Update translations (#2798)
* Update translations
2023-12-31 10:33:12 +01:00
b89bf1d5e8 Feature/increase timeout to load currencies (#2800)
* Increase timeout

* Update changelog
2023-12-31 10:23:56 +01:00
53ce37a83a Update OSS friends (#2799) 2023-12-31 10:23:13 +01:00
e9ac9057ff Fix debug instructions (#2802) 2023-12-30 21:11:56 +01:00
7020fc2a93 Feature/add hint for community language support (#2793)
* Add hint

* Update changelog
2023-12-30 10:55:11 +01:00
efcd9539dd Feature/upgrade ng extract i18n merge to 2.9.1 (#2797)
* Upgrade ng-extract-i18n-merge to version 2.9.1

* Update changelog
2023-12-30 10:54:04 +01:00
61ecc48d0e Feature/improve language localization for de 20231229 (#2796)
* Improve language localizations

* Update changelog
2023-12-30 10:34:17 +01:00
e465f1b791 Feature/add support to edit currency of asset profile with data source manual (#2789)
* Add support to edit currency

* Update changelog
2023-12-29 19:55:51 +01:00
01b6c14bcc Feature/improve handling of derived currency usx (#2788)
* Improve handling of USX

* Update changelog
2023-12-29 17:31:50 +01:00
34b02210df Feature/expose the environment variable REQUEST_TIMEOUT (#2792)
* Expose the environment variable `REQUEST_TIMEOUT`

* Update changelog
2023-12-29 17:29:33 +01:00
0034776b34 Feature/upgrade nx to version 17.2.7 (#2781)
* Upgrade Nx to version 17.2.7

* Update changelog
2023-12-29 11:26:24 +01:00
b183c45027 Time weighted portfolio performance calculation (#2778)
* Implement time weighted portfolio performance calculation

* Update changelog
2023-12-27 15:55:35 +01:00
7d68905f1b Feature/use has permission annotation in endpoints (#2771)
* Use HasPermission in endpoints

* Update changelog
2023-12-26 19:23:25 +01:00
0953c072fe Release 2.32.0 (#2784) 2023-12-26 10:18:32 +01:00
d152187ee8 Feature/upgrade prisma to version 5.7.1 (#2780)
* Upgrade prisma to version 5.7.1

* Update changelog
2023-12-26 10:16:20 +01:00
3c5affce88 Feature/upgrade prettier to version 3.1.1 (#2768)
* Upgrade prettier to version 3.1.1

* Update changelog
2023-12-24 16:23:17 +01:00
f27e21f9a0 Extend issue template (#2776) 2023-12-23 17:45:37 +01:00
337ca328c3 Feature/drop activity id on import (#2769)
* Drop activity id on import

* Update changelog
2023-12-22 20:16:02 +01:00
beb9e2c43f Feature/modernize nx executors (#2767)
* Modernize Nx executors

* @nx/eslint:lint
* @nx/webpack:webpack

* Update changelog
2023-12-21 11:44:36 +01:00
4d79df90a7 Feature/support search by asset profile id (#2765)
* Add support to search for an asset profile by id

* Update changelog
2023-12-20 19:24:03 +01:00
aa72d9b730 Feature/improve validation of currency management (#2761)
* Improve validation

* Update changelog
2023-12-20 11:53:40 +01:00
80e899a5d3 Bugfix/reset letter spacing in buttons (#2762)
* Reset letter spacing

* Update changelog
2023-12-19 19:54:20 +01:00
7c33120546 Reimplement redactObject() without cloneDeep() (#2760)
* Reimplement redactObject() without cloneDeep()

* Update changelog
2023-12-19 19:53:37 +01:00
7f3c86038f Sort imports (#2739) 2023-12-18 19:52:57 +01:00
c1446f8559 Feature/set select column to sticky in lazy loaded activities table (#2755)
* Set select column to sticky

* Update changelog
2023-12-17 20:09:56 +01:00
88d5dfe435 Release 2.31.0 (#2757) 2023-12-16 20:01:38 +01:00
7dc8f80fdf Feature/upgrade to nx version 17.2.5 (#2756)
* Upgrade Nx to version 17.2.5

* Update changelog
2023-12-16 20:00:04 +01:00
96f90c7259 Feature/add lazy loaded activities table to import activities dialog (#2754)
* Add lazy-loaded activities table

* Update changelog
2023-12-16 19:23:01 +01:00
a10d9cb6ba Feature/add lazy loaded activities table to account detail dialog (#2752)
* Add lazy-loaded activities table

* Update changelog
2023-12-16 19:04:08 +01:00
4547c5da1d Feature/add lazy loaded activities table to position detail dialog (#2753)
* Add lazy-loaded activities table

* Update changelog
2023-12-16 17:10:19 +01:00
28706d7b26 Refactor order service (#2751) 2023-12-16 17:08:59 +01:00
492bc5e17b Feature/improve font weight in value component (#2747)
* Improve font weight

* Update changelog
2023-12-16 14:41:50 +01:00
6c37737051 Bugfix/fix loading state of lazy loaded activities table component (#2744)
* Fix loading state

* Update changelog
2023-12-16 10:24:04 +01:00
8677d20c2c Feature/upgrade inter to version 4 (#2746)
* Upgrade to Inter 4

* Update changelog
2023-12-16 10:23:08 +01:00
4d905065ad Extend turkish translation (#2750) 2023-12-16 09:23:42 +01:00
5599b41b83 Bugfix/fix edit of activity in lazy loaded activities table (#2749)
* Fix edit

* Update changelog
2023-12-16 09:19:09 +01:00
8d5a60d777 Update turkish locales (#2748)
* Update translation

* Update changelog
2023-12-15 08:15:51 +01:00
695acf4f3f Update turkish translation (#2731)
* Update locales

* Update changelog
2023-12-14 20:04:22 +01:00
243 changed files with 9354 additions and 6077 deletions

View File

@ -6,7 +6,13 @@ labels: ''
assignees: '' assignees: ''
--- ---
The Issue tracker is **ONLY** used for reporting bugs. New features should be discussed in our [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) community or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions). **Important Notice**
The issue tracker is **ONLY** used for reporting bugs. New features should be discussed in our [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) community or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
Incomplete or non-reproducible issues may be closed, but we are here to help! If you encounter difficulties reproducing the bug or need assistance, please reach out to our community channels mentioned above.
Thank you for your understanding and cooperation!
**Bug Description** **Bug Description**
@ -36,8 +42,9 @@ The Issue tracker is **ONLY** used for reporting bugs. New features should be di
<!-- Please complete the following information --> <!-- Please complete the following information -->
- Cloud or Self-hosted
- Ghostfolio Version X.Y.Z - Ghostfolio Version X.Y.Z
- Cloud or Self-hosted
- Experimental Features enabled or disabled
- Browser - Browser
- OS - OS

View File

@ -1,3 +1,7 @@
/.nx/cache /.nx/cache
# Issue: https://github.com/prettier/prettier/issues/15650
/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html
/dist /dist
/test/import /test/import

View File

@ -5,6 +5,125 @@ 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).
## 2.37.0 - 2024-01-11
### Changed
- Improved the chart size in the asset profile details dialog of the admin control
- Updated the `docker compose` instructions to _Compose V2_ in the documentation
### Fixed
- Fixed the hidden fifth tab on mobile
## 2.36.0 - 2024-01-07
### Added
- Extended the assistant by a tag selector (experimental)
- Added support to set a _CoinGecko_ Demo API key via environment variable (`API_KEY_COINGECKO_DEMO`)
- Added support to set a _CoinGecko_ Pro API key via environment variable (`API_KEY_COINGECKO_PRO`)
### Changed
- Improved the language localization for German (`de`)
- Removed the `AccountType` enum
- Refreshed the cryptocurrencies list
## 2.35.0 - 2024-01-06
### Added
- Added support to grant private access
- Added a hint for _Time-Weighted Rate of Return_ (TWR) to the portfolio summary tab on the home page
- Added support for REST APIs (`JSON`) via the scraper configuration
- Enabled the _Redis_ authentication in the `docker-compose` files
- Set up a git-hook to format the code before any commit
### Changed
- Improved the user interface of the access table to share the portfolio
- Improved the style of the assistant (experimental)
## 2.34.0 - 2024-01-02
### Added
- Extended the assistant by a date range selector (experimental)
- Added a button to test the scraper configuration in the asset profile details dialog of the admin control
### Changed
- Improved the style of the _Top 3_ and _Bottom 3_ performers on the analysis page
- Upgraded `Nx` from version `17.2.7` to `17.2.8`
### Fixed
- Improved the time-weighted performance calculation for `1D`
- Improved the tabs on iOS (_Add to Home Screen_)
## 2.33.0 - 2023-12-31
### Added
- Added support to edit the currency of asset profiles with `MANUAL` data source in the asset profile details dialog of the admin control panel
- Added a hint for the community languages in the user settings
### Changed
- Changed the performance calculation to a time-weighted approach
- Normalized the benchmark by currency in the benchmark comparator
- Increased the timeout to load currencies in the exchange rate data service
- Exposed the environment variable `REQUEST_TIMEOUT`
- Used the `HasPermission` annotation in endpoints
- Improved the language localization for German (`de`)
- Upgraded `ng-extract-i18n-merge` from version `2.9.0` to `2.9.1`
- Upgraded `Nx` from version `17.2.5` to `17.2.7`
### Fixed
- Improved the handling of derived currencies (`USX`)
## 2.32.0 - 2023-12-26
### Added
- Added support to search for an asset profile by `id` as an administrator
### Changed
- Set the select column of the lazy-loaded activities table to stick at the end (experimental)
- Dropped the activity id in the activities import
- Improved the validation of the currency management in the admin control panel
- Improved the performance of the value redaction interceptor for the impersonation mode by eliminating `cloneDeep`
- Modernized the `Nx` executors
- `@nx/eslint:lint`
- `@nx/webpack:webpack`
- Upgraded `prettier` from version `3.1.0` to `3.1.1`
- Upgraded `prisma` from version `5.7.0` to `5.7.1`
### Fixed
- Reset the letter spacing in buttons
## 2.31.0 - 2023-12-16
### Changed
- Introduced the lazy-loaded activities table to the account detail dialog (experimental)
- Introduced the lazy-loaded activities table to the import activities dialog (experimental)
- Introduced the lazy-loaded activities table to the position detail dialog (experimental)
- Improved the font weight in the value component
- Improved the language localization for Türkçe (`tr`)
- Upgraded `angular` from version `17.0.4` to `17.0.7`
- Upgraded to _Inter_ 4 font family
- Upgraded `Nx` from version `17.0.2` to `17.2.5`
### Fixed
- Fixed the loading state in the lazy-loaded activities table on the portfolio activities page (experimental)
- Fixed the edit of activity in the lazy-loaded activities table on the portfolio activities page (experimental)
## 2.30.0 - 2023-12-12 ## 2.30.0 - 2023-12-12
### Added ### Added
@ -264,7 +383,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Changed the users table in the admin control panel to an `@angular/material` data table - Changed the users table in the admin control panel to an `@angular/material` data table
- Improved the styling of the membership status - Improved the style of the membership status
### Fixed ### Fixed
@ -1439,7 +1558,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Improved the styling in the admin control panel - Improved the style in the admin control panel
- Removed the _Google Play_ badge from the landing page - Removed the _Google Play_ badge from the landing page
- Upgraded `eslint` dependencies - Upgraded `eslint` dependencies
@ -2194,7 +2313,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Simplified the initialization of the exchange rate service - Simplified the initialization of the exchange rate service
- Improved the orders query for `assetClass` with symbol profile overrides - Improved the orders query for `assetClass` with symbol profile overrides
- Improved the styling of the benchmarks in the markets overview - Improved the style of the benchmarks in the markets overview
### Todo ### Todo
@ -2528,7 +2647,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### Fixed
- Fixed a styling issue in the benchmark component on mobile - Fixed a style issue in the benchmark component on mobile
## 1.152.0 - 26.05.2022 ## 1.152.0 - 26.05.2022
@ -3135,7 +3254,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### Fixed
- Fixed the styling in the footer row of the activities table - Fixed the style in the footer row of the activities table
## 1.106.0 - 23.01.2022 ## 1.106.0 - 23.01.2022
@ -3903,7 +4022,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Improved the wording for the _Restricted View_: _Presenter View_ - Improved the wording for the _Restricted View_: _Presenter View_
- Improved the styling of the tables - Improved the style of the tables
- Ignored cash assets in the allocation chart by sector, continent and country - Ignored cash assets in the allocation chart by sector, continent and country
### Fixed ### Fixed
@ -4106,8 +4225,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Improved the styling of the current pricing plan - Improved the style of the current pricing plan
- Improved the styling of the transaction type badge - Improved the style of the transaction type badge
- Set the public _Stripe_ key dynamically - Set the public _Stripe_ key dynamically
- Upgraded `angular-material-css-vars` from version `2.0.0` to `2.1.0` - Upgraded `angular-material-css-vars` from version `2.0.0` to `2.1.0`
@ -4467,7 +4586,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Improved the users table styling of the admin control panel - Improved the users table style of the admin control panel
- Improved the background colors in the dark mode - Improved the background colors in the dark mode
## 0.92.0 - 25.04.2021 ## 0.92.0 - 25.04.2021
@ -4491,7 +4610,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Improved the styling of the rules in the _X-ray_ section - Improved the style of the rules in the _X-ray_ section
## 0.90.0 - 22.04.2021 ## 0.90.0 - 22.04.2021
@ -4686,7 +4805,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Improved the alignment of the _Why Ghostfolio?_ section - Improved the alignment of the _Why Ghostfolio?_ section
- Improved the styling of the _Fear & Greed Index_ (market mood) - Improved the style of the _Fear & Greed Index_ (market mood)
## 0.73.0 - 31.03.2021 ## 0.73.0 - 31.03.2021
@ -4732,7 +4851,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Improved the styling in the _X-ray_ section - Improved the style in the _X-ray_ section
## 0.70.0 - 27.03.2021 ## 0.70.0 - 27.03.2021
@ -5027,7 +5146,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### Fixed
- Only show relevant data in the position detail dialog - Only show relevant data in the position detail dialog
- Improved the performance chart styling in Safari - Improved the performance chart style in Safari
## 0.40.0 - 01.03.2021 ## 0.40.0 - 01.03.2021

View File

@ -49,7 +49,7 @@ Ghostfolio is for you if you are...
- ✅ Create, update and delete transactions - ✅ Create, update and delete transactions
- ✅ Multi account management - ✅ Multi account management
- ✅ Portfolio performance for `Today`, `YTD`, `1Y`, `5Y`, `Max` - ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `YTD`, `1Y`, `5Y`, `Max`
- ✅ Various charts - ✅ Various charts
- ✅ Static analysis to identify potential risks in your portfolio - ✅ Static analysis to identify potential risks in your portfolio
- ✅ Import and export transactions - ✅ Import and export transactions
@ -87,19 +87,22 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
### Supported Environment Variables ### Supported Environment Variables
| Name | Default Value | Description | | Name | Default Value | Description |
| ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | ------------------------ | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens | | `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` | | `API_KEY_COINGECKO_DEMO` |   | The _CoinGecko_ Demo API key |
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on | | `API_KEY_COINGECKO_PRO` |   | The _CoinGecko_ Pro API |
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) | | `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
| `PORT` | `3333` | The port where the Ghostfolio application will run on | | `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database | | `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database | | `PORT` | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database | | `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
| `REDIS_HOST` | | The host where _Redis_ is running | | `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
| `REDIS_PASSWORD` | | The password of _Redis_ | | `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
| `REDIS_PORT` | | The port where _Redis_ is running | | `REDIS_HOST` | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | | The password of _Redis_ |
| `REDIS_PORT` | | The port where _Redis_ is running |
| `REQUEST_TIMEOUT` | `2000` | The timeout of network requests to data providers in milliseconds |
### Run with Docker Compose ### Run with Docker Compose
@ -115,7 +118,7 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
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 --env-file ./.env -f docker/docker-compose.yml up -d docker compose --env-file ./.env -f docker/docker-compose.yml up -d
``` ```
#### b. Build and run environment #### b. Build and run environment
@ -123,8 +126,8 @@ docker-compose --env-file ./.env -f docker/docker-compose.yml up -d
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 --env-file ./.env -f docker/docker-compose.build.yml build docker compose --env-file ./.env -f docker/docker-compose.build.yml build
docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d docker compose --env-file ./.env -f docker/docker-compose.build.yml up -d
``` ```
#### Setup #### Setup
@ -135,7 +138,7 @@ docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
#### 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 --env-file ./.env -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`
At each start, the container will automatically apply the database schema migrations if needed. At each start, the container will automatically apply the database schema migrations if needed.
### Home Server Systems (Community) ### Home Server Systems (Community)
@ -155,8 +158,9 @@ Ghostfolio is available for various home server systems, including [Runtipi](htt
### Setup ### Setup
1. Run `yarn install` 1. Run `yarn install`
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 `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 1. Run `yarn database:setup` to initialize the database schema
1. Run `git config core.hooksPath ./git-hooks/` to setup git hooks
1. Start the server and the client (see [_Development_](#Development)) 1. Start the server and the client (see [_Development_](#Development))
1. Open http://localhost:4200/en in your browser 1. Open http://localhost:4200/en in your browser
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`)
@ -165,7 +169,7 @@ Ghostfolio is available for various home server systems, including [Runtipi](htt
#### Debug #### Debug
Run `yarn watch:server` and click _Launch Program_ in [Visual Studio Code](https://code.visualstudio.com) Run `yarn watch:server` and click _Debug API_ in [Visual Studio Code](https://code.visualstudio.com)
#### Serve #### Serve
@ -278,6 +282,6 @@ If you like to support this project, get [**Ghostfolio Premium**](https://ghostf
## License ## License
© 2021 - 2023 [Ghostfolio](https://ghostfol.io) © 2021 - 2024 [Ghostfolio](https://ghostfol.io)
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html). Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).

View File

@ -7,14 +7,15 @@
"generators": {}, "generators": {},
"targets": { "targets": {
"build": { "build": {
"executor": "@nrwl/webpack:webpack", "executor": "@nx/webpack:webpack",
"options": { "options": {
"outputPath": "dist/apps/api", "outputPath": "dist/apps/api",
"main": "apps/api/src/main.ts", "main": "apps/api/src/main.ts",
"tsConfig": "apps/api/tsconfig.app.json", "tsConfig": "apps/api/tsconfig.app.json",
"assets": ["apps/api/src/assets"], "assets": ["apps/api/src/assets"],
"target": "node", "target": "node",
"compiler": "tsc" "compiler": "tsc",
"webpackConfig": "apps/api/webpack.config.js"
}, },
"configurations": { "configurations": {
"production": { "production": {
@ -39,7 +40,7 @@
} }
}, },
"lint": { "lint": {
"executor": "@nrwl/linter:eslint", "executor": "@nx/eslint:lint",
"options": { "options": {
"lintFilePatterns": ["apps/api/**/*.ts"] "lintFilePatterns": ["apps/api/**/*.ts"]
} }

View File

@ -1,5 +1,8 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { Access } from '@ghostfolio/common/interfaces'; import { Access } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -24,11 +27,12 @@ import { CreateAccessDto } from './create-access.dto';
export class AccessController { export class AccessController {
public constructor( public constructor(
private readonly accessService: AccessService, private readonly accessService: AccessService,
private readonly configurationService: ConfigurationService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@Get() @Get()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getAllAccesses(): Promise<Access[]> { public async getAllAccesses(): Promise<Access[]> {
const accessesWithGranteeUser = await this.accessService.accesses({ const accessesWithGranteeUser = await this.accessService.accesses({
include: { include: {
@ -57,13 +61,15 @@ export class AccessController {
}); });
} }
@HasPermission(permissions.createAccess)
@Post() @Post()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async createAccess( public async createAccess(
@Body() data: CreateAccessDto @Body() data: CreateAccessDto
): Promise<AccessModel> { ): Promise<AccessModel> {
if ( if (
!hasPermission(this.request.user.permissions, permissions.createAccess) this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
@ -71,25 +77,29 @@ export class AccessController {
); );
} }
return this.accessService.createAccess({ try {
alias: data.alias || undefined, return await this.accessService.createAccess({
GranteeUser: data.granteeUserId alias: data.alias || undefined,
? { connect: { id: data.granteeUserId } } GranteeUser: data.granteeUserId
: undefined, ? { connect: { id: data.granteeUserId } }
User: { connect: { id: this.request.user.id } } : undefined,
}); User: { connect: { id: this.request.user.id } }
});
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
} }
@Delete(':id') @Delete(':id')
@UseGuards(AuthGuard('jwt')) @HasPermission(permissions.deleteAccess)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteAccess(@Param('id') id: string): Promise<AccessModel> { public async deleteAccess(@Param('id') id: string): Promise<AccessModel> {
const access = await this.accessService.access({ id }); const access = await this.accessService.access({ id });
if ( if (!access || access.userId !== this.request.user.id) {
!hasPermission(this.request.user.permissions, permissions.deleteAccess) ||
!access ||
access.userId !== this.request.user.id
) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN

View File

@ -1,3 +1,4 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -7,7 +8,7 @@ import { AccessService } from './access.service';
@Module({ @Module({
controllers: [AccessController], controllers: [AccessController],
exports: [AccessService], exports: [AccessService],
imports: [PrismaModule], imports: [ConfigurationModule, PrismaModule],
providers: [AccessService] providers: [AccessService]
}) })
export class AccessModule {} export class AccessModule {}

View File

@ -1,4 +1,4 @@
import { IsOptional, IsString } from 'class-validator'; import { IsOptional, IsString, IsUUID } from 'class-validator';
export class CreateAccessDto { export class CreateAccessDto {
@IsOptional() @IsOptional()
@ -6,7 +6,7 @@ export class CreateAccessDto {
alias?: string; alias?: string;
@IsOptional() @IsOptional()
@IsString() @IsUUID()
granteeUserId?: string; granteeUserId?: string;
@IsOptional() @IsOptional()

View File

@ -1,3 +1,6 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
@ -8,11 +11,11 @@ import {
UseGuards UseGuards
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AccountBalanceService } from './account-balance.service';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccountBalance } from '@prisma/client'; import { AccountBalance } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccountBalanceService } from './account-balance.service';
@Controller('account-balance') @Controller('account-balance')
export class AccountBalanceController { export class AccountBalanceController {
@ -21,8 +24,9 @@ export class AccountBalanceController {
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@HasPermission(permissions.deleteAccountBalance)
@Delete(':id') @Delete(':id')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteAccountBalance( public async deleteAccountBalance(
@Param('id') id: string @Param('id') id: string
): Promise<AccountBalance> { ): Promise<AccountBalance> {
@ -30,14 +34,7 @@ export class AccountBalanceController {
id id
}); });
if ( if (!accountBalance || accountBalance.userId !== this.request.user.id) {
!hasPermission(
this.request.user.permissions,
permissions.deleteAccountBalance
) ||
!accountBalance ||
accountBalance.userId !== this.request.user.id
) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN

View File

@ -1,5 +1,7 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
@ -7,7 +9,7 @@ import {
AccountBalancesResponse, AccountBalancesResponse,
Accounts Accounts
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { import type {
AccountWithValue, AccountWithValue,
RequestWithUser RequestWithUser
@ -47,17 +49,9 @@ export class AccountController {
) {} ) {}
@Delete(':id') @Delete(':id')
@UseGuards(AuthGuard('jwt')) @HasPermission(permissions.deleteAccount)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteAccount(@Param('id') id: string): Promise<AccountModel> { public async deleteAccount(@Param('id') id: string): Promise<AccountModel> {
if (
!hasPermission(this.request.user.permissions, permissions.deleteAccount)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const account = await this.accountService.accountWithOrders( const account = await this.accountService.accountWithOrders(
{ {
id_userId: { id_userId: {
@ -87,7 +81,7 @@ export class AccountController {
} }
@Get() @Get()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
public async getAllAccounts( public async getAllAccounts(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId
@ -102,7 +96,7 @@ export class AccountController {
} }
@Get(':id') @Get(':id')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
public async getAccountById( public async getAccountById(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
@ -122,7 +116,7 @@ export class AccountController {
} }
@Get(':id/balances') @Get(':id/balances')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
public async getAccountBalancesById( public async getAccountBalancesById(
@Param('id') id: string @Param('id') id: string
@ -133,20 +127,12 @@ export class AccountController {
}); });
} }
@HasPermission(permissions.createAccount)
@Post() @Post()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async createAccount( public async createAccount(
@Body() data: CreateAccountDto @Body() data: CreateAccountDto
): Promise<AccountModel> { ): Promise<AccountModel> {
if (
!hasPermission(this.request.user.permissions, permissions.createAccount)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
if (data.platformId) { if (data.platformId) {
const platformId = data.platformId; const platformId = data.platformId;
delete data.platformId; delete data.platformId;
@ -172,20 +158,12 @@ export class AccountController {
} }
} }
@HasPermission(permissions.updateAccount)
@Post('transfer-balance') @Post('transfer-balance')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async transferAccountBalance( public async transferAccountBalance(
@Body() { accountIdFrom, accountIdTo, balance }: TransferBalanceDto @Body() { accountIdFrom, accountIdTo, balance }: TransferBalanceDto
) { ) {
if (
!hasPermission(this.request.user.permissions, permissions.updateAccount)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const accountsOfUser = await this.accountService.getAccounts( const accountsOfUser = await this.accountService.getAccounts(
this.request.user.id this.request.user.id
); );
@ -234,18 +212,10 @@ export class AccountController {
}); });
} }
@HasPermission(permissions.updateAccount)
@Put(':id') @Put(':id')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) { public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {
if (
!hasPermission(this.request.user.permissions, permissions.updateAccount)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalAccount = await this.accountService.account({ const originalAccount = await this.accountService.account({
id_userId: { id_userId: {
id, id,

View File

@ -1,6 +1,9 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
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 { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto'; import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import { import {
@ -17,7 +20,7 @@ import {
AdminMarketDataDetails, AdminMarketDataDetails,
EnhancedSymbolProfile EnhancedSymbolProfile
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { import type {
MarketDataPreset, MarketDataPreset,
RequestWithUser RequestWithUser
@ -29,6 +32,7 @@ import {
Get, Get,
HttpException, HttpException,
Inject, Inject,
Logger,
Param, Param,
Patch, Patch,
Post, Post,
@ -54,61 +58,29 @@ export class AdminController {
private readonly adminService: AdminService, private readonly adminService: AdminService,
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly manualService: ManualService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@Get() @Get()
@UseGuards(AuthGuard('jwt')) @HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getAdminData(): Promise<AdminData> { public async getAdminData(): Promise<AdminData> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.adminService.get(); return this.adminService.get();
} }
@HasPermission(permissions.accessAdminControl)
@Post('gather') @Post('gather')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gather7Days(): Promise<void> { public async gather7Days(): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
this.dataGatheringService.gather7Days(); this.dataGatheringService.gather7Days();
} }
@HasPermission(permissions.accessAdminControl)
@Post('gather/max') @Post('gather/max')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherMax(): Promise<void> { public async gatherMax(): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const uniqueAssets = await this.dataGatheringService.getUniqueAssets(); const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
await this.dataGatheringService.addJobsToQueue( await this.dataGatheringService.addJobsToQueue(
@ -130,21 +102,10 @@ export class AdminController {
this.dataGatheringService.gatherMax(); this.dataGatheringService.gatherMax();
} }
@HasPermission(permissions.accessAdminControl)
@Post('gather/profile-data') @Post('gather/profile-data')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherProfileData(): Promise<void> { public async gatherProfileData(): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const uniqueAssets = await this.dataGatheringService.getUniqueAssets(); const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
await this.dataGatheringService.addJobsToQueue( await this.dataGatheringService.addJobsToQueue(
@ -164,24 +125,13 @@ export class AdminController {
); );
} }
@HasPermission(permissions.accessAdminControl)
@Post('gather/profile-data/:dataSource/:symbol') @Post('gather/profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherProfileDataForSymbol( public async gatherProfileDataForSymbol(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<void> { ): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
await this.dataGatheringService.addJobToQueue({ await this.dataGatheringService.addJobToQueue({
data: { data: {
dataSource, dataSource,
@ -196,47 +146,25 @@ export class AdminController {
} }
@Post('gather/:dataSource/:symbol') @Post('gather/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@HasPermission(permissions.accessAdminControl)
public async gatherSymbol( public async gatherSymbol(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<void> { ): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
this.dataGatheringService.gatherSymbol({ dataSource, symbol }); this.dataGatheringService.gatherSymbol({ dataSource, symbol });
return; return;
} }
@HasPermission(permissions.accessAdminControl)
@Post('gather/:dataSource/:symbol/:dateString') @Post('gather/:dataSource/:symbol/:dateString')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherSymbolForDate( public async gatherSymbolForDate(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('dateString') dateString: string, @Param('dateString') dateString: string,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<MarketData> { ): Promise<MarketData> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const date = parseISO(dateString); const date = parseISO(dateString);
if (!isDate(date)) { if (!isDate(date)) {
@ -254,7 +182,8 @@ export class AdminController {
} }
@Get('market-data') @Get('market-data')
@UseGuards(AuthGuard('jwt')) @HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getMarketData( public async getMarketData(
@Query('assetSubClasses') filterByAssetSubClasses?: string, @Query('assetSubClasses') filterByAssetSubClasses?: string,
@Query('presetId') presetId?: MarketDataPreset, @Query('presetId') presetId?: MarketDataPreset,
@ -264,18 +193,6 @@ export class AdminController {
@Query('sortDirection') sortDirection?: Prisma.SortOrder, @Query('sortDirection') sortDirection?: Prisma.SortOrder,
@Query('take') take?: number @Query('take') take?: number
): Promise<AdminMarketData> { ): Promise<AdminMarketData> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAssetSubClasses, filterByAssetSubClasses,
filterBySearchQuery filterBySearchQuery
@ -292,45 +209,47 @@ export class AdminController {
} }
@Get('market-data/:dataSource/:symbol') @Get('market-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt')) @HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getMarketDataBySymbol( public async getMarketDataBySymbol(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<AdminMarketDataDetails> { ): Promise<AdminMarketDataDetails> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.adminService.getMarketDataBySymbol({ dataSource, symbol }); return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
} }
@HasPermission(permissions.accessAdminControl)
@Post('market-data/:dataSource/:symbol/test')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async testMarketData(
@Body() data: { scraperConfiguration: string },
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<{ price: number }> {
try {
const scraperConfiguration = JSON.parse(data.scraperConfiguration);
const price = await this.manualService.test(scraperConfiguration);
if (price) {
return { price };
}
throw new Error('Could not parse the current market price');
} catch (error) {
Logger.error(error);
throw new HttpException(error.message, StatusCodes.BAD_REQUEST);
}
}
@HasPermission(permissions.accessAdminControl)
@Post('market-data/:dataSource/:symbol') @Post('market-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateMarketData( public async updateMarketData(
@Body() data: UpdateBulkMarketDataDto, @Body() data: UpdateBulkMarketDataDto,
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
) { ) {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map( const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map(
({ date, marketPrice }) => ({ ({ date, marketPrice }) => ({
dataSource, dataSource,
@ -349,26 +268,15 @@ export class AdminController {
/** /**
* @deprecated * @deprecated
*/ */
@HasPermission(permissions.accessAdminControl)
@Put('market-data/:dataSource/:symbol/:dateString') @Put('market-data/:dataSource/:symbol/:dateString')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async update( public async update(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('dateString') dateString: string, @Param('dateString') dateString: string,
@Param('symbol') symbol: string, @Param('symbol') symbol: string,
@Body() data: UpdateMarketDataDto @Body() data: UpdateMarketDataDto
) { ) {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const date = parseISO(dateString); const date = parseISO(dateString);
return this.marketDataService.updateMarketData({ return this.marketDataService.updateMarketData({
@ -383,24 +291,14 @@ export class AdminController {
}); });
} }
@HasPermission(permissions.accessAdminControl)
@Post('profile-data/:dataSource/:symbol') @Post('profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async addProfileData( public async addProfileData(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<SymbolProfile | never> { ): Promise<SymbolProfile | never> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.adminService.addAssetProfile({ return this.adminService.addAssetProfile({
dataSource, dataSource,
symbol, symbol,
@ -409,45 +307,23 @@ export class AdminController {
} }
@Delete('profile-data/:dataSource/:symbol') @Delete('profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt')) @HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteProfileData( public async deleteProfileData(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<void> { ): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.adminService.deleteProfileData({ dataSource, symbol }); return this.adminService.deleteProfileData({ dataSource, symbol });
} }
@HasPermission(permissions.accessAdminControl)
@Patch('profile-data/:dataSource/:symbol') @Patch('profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async patchAssetProfileData( public async patchAssetProfileData(
@Body() assetProfileData: UpdateAssetProfileDto, @Body() assetProfileData: UpdateAssetProfileDto,
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<EnhancedSymbolProfile> { ): Promise<EnhancedSymbolProfile> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.adminService.patchAssetProfileData({ return this.adminService.patchAssetProfileData({
...assetProfileData, ...assetProfileData,
dataSource, dataSource,
@ -455,24 +331,13 @@ export class AdminController {
}); });
} }
@HasPermission(permissions.accessAdminControl)
@Put('settings/:key') @Put('settings/:key')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateProperty( public async updateProperty(
@Param('key') key: string, @Param('key') key: string,
@Body() data: PropertyDto @Body() data: PropertyDto
) { ) {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return await this.adminService.putSetting(key, data.value); return await this.adminService.putSetting(key, data.value);
} }
} }

View File

@ -176,6 +176,7 @@ export class AdminService {
if (searchQuery) { if (searchQuery) {
where.OR = [ where.OR = [
{ id: { mode: 'insensitive', startsWith: searchQuery } },
{ isin: { mode: 'insensitive', startsWith: searchQuery } }, { isin: { mode: 'insensitive', startsWith: searchQuery } },
{ name: { mode: 'insensitive', startsWith: searchQuery } }, { name: { mode: 'insensitive', startsWith: searchQuery } },
{ symbol: { mode: 'insensitive', startsWith: searchQuery } } { symbol: { mode: 'insensitive', startsWith: searchQuery } }
@ -320,6 +321,7 @@ export class AdminService {
assetClass, assetClass,
assetSubClass, assetSubClass,
comment, comment,
currency,
dataSource, dataSource,
name, name,
scraperConfiguration, scraperConfiguration,
@ -330,6 +332,7 @@ export class AdminService {
assetClass, assetClass,
assetSubClass, assetSubClass,
comment, comment,
currency,
dataSource, dataSource,
name, name,
scraperConfiguration, scraperConfiguration,

View File

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

View File

@ -14,6 +14,10 @@ export class UpdateAssetProfileDto {
@IsOptional() @IsOptional()
comment?: string; comment?: string;
@IsString()
@IsOptional()
currency?: string;
@IsString() @IsString()
@IsOptional() @IsOptional()
name?: string; name?: string;

View File

@ -1,40 +1,18 @@
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import type { RequestWithUser } from '@ghostfolio/common/types'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { import { permissions } from '@ghostfolio/common/permissions';
Controller, import { Controller, Delete, Param, UseGuards } from '@nestjs/common';
Delete,
HttpException,
Inject,
Param,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@Controller('auth-device') @Controller('auth-device')
export class AuthDeviceController { export class AuthDeviceController {
public constructor( public constructor(private readonly authDeviceService: AuthDeviceService) {}
private readonly authDeviceService: AuthDeviceService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Delete(':id') @Delete(':id')
@UseGuards(AuthGuard('jwt')) @HasPermission(permissions.deleteAuthDevice)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteAuthDevice(@Param('id') id: string): Promise<void> { public async deleteAuthDevice(@Param('id') id: string): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.deleteAuthDevice
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
await this.authDeviceService.deleteAuthDevice({ id }); await this.authDeviceService.deleteAuthDevice({ id });
} }
} }

View File

@ -1,4 +1,5 @@
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { OAuthResponse } from '@ghostfolio/common/interfaces'; import { OAuthResponse } from '@ghostfolio/common/interfaces';
@ -118,13 +119,13 @@ export class AuthController {
} }
@Get('webauthn/generate-registration-options') @Get('webauthn/generate-registration-options')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async generateRegistrationOptions() { public async generateRegistrationOptions() {
return this.webAuthService.generateRegistrationOptions(); return this.webAuthService.generateRegistrationOptions();
} }
@Post('webauthn/verify-attestation') @Post('webauthn/verify-attestation')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async verifyAttestation( public async verifyAttestation(
@Body() body: { deviceName: string; credential: AttestationCredentialJSON } @Body() body: { deviceName: string; credential: AttestationCredentialJSON }
) { ) {

View File

@ -1,3 +1,5 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
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 type { import type {
@ -5,7 +7,7 @@ import type {
BenchmarkResponse, BenchmarkResponse,
UniqueAsset UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -33,21 +35,10 @@ export class BenchmarkController {
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@HasPermission(permissions.accessAdminControl)
@Post() @Post()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) { public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
try { try {
const benchmark = await this.benchmarkService.addBenchmark({ const benchmark = await this.benchmarkService.addBenchmark({
dataSource, dataSource,
@ -71,23 +62,12 @@ export class BenchmarkController {
} }
@Delete(':dataSource/:symbol') @Delete(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt')) @HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteBenchmark( public async deleteBenchmark(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
) { ) {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
try { try {
const benchmark = await this.benchmarkService.deleteBenchmark({ const benchmark = await this.benchmarkService.deleteBenchmark({
dataSource, dataSource,
@ -120,7 +100,7 @@ export class BenchmarkController {
} }
@Get(':dataSource/:symbol/:startDateString') @Get(':dataSource/:symbol/:startDateString')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getBenchmarkMarketDataBySymbol( public async getBenchmarkMarketDataBySymbol(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@ -128,11 +108,13 @@ export class BenchmarkController {
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<BenchmarkMarketDataDetails> { ): Promise<BenchmarkMarketDataDetails> {
const startDate = new Date(startDateString); const startDate = new Date(startDateString);
const userCurrency = this.request.user.Settings.settings.baseCurrency;
return this.benchmarkService.getMarketDataBySymbol({ return this.benchmarkService.getMarketDataBySymbol({
dataSource, dataSource,
startDate, startDate,
symbol symbol,
userCurrency
}); });
} }
} }

View File

@ -2,6 +2,7 @@ import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.mo
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
@ -17,6 +18,7 @@ import { BenchmarkService } from './benchmark.service';
imports: [ imports: [
ConfigurationModule, ConfigurationModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule,
MarketDataModule, MarketDataModule,
PrismaModule, PrismaModule,
PropertyModule, PropertyModule,

View File

@ -11,6 +11,7 @@ describe('BenchmarkService', () => {
null, null,
null, null,
null, null,
null,
null null
); );
}); });

View File

@ -1,6 +1,7 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
@ -11,7 +12,8 @@ import {
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
DATE_FORMAT, DATE_FORMAT,
calculateBenchmarkTrend calculateBenchmarkTrend,
parseDate
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
Benchmark, Benchmark,
@ -21,11 +23,11 @@ import {
UniqueAsset UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { BenchmarkTrend } from '@ghostfolio/common/types'; import { BenchmarkTrend } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { SymbolProfile } from '@prisma/client'; import { SymbolProfile } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { format, subDays } from 'date-fns'; import { format, isSameDay, subDays } from 'date-fns';
import { uniqBy } from 'lodash'; import { isNumber, last, uniqBy } from 'lodash';
import ms from 'ms'; import ms from 'ms';
@Injectable() @Injectable()
@ -34,6 +36,7 @@ export class BenchmarkService {
public constructor( public constructor(
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
@ -203,8 +206,14 @@ export class BenchmarkService {
public async getMarketDataBySymbol({ public async getMarketDataBySymbol({
dataSource, dataSource,
startDate, startDate,
symbol symbol,
}: { startDate: Date } & UniqueAsset): Promise<BenchmarkMarketDataDetails> { userCurrency
}: {
startDate: Date;
userCurrency: string;
} & UniqueAsset): Promise<BenchmarkMarketDataDetails> {
const marketData: { date: string; value: number }[] = [];
const [currentSymbolItem, marketDataItems] = await Promise.all([ const [currentSymbolItem, marketDataItems] = await Promise.all([
this.symbolService.get({ this.symbolService.get({
dataGatheringItem: { dataGatheringItem: {
@ -226,44 +235,101 @@ export class BenchmarkService {
}) })
]); ]);
const exchangeRates = await this.exchangeRateDataService.getExchangeRates({
currencyFrom: currentSymbolItem.currency,
currencyTo: userCurrency,
dates: marketDataItems.map(({ date }) => {
return date;
})
});
const exchangeRateAtStartDate =
exchangeRates[format(startDate, DATE_FORMAT)];
if (!exchangeRateAtStartDate) {
Logger.error(
`No exchange rate has been found for ${
currentSymbolItem.currency
}${userCurrency} at ${format(startDate, DATE_FORMAT)}`,
'BenchmarkService'
);
return { marketData };
}
const marketPriceAtStartDate = marketDataItems?.find(({ date }) => {
return isSameDay(date, startDate);
})?.marketPrice;
if (!marketPriceAtStartDate) {
Logger.error(
`No historical market data has been found for ${symbol} (${dataSource}) at ${format(
startDate,
DATE_FORMAT
)}`,
'BenchmarkService'
);
return { marketData };
}
const step = Math.round( const step = Math.round(
marketDataItems.length / Math.min(marketDataItems.length, MAX_CHART_ITEMS) marketDataItems.length / Math.min(marketDataItems.length, MAX_CHART_ITEMS)
); );
const marketPriceAtStartDate = marketDataItems?.[0]?.marketPrice ?? 0; let i = 0;
const response = {
marketData: [
...marketDataItems
.filter((marketDataItem, index) => {
return index % step === 0;
})
.map((marketDataItem) => {
return {
date: format(marketDataItem.date, DATE_FORMAT),
value:
marketPriceAtStartDate === 0
? 0
: this.calculateChangeInPercentage(
marketPriceAtStartDate,
marketDataItem.marketPrice
) * 100
};
})
]
};
if (currentSymbolItem?.marketPrice) { for (let marketDataItem of marketDataItems) {
response.marketData.push({ if (i % step !== 0) {
continue;
}
const exchangeRate =
exchangeRates[format(marketDataItem.date, DATE_FORMAT)];
const exchangeRateFactor =
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate)
? exchangeRate / exchangeRateAtStartDate
: 1;
marketData.push({
date: format(marketDataItem.date, DATE_FORMAT),
value:
marketPriceAtStartDate === 0
? 0
: this.calculateChangeInPercentage(
marketPriceAtStartDate,
marketDataItem.marketPrice * exchangeRateFactor
) * 100
});
}
const includesToday = isSameDay(
parseDate(last(marketData).date),
new Date()
);
if (currentSymbolItem?.marketPrice && !includesToday) {
const exchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)];
const exchangeRateFactor =
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate)
? exchangeRate / exchangeRateAtStartDate
: 1;
marketData.push({
date: format(new Date(), DATE_FORMAT), date: format(new Date(), DATE_FORMAT),
value: value:
this.calculateChangeInPercentage( this.calculateChangeInPercentage(
marketPriceAtStartDate, marketPriceAtStartDate,
currentSymbolItem.marketPrice currentSymbolItem.marketPrice * exchangeRateFactor
) * 100 ) * 100
}); });
} }
return response; return {
marketData
};
} }
public async addBenchmark({ public async addBenchmark({

View File

@ -1,39 +1,18 @@
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 { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import type { RequestWithUser } from '@ghostfolio/common/types'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { import { permissions } from '@ghostfolio/common/permissions';
Controller, import { Controller, Post, UseGuards } from '@nestjs/common';
HttpException,
Inject,
Post,
UseGuards
} from '@nestjs/common';
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 redisCacheService: RedisCacheService) {}
private readonly redisCacheService: RedisCacheService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@HasPermission(permissions.accessAdminControl)
@Post('flush') @Post('flush')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async flushCache(): Promise<void> { public async flushCache(): Promise<void> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.redisCacheService.reset(); return this.redisCacheService.reset();
} }
} }

View File

@ -1,3 +1,4 @@
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import { import {
Controller, Controller,
@ -19,7 +20,7 @@ export class ExchangeRateController {
) {} ) {}
@Get(':symbol/:dateString') @Get(':symbol/:dateString')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getExchangeRate( public async getExchangeRate(
@Param('dateString') dateString: string, @Param('dateString') dateString: string,
@Param('symbol') symbol: string @Param('symbol') symbol: string

View File

@ -1,3 +1,4 @@
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { Export } from '@ghostfolio/common/interfaces'; import { Export } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Get, Inject, Query, UseGuards } from '@nestjs/common'; import { Controller, Get, Inject, Query, UseGuards } from '@nestjs/common';
@ -14,12 +15,13 @@ export class ExportController {
) {} ) {}
@Get() @Get()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async export( public async export(
@Query('activityIds') activityIds?: string[] @Query('activityIds') activityIds?: string[]
): Promise<Export> { ): Promise<Export> {
return this.exportService.export({ return this.exportService.export({
activityIds, activityIds,
userCurrency: this.request.user.Settings.settings.baseCurrency,
userId: this.request.user.id userId: this.request.user.id
}); });
} }

View File

@ -13,9 +13,11 @@ export class ExportService {
public async export({ public async export({
activityIds, activityIds,
userCurrency,
userId userId
}: { }: {
activityIds?: string[]; activityIds?: string[];
userCurrency: string;
userId: string; userId: string;
}): Promise<Export> { }): Promise<Export> {
const accounts = ( const accounts = (
@ -39,10 +41,13 @@ export class ExportService {
} }
); );
let activities = await this.orderService.orders({ let { activities } = await this.orderService.getOrders({
include: { SymbolProfile: true }, userCurrency,
orderBy: { date: 'desc' }, userId,
where: { userId } includeDrafts: true,
sortColumn: 'date',
sortDirection: 'asc',
withExcludedAccounts: true
}); });
if (activityIds) { if (activityIds) {

View File

@ -1,3 +1,5 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
@ -34,7 +36,8 @@ export class ImportController {
) {} ) {}
@Post() @Post()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@HasPermission(permissions.createOrder)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async import( public async import(
@ -42,11 +45,7 @@ export class ImportController {
@Query('dryRun') isDryRun?: boolean @Query('dryRun') isDryRun?: boolean
): Promise<ImportResponse> { ): Promise<ImportResponse> {
if ( if (
!hasPermission( !hasPermission(this.request.user.permissions, permissions.createAccount)
this.request.user.permissions,
permissions.createAccount
) ||
!hasPermission(this.request.user.permissions, permissions.createOrder)
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
@ -92,7 +91,7 @@ export class ImportController {
} }
@Get('dividends/:dataSource/:symbol') @Get('dividends/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async gatherDividends( public async gatherDividends(

View File

@ -236,6 +236,7 @@ export class ImportService {
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({ const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({
activitiesDto, activitiesDto,
userCurrency,
userId userId
}); });
@ -459,15 +460,18 @@ export class ImportService {
private async extendActivitiesWithErrors({ private async extendActivitiesWithErrors({
activitiesDto, activitiesDto,
userCurrency,
userId userId
}: { }: {
activitiesDto: Partial<CreateOrderDto>[]; activitiesDto: Partial<CreateOrderDto>[];
userCurrency: string;
userId: string; userId: string;
}): Promise<Partial<Activity>[]> { }): Promise<Partial<Activity>[]> {
const existingActivities = await this.orderService.orders({ let { activities: existingActivities } = await this.orderService.getOrders({
include: { SymbolProfile: true }, userCurrency,
orderBy: { date: 'desc' }, userId,
where: { userId } includeDrafts: true,
withExcludedAccounts: true
}); });
return activitiesDto.map( return activitiesDto.map(

View File

@ -8,7 +8,6 @@ import { PropertyService } from '@ghostfolio/api/services/property/property.serv
import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
DEFAULT_REQUEST_TIMEOUT,
PROPERTY_BETTER_UPTIME_MONITOR_ID, PROPERTY_BETTER_UPTIME_MONITOR_ID,
PROPERTY_COUNTRIES_OF_SUBSCRIBERS, PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
PROPERTY_DEMO_USER_ID, PROPERTY_DEMO_USER_ID,
@ -162,7 +161,7 @@ export class InfoService {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, this.configurationService.get('REQUEST_TIMEOUT'));
const { pull_count } = await got( const { pull_count } = await got(
`https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`, `https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`,
@ -187,7 +186,7 @@ export class InfoService {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, this.configurationService.get('REQUEST_TIMEOUT'));
const { body } = await got('https://github.com/ghostfolio/ghostfolio', { const { body } = await got('https://github.com/ghostfolio/ghostfolio', {
// @ts-ignore // @ts-ignore
@ -214,7 +213,7 @@ export class InfoService {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, this.configurationService.get('REQUEST_TIMEOUT'));
const { stargazers_count } = await got( const { stargazers_count } = await got(
`https://api.github.com/repos/ghostfolio/ghostfolio`, `https://api.github.com/repos/ghostfolio/ghostfolio`,
@ -342,7 +341,7 @@ export class InfoService {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, this.configurationService.get('REQUEST_TIMEOUT'));
const { data } = await got( const { data } = await got(
`https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format( `https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format(

View File

@ -1,5 +1,5 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { HttpException, Injectable } from '@nestjs/common'; import { HttpException, Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
@ -9,6 +9,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@Injectable() @Injectable()
export class LogoService { export class LogoService {
public constructor( public constructor(
private readonly configurationService: ConfigurationService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) {} ) {}
@ -46,7 +47,7 @@ export class LogoService {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, this.configurationService.get('REQUEST_TIMEOUT'));
return got( return got(
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`, `https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,

View File

@ -1,3 +1,5 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
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';
@ -44,24 +46,16 @@ export class OrderController {
) {} ) {}
@Delete() @Delete()
@UseGuards(AuthGuard('jwt')) @HasPermission(permissions.deleteOrder)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteOrders(): Promise<number> { public async deleteOrders(): Promise<number> {
if (
!hasPermission(this.request.user.permissions, permissions.deleteOrder)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.orderService.deleteOrders({ return this.orderService.deleteOrders({
userId: this.request.user.id userId: this.request.user.id
}); });
} }
@Delete(':id') @Delete(':id')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> { public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
const order = await this.orderService.order({ id }); const order = await this.orderService.order({ id });
@ -82,7 +76,7 @@ export class OrderController {
} }
@Get() @Get()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAllOrders( public async getAllOrders(
@ -120,19 +114,11 @@ export class OrderController {
return { activities, count }; return { activities, count };
} }
@HasPermission(permissions.createOrder)
@Post() @Post()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> { public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> {
if (
!hasPermission(this.request.user.permissions, permissions.createOrder)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const order = await this.orderService.createOrder({ const order = await this.orderService.createOrder({
...data, ...data,
date: parseISO(data.date), date: parseISO(data.date),
@ -170,19 +156,16 @@ export class OrderController {
return order; return order;
} }
@HasPermission(permissions.updateOrder)
@Put(':id') @Put(':id')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) { public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
const originalOrder = await this.orderService.order({ const originalOrder = await this.orderService.order({
id id
}); });
if ( if (!originalOrder || originalOrder.userId !== this.request.user.id) {
!hasPermission(this.request.user.permissions, permissions.updateOrder) ||
!originalOrder ||
originalOrder.userId !== this.request.user.id
) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN

View File

@ -25,7 +25,7 @@ import { endOfToday, isAfter } from 'date-fns';
import { groupBy } from 'lodash'; import { groupBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { Activities, Activity } from './interfaces/activities.interface'; import { Activities } from './interfaces/activities.interface';
@Injectable() @Injectable()
export class OrderService { export class OrderService {
@ -37,34 +37,6 @@ export class OrderService {
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) {} ) {}
public async order(
orderWhereUniqueInput: Prisma.OrderWhereUniqueInput
): Promise<Order | null> {
return this.prismaService.order.findUnique({
where: orderWhereUniqueInput
});
}
public async orders(params: {
include?: Prisma.OrderInclude;
skip?: number;
take?: number;
cursor?: Prisma.OrderWhereUniqueInput;
where?: Prisma.OrderWhereInput;
orderBy?: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput>;
}): Promise<OrderWithAccount[]> {
const { include, skip, take, cursor, where, orderBy } = params;
return this.prismaService.order.findMany({
cursor,
include,
orderBy,
skip,
take,
where
});
}
public async createOrder( public async createOrder(
data: Prisma.OrderCreateInput & { data: Prisma.OrderCreateInput & {
accountId?: string; accountId?: string;
@ -379,6 +351,14 @@ export class OrderService {
return { activities, count }; return { activities, count };
} }
public async order(
orderWhereUniqueInput: Prisma.OrderWhereUniqueInput
): Promise<Order | null> {
return this.prismaService.order.findUnique({
where: orderWhereUniqueInput
});
}
public async updateOrder({ public async updateOrder({
data, data,
where where
@ -455,4 +435,24 @@ export class OrderService {
where where
}); });
} }
private async orders(params: {
include?: Prisma.OrderInclude;
skip?: number;
take?: number;
cursor?: Prisma.OrderWhereUniqueInput;
where?: Prisma.OrderWhereInput;
orderBy?: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput>;
}): Promise<OrderWithAccount[]> {
const { include, skip, take, cursor, where, orderBy } = params;
return this.prismaService.order.findMany({
cursor,
include,
orderBy,
skip,
take,
where
});
}
} }

View File

@ -1,18 +1,17 @@
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import type { RequestWithUser } from '@ghostfolio/common/types'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { permissions } from '@ghostfolio/common/permissions';
import { import {
Body, Body,
Controller, Controller,
Delete, Delete,
Get, Get,
HttpException, HttpException,
Inject,
Param, Param,
Post, Post,
Put, Put,
UseGuards UseGuards
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Platform } from '@prisma/client'; import { Platform } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -23,49 +22,30 @@ import { UpdatePlatformDto } from './update-platform.dto';
@Controller('platform') @Controller('platform')
export class PlatformController { export class PlatformController {
public constructor( public constructor(private readonly platformService: PlatformService) {}
private readonly platformService: PlatformService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Get() @Get()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getPlatforms() { public async getPlatforms() {
return this.platformService.getPlatformsWithAccountCount(); return this.platformService.getPlatformsWithAccountCount();
} }
@HasPermission(permissions.createPlatform)
@Post() @Post()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async createPlatform( public async createPlatform(
@Body() data: CreatePlatformDto @Body() data: CreatePlatformDto
): Promise<Platform> { ): Promise<Platform> {
if (
!hasPermission(this.request.user.permissions, permissions.createPlatform)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.platformService.createPlatform(data); return this.platformService.createPlatform(data);
} }
@HasPermission(permissions.updatePlatform)
@Put(':id') @Put(':id')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updatePlatform( public async updatePlatform(
@Param('id') id: string, @Param('id') id: string,
@Body() data: UpdatePlatformDto @Body() data: UpdatePlatformDto
) { ) {
if (
!hasPermission(this.request.user.permissions, permissions.updatePlatform)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalPlatform = await this.platformService.getPlatform({ const originalPlatform = await this.platformService.getPlatform({
id id
}); });
@ -88,17 +68,9 @@ export class PlatformController {
} }
@Delete(':id') @Delete(':id')
@UseGuards(AuthGuard('jwt')) @HasPermission(permissions.deletePlatform)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deletePlatform(@Param('id') id: string) { public async deletePlatform(@Param('id') id: string) {
if (
!hasPermission(this.request.user.permissions, permissions.deletePlatform)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalPlatform = await this.platformService.getPlatform({ const originalPlatform = await this.platformService.getPlatform({
id id
}); });

View File

@ -92,6 +92,7 @@ describe('PortfolioCalculator', () => {
marketPrice: 148.9, marketPrice: 148.9,
quantity: new Big('0'), quantity: new Big('0'),
symbol: 'BALN.SW', symbol: 'BALN.SW',
timeWeightedInvestment: new Big('285.8'),
transactionCount: 2 transactionCount: 2
} }
], ],

View File

@ -81,6 +81,7 @@ describe('PortfolioCalculator', () => {
marketPrice: 148.9, marketPrice: 148.9,
quantity: new Big('2'), quantity: new Big('2'),
symbol: 'BALN.SW', symbol: 'BALN.SW',
timeWeightedInvestment: new Big('273.2'),
transactionCount: 1 transactionCount: 1
} }
], ],

View File

@ -73,10 +73,10 @@ describe('PortfolioCalculator', () => {
currentValue: new Big('13657.2'), currentValue: new Big('13657.2'),
errors: [], errors: [],
grossPerformance: new Big('27172.74'), grossPerformance: new Big('27172.74'),
grossPerformancePercentage: new Big('42.40043067128546016291'), grossPerformancePercentage: new Big('42.41978276196153750666'),
hasErrors: false, hasErrors: false,
netPerformance: new Big('27172.74'), netPerformance: new Big('27172.74'),
netPerformancePercentage: new Big('42.40043067128546016291'), netPerformancePercentage: new Big('42.41978276196153750666'),
positions: [ positions: [
{ {
averagePrice: new Big('320.43'), averagePrice: new Big('320.43'),
@ -85,13 +85,14 @@ describe('PortfolioCalculator', () => {
fee: new Big('0'), fee: new Big('0'),
firstBuyDate: '2015-01-01', firstBuyDate: '2015-01-01',
grossPerformance: new Big('27172.74'), grossPerformance: new Big('27172.74'),
grossPerformancePercentage: new Big('42.40043067128546016291'), grossPerformancePercentage: new Big('42.41978276196153750666'),
investment: new Big('320.43'), investment: new Big('320.43'),
netPerformance: new Big('27172.74'), netPerformance: new Big('27172.74'),
netPerformancePercentage: new Big('42.40043067128546016291'), netPerformancePercentage: new Big('42.41978276196153750666'),
marketPrice: 13657.2, marketPrice: 13657.2,
quantity: new Big('1'), quantity: new Big('1'),
symbol: 'BTCUSD', symbol: 'BTCUSD',
timeWeightedInvestment: new Big('640.56763686131386861314'),
transactionCount: 2 transactionCount: 2
} }
], ],

View File

@ -73,10 +73,10 @@ describe('PortfolioCalculator', () => {
currentValue: new Big('87.8'), currentValue: new Big('87.8'),
errors: [], errors: [],
grossPerformance: new Big('21.93'), grossPerformance: new Big('21.93'),
grossPerformancePercentage: new Big('0.14465699208443271768'), grossPerformancePercentage: new Big('0.15113417083448194384'),
hasErrors: false, hasErrors: false,
netPerformance: new Big('17.68'), netPerformance: new Big('17.68'),
netPerformancePercentage: new Big('0.11662269129287598945'), netPerformancePercentage: new Big('0.12184460284330327256'),
positions: [ positions: [
{ {
averagePrice: new Big('75.80'), averagePrice: new Big('75.80'),
@ -85,13 +85,14 @@ describe('PortfolioCalculator', () => {
fee: new Big('4.25'), fee: new Big('4.25'),
firstBuyDate: '2022-03-07', firstBuyDate: '2022-03-07',
grossPerformance: new Big('21.93'), grossPerformance: new Big('21.93'),
grossPerformancePercentage: new Big('0.14465699208443271768'), grossPerformancePercentage: new Big('0.15113417083448194384'),
investment: new Big('75.80'), investment: new Big('75.80'),
netPerformance: new Big('17.68'), netPerformance: new Big('17.68'),
netPerformancePercentage: new Big('0.11662269129287598945'), netPerformancePercentage: new Big('0.12184460284330327256'),
marketPrice: 87.8, marketPrice: 87.8,
quantity: new Big('1'), quantity: new Big('1'),
symbol: 'NOVN.SW', symbol: 'NOVN.SW',
timeWeightedInvestment: new Big('145.10285714285714285714'),
transactionCount: 2 transactionCount: 2
} }
], ],

View File

@ -112,6 +112,7 @@ describe('PortfolioCalculator', () => {
marketPrice: 87.8, marketPrice: 87.8,
quantity: new Big('0'), quantity: new Big('0'),
symbol: 'NOVN.SW', symbol: 'NOVN.SW',
timeWeightedInvestment: new Big('151.6'),
transactionCount: 2 transactionCount: 2
} }
], ],

View File

@ -15,6 +15,7 @@ import {
addMilliseconds, addMilliseconds,
addMonths, addMonths,
addYears, addYears,
differenceInDays,
endOfDay, endOfDay,
format, format,
isAfter, isAfter,
@ -43,7 +44,7 @@ import { TransactionPointSymbol } from './interfaces/transaction-point-symbol.in
import { TransactionPoint } from './interfaces/transaction-point.interface'; import { TransactionPoint } from './interfaces/transaction-point.interface';
export class PortfolioCalculator { export class PortfolioCalculator {
private static readonly CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT = private static readonly CALCULATE_PERCENTAGE_PERFORMANCE_WITH_TIME_WEIGHTED_INVESTMENT =
true; true;
private static readonly ENABLE_LOGGING = false; private static readonly ENABLE_LOGGING = false;
@ -238,12 +239,13 @@ export class PortfolioCalculator {
} }
} }
const valuesByDate: { const accumulatedValuesByDate: {
[date: string]: { [date: string]: {
maxTotalInvestmentValue: Big; maxTotalInvestmentValue: Big;
totalCurrentValue: Big; totalCurrentValue: Big;
totalInvestmentValue: Big; totalInvestmentValue: Big;
totalNetPerformanceValue: Big; totalNetPerformanceValue: Big;
totalTimeWeightedInvestmentValue: Big;
}; };
} = {}; } = {};
@ -253,6 +255,7 @@ export class PortfolioCalculator {
investmentValues: { [date: string]: Big }; investmentValues: { [date: string]: Big };
maxInvestmentValues: { [date: string]: Big }; maxInvestmentValues: { [date: string]: Big };
netPerformanceValues: { [date: string]: Big }; netPerformanceValues: { [date: string]: Big };
timeWeightedInvestmentValues: { [date: string]: Big };
}; };
} = {}; } = {};
@ -261,7 +264,8 @@ export class PortfolioCalculator {
currentValues, currentValues,
investmentValues, investmentValues,
maxInvestmentValues, maxInvestmentValues,
netPerformanceValues netPerformanceValues,
timeWeightedInvestmentValues
} = this.getSymbolMetrics({ } = this.getSymbolMetrics({
end, end,
marketSymbolMap, marketSymbolMap,
@ -275,7 +279,8 @@ export class PortfolioCalculator {
currentValues, currentValues,
investmentValues, investmentValues,
maxInvestmentValues, maxInvestmentValues,
netPerformanceValues netPerformanceValues,
timeWeightedInvestmentValues
}; };
} }
@ -293,38 +298,50 @@ export class PortfolioCalculator {
symbolValues.maxInvestmentValues?.[dateString] ?? new Big(0); symbolValues.maxInvestmentValues?.[dateString] ?? new Big(0);
const netPerformanceValue = const netPerformanceValue =
symbolValues.netPerformanceValues?.[dateString] ?? new Big(0); symbolValues.netPerformanceValues?.[dateString] ?? new Big(0);
const timeWeightedInvestmentValue =
symbolValues.timeWeightedInvestmentValues?.[dateString] ?? new Big(0);
valuesByDate[dateString] = { accumulatedValuesByDate[dateString] = {
totalCurrentValue: ( totalCurrentValue: (
valuesByDate[dateString]?.totalCurrentValue ?? new Big(0) accumulatedValuesByDate[dateString]?.totalCurrentValue ?? new Big(0)
).add(currentValue), ).add(currentValue),
totalInvestmentValue: ( totalInvestmentValue: (
valuesByDate[dateString]?.totalInvestmentValue ?? new Big(0) accumulatedValuesByDate[dateString]?.totalInvestmentValue ??
new Big(0)
).add(investmentValue), ).add(investmentValue),
totalTimeWeightedInvestmentValue: (
accumulatedValuesByDate[dateString]
?.totalTimeWeightedInvestmentValue ?? new Big(0)
).add(timeWeightedInvestmentValue),
maxTotalInvestmentValue: ( maxTotalInvestmentValue: (
valuesByDate[dateString]?.maxTotalInvestmentValue ?? new Big(0) accumulatedValuesByDate[dateString]?.maxTotalInvestmentValue ??
new Big(0)
).add(maxInvestmentValue), ).add(maxInvestmentValue),
totalNetPerformanceValue: ( totalNetPerformanceValue: (
valuesByDate[dateString]?.totalNetPerformanceValue ?? new Big(0) accumulatedValuesByDate[dateString]?.totalNetPerformanceValue ??
new Big(0)
).add(netPerformanceValue) ).add(netPerformanceValue)
}; };
} }
} }
return Object.entries(valuesByDate).map(([date, values]) => { return Object.entries(accumulatedValuesByDate).map(([date, values]) => {
const { const {
maxTotalInvestmentValue, maxTotalInvestmentValue,
totalCurrentValue, totalCurrentValue,
totalInvestmentValue, totalInvestmentValue,
totalNetPerformanceValue totalNetPerformanceValue,
totalTimeWeightedInvestmentValue
} = values; } = values;
const netPerformanceInPercentage = maxTotalInvestmentValue.eq(0) let investmentValue =
PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_TIME_WEIGHTED_INVESTMENT
? totalTimeWeightedInvestmentValue
: maxTotalInvestmentValue;
const netPerformanceInPercentage = investmentValue.eq(0)
? 0 ? 0
: totalNetPerformanceValue : totalNetPerformanceValue.div(investmentValue).mul(100).toNumber();
.div(maxTotalInvestmentValue)
.mul(100)
.toNumber();
return { return {
date, date,
@ -447,7 +464,6 @@ export class PortfolioCalculator {
if (firstIndex > 0) { if (firstIndex > 0) {
firstIndex--; firstIndex--;
} }
const initialValues: { [symbol: string]: Big } = {};
const positions: TimelinePosition[] = []; const positions: TimelinePosition[] = [];
let hasAnySymbolMetricsErrors = false; let hasAnySymbolMetricsErrors = false;
@ -461,9 +477,9 @@ export class PortfolioCalculator {
grossPerformance, grossPerformance,
grossPerformancePercentage, grossPerformancePercentage,
hasErrors, hasErrors,
initialValue,
netPerformance, netPerformance,
netPerformancePercentage netPerformancePercentage,
timeWeightedInvestment
} = this.getSymbolMetrics({ } = this.getSymbolMetrics({
end, end,
marketSymbolMap, marketSymbolMap,
@ -472,9 +488,9 @@ export class PortfolioCalculator {
}); });
hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors; hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors;
initialValues[item.symbol] = initialValue;
positions.push({ positions.push({
timeWeightedInvestment,
averagePrice: item.quantity.eq(0) averagePrice: item.quantity.eq(0)
? new Big(0) ? new Big(0)
: item.investment.div(item.quantity), : item.investment.div(item.quantity),
@ -509,7 +525,7 @@ export class PortfolioCalculator {
} }
} }
const overall = this.calculateOverallPerformance(positions, initialValues); const overall = this.calculateOverallPerformance(positions);
return { return {
...overall, ...overall,
@ -732,18 +748,13 @@ export class PortfolioCalculator {
}; };
} }
private calculateOverallPerformance( private calculateOverallPerformance(positions: TimelinePosition[]) {
positions: TimelinePosition[],
initialValues: { [symbol: string]: Big }
) {
let currentValue = new Big(0); let currentValue = new Big(0);
let grossPerformance = new Big(0); let grossPerformance = new Big(0);
let grossPerformancePercentage = new Big(0);
let hasErrors = false; let hasErrors = false;
let netPerformance = new Big(0); let netPerformance = new Big(0);
let netPerformancePercentage = new Big(0);
let sumOfWeights = new Big(0);
let totalInvestment = new Big(0); let totalInvestment = new Big(0);
let totalTimeWeightedInvestment = new Big(0);
for (const currentPosition of positions) { for (const currentPosition of positions) {
if (currentPosition.marketPrice) { if (currentPosition.marketPrice) {
@ -766,21 +777,9 @@ export class PortfolioCalculator {
hasErrors = true; hasErrors = true;
} }
if (currentPosition.grossPerformancePercentage) { if (currentPosition.timeWeightedInvestment) {
// Use the average from the initial value and the current investment as totalTimeWeightedInvestment = totalTimeWeightedInvestment.plus(
// a weight currentPosition.timeWeightedInvestment
const weight = (initialValues[currentPosition.symbol] ?? new Big(0))
.plus(currentPosition.investment)
.div(2);
sumOfWeights = sumOfWeights.plus(weight);
grossPerformancePercentage = grossPerformancePercentage.plus(
currentPosition.grossPerformancePercentage.mul(weight)
);
netPerformancePercentage = netPerformancePercentage.plus(
currentPosition.netPerformancePercentage.mul(weight)
); );
} else if (!currentPosition.quantity.eq(0)) { } else if (!currentPosition.quantity.eq(0)) {
Logger.warn( Logger.warn(
@ -791,22 +790,18 @@ export class PortfolioCalculator {
} }
} }
if (sumOfWeights.gt(0)) {
grossPerformancePercentage = grossPerformancePercentage.div(sumOfWeights);
netPerformancePercentage = netPerformancePercentage.div(sumOfWeights);
} else {
grossPerformancePercentage = new Big(0);
netPerformancePercentage = new Big(0);
}
return { return {
currentValue, currentValue,
grossPerformance, grossPerformance,
grossPerformancePercentage,
hasErrors, hasErrors,
netPerformance, netPerformance,
netPerformancePercentage, totalInvestment,
totalInvestment netPerformancePercentage: totalTimeWeightedInvestment.eq(0)
? new Big(0)
: netPerformance.div(totalTimeWeightedInvestment),
grossPerformancePercentage: totalTimeWeightedInvestment.eq(0)
? new Big(0)
: grossPerformance.div(totalTimeWeightedInvestment)
}; };
} }
@ -1018,6 +1013,7 @@ export class PortfolioCalculator {
let averagePriceAtEndDate = new Big(0); let averagePriceAtEndDate = new Big(0);
let averagePriceAtStartDate = new Big(0); let averagePriceAtStartDate = new Big(0);
const currentValues: { [date: string]: Big } = {};
let feesAtStartDate = new Big(0); let feesAtStartDate = new Big(0);
let fees = new Big(0); let fees = new Big(0);
let grossPerformance = new Big(0); let grossPerformance = new Big(0);
@ -1025,12 +1021,12 @@ export class PortfolioCalculator {
let grossPerformanceFromSells = new Big(0); let grossPerformanceFromSells = new Big(0);
let initialValue: Big; let initialValue: Big;
let investmentAtStartDate: Big; let investmentAtStartDate: Big;
const currentValues: { [date: string]: Big } = {};
const investmentValues: { [date: string]: Big } = {}; const investmentValues: { [date: string]: Big } = {};
const maxInvestmentValues: { [date: string]: Big } = {}; const maxInvestmentValues: { [date: string]: Big } = {};
let lastAveragePrice = new Big(0); let lastAveragePrice = new Big(0);
let maxTotalInvestment = new Big(0); let maxTotalInvestment = new Big(0);
const netPerformanceValues: { [date: string]: Big } = {}; const netPerformanceValues: { [date: string]: Big } = {};
const timeWeightedInvestmentValues: { [date: string]: Big } = {};
let totalInvestment = new Big(0); let totalInvestment = new Big(0);
let totalInvestmentWithGrossPerformanceFromSell = new Big(0); let totalInvestmentWithGrossPerformanceFromSell = new Big(0);
let totalUnits = new Big(0); let totalUnits = new Big(0);
@ -1122,6 +1118,9 @@ export class PortfolioCalculator {
return order.itemType === 'end'; return order.itemType === 'end';
}); });
let totalInvestmentDays = 0;
let sumOfTimeWeightedInvestments = new Big(0);
for (let i = 0; i < orders.length; i += 1) { for (let i = 0; i < orders.length; i += 1) {
const order = orders[i]; const order = orders[i];
@ -1162,11 +1161,11 @@ export class PortfolioCalculator {
order.type === 'BUY' order.type === 'BUY'
? order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type)) ? order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
: totalUnits.gt(0) : totalUnits.gt(0)
? totalInvestment ? totalInvestment
.div(totalUnits) .div(totalUnits)
.mul(order.quantity) .mul(order.quantity)
.mul(this.getFactor(order.type)) .mul(this.getFactor(order.type))
: new Big(0); : new Big(0);
if (PortfolioCalculator.ENABLE_LOGGING) { if (PortfolioCalculator.ENABLE_LOGGING) {
console.log('totalInvestment', totalInvestment.toNumber()); console.log('totalInvestment', totalInvestment.toNumber());
@ -1174,6 +1173,7 @@ export class PortfolioCalculator {
console.log('transactionInvestment', transactionInvestment.toNumber()); console.log('transactionInvestment', transactionInvestment.toNumber());
} }
const totalInvestmentBeforeTransaction = totalInvestment;
totalInvestment = totalInvestment.plus(transactionInvestment); totalInvestment = totalInvestment.plus(transactionInvestment);
if (i >= indexOfStartOrder && totalInvestment.gt(maxTotalInvestment)) { if (i >= indexOfStartOrder && totalInvestment.gt(maxTotalInvestment)) {
@ -1243,14 +1243,51 @@ export class PortfolioCalculator {
grossPerformanceAtStartDate = grossPerformance; grossPerformanceAtStartDate = grossPerformance;
} }
if (isChartMode && i > indexOfStartOrder) { if (i > indexOfStartOrder) {
currentValues[order.date] = valueOfInvestment; // Only consider periods with an investment for the calculation of
netPerformanceValues[order.date] = grossPerformance // the time weighted investment
.minus(grossPerformanceAtStartDate) if (valueOfInvestmentBeforeTransaction.gt(0)) {
.minus(fees.minus(feesAtStartDate)); // Calculate the number of days since the previous order
const orderDate = new Date(order.date);
const previousOrderDate = new Date(orders[i - 1].date);
investmentValues[order.date] = totalInvestment; let daysSinceLastOrder = differenceInDays(
maxInvestmentValues[order.date] = maxTotalInvestment; orderDate,
previousOrderDate
);
// Set to at least 1 day, otherwise the transactions on the same day
// would not be considered in the time weighted calculation
if (daysSinceLastOrder <= 0) {
daysSinceLastOrder = 1;
}
// Sum up the total investment days since the start date to calculate
// the time weighted investment
totalInvestmentDays += daysSinceLastOrder;
sumOfTimeWeightedInvestments = sumOfTimeWeightedInvestments.add(
valueAtStartDate
.minus(investmentAtStartDate)
.plus(totalInvestmentBeforeTransaction)
.mul(daysSinceLastOrder)
);
}
if (isChartMode) {
currentValues[order.date] = valueOfInvestment;
netPerformanceValues[order.date] = grossPerformance
.minus(grossPerformanceAtStartDate)
.minus(fees.minus(feesAtStartDate));
investmentValues[order.date] = totalInvestment;
maxInvestmentValues[order.date] = maxTotalInvestment;
timeWeightedInvestmentValues[order.date] =
totalInvestmentDays > 0
? sumOfTimeWeightedInvestments.div(totalInvestmentDays)
: new Big(0);
}
} }
if (PortfolioCalculator.ENABLE_LOGGING) { if (PortfolioCalculator.ENABLE_LOGGING) {
@ -1274,50 +1311,79 @@ export class PortfolioCalculator {
.minus(grossPerformanceAtStartDate) .minus(grossPerformanceAtStartDate)
.minus(fees.minus(feesAtStartDate)); .minus(fees.minus(feesAtStartDate));
const timeWeightedAverageInvestmentBetweenStartAndEndDate =
totalInvestmentDays > 0
? sumOfTimeWeightedInvestments.div(totalInvestmentDays)
: new Big(0);
const maxInvestmentBetweenStartAndEndDate = valueAtStartDate.plus( const maxInvestmentBetweenStartAndEndDate = valueAtStartDate.plus(
maxTotalInvestment.minus(investmentAtStartDate) maxTotalInvestment.minus(investmentAtStartDate)
); );
const grossPerformancePercentage = let grossPerformancePercentage: Big;
PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT ||
averagePriceAtStartDate.eq(0) || if (
averagePriceAtEndDate.eq(0) || PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_TIME_WEIGHTED_INVESTMENT
orders[indexOfStartOrder].unitPrice.eq(0) ) {
? maxInvestmentBetweenStartAndEndDate.gt(0) grossPerformancePercentage =
? totalGrossPerformance.div(maxInvestmentBetweenStartAndEndDate) timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0)
: new Big(0) ? totalGrossPerformance.div(
: // This formula has the issue that buying more units with a price timeWeightedAverageInvestmentBetweenStartAndEndDate
// lower than the average buying price results in a positive
// performance even if the market price stays constant
unitPriceAtEndDate
.div(averagePriceAtEndDate)
.div(
orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
) )
.minus(1); : new Big(0);
} else {
grossPerformancePercentage =
averagePriceAtStartDate.eq(0) ||
averagePriceAtEndDate.eq(0) ||
orders[indexOfStartOrder].unitPrice.eq(0)
? maxInvestmentBetweenStartAndEndDate.gt(0)
? totalGrossPerformance.div(maxInvestmentBetweenStartAndEndDate)
: new Big(0)
: // This formula has the issue that buying more units with a price
// lower than the average buying price results in a positive
// performance even if the market price stays constant
unitPriceAtEndDate
.div(averagePriceAtEndDate)
.div(
orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
)
.minus(1);
}
const feesPerUnit = totalUnits.gt(0) const feesPerUnit = totalUnits.gt(0)
? fees.minus(feesAtStartDate).div(totalUnits) ? fees.minus(feesAtStartDate).div(totalUnits)
: new Big(0); : new Big(0);
const netPerformancePercentage = let netPerformancePercentage: Big;
PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT ||
averagePriceAtStartDate.eq(0) || if (
averagePriceAtEndDate.eq(0) || PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_TIME_WEIGHTED_INVESTMENT
orders[indexOfStartOrder].unitPrice.eq(0) ) {
? maxInvestmentBetweenStartAndEndDate.gt(0) netPerformancePercentage =
? totalNetPerformance.div(maxInvestmentBetweenStartAndEndDate) timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0)
: new Big(0) ? totalNetPerformance.div(
: // This formula has the issue that buying more units with a price timeWeightedAverageInvestmentBetweenStartAndEndDate
// lower than the average buying price results in a positive
// performance even if the market price stays constant
unitPriceAtEndDate
.minus(feesPerUnit)
.div(averagePriceAtEndDate)
.div(
orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
) )
.minus(1); : new Big(0);
} else {
netPerformancePercentage =
averagePriceAtStartDate.eq(0) ||
averagePriceAtEndDate.eq(0) ||
orders[indexOfStartOrder].unitPrice.eq(0)
? maxInvestmentBetweenStartAndEndDate.gt(0)
? totalNetPerformance.div(maxInvestmentBetweenStartAndEndDate)
: new Big(0)
: // This formula has the issue that buying more units with a price
// lower than the average buying price results in a positive
// performance even if the market price stays constant
unitPriceAtEndDate
.minus(feesPerUnit)
.div(averagePriceAtEndDate)
.div(
orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
)
.minus(1);
}
if (PortfolioCalculator.ENABLE_LOGGING) { if (PortfolioCalculator.ENABLE_LOGGING) {
console.log( console.log(
@ -1330,6 +1396,9 @@ export class PortfolioCalculator {
2 2
)} -> ${averagePriceAtEndDate.toFixed(2)} )} -> ${averagePriceAtEndDate.toFixed(2)}
Total investment: ${totalInvestment.toFixed(2)} Total investment: ${totalInvestment.toFixed(2)}
Time weighted investment: ${timeWeightedAverageInvestmentBetweenStartAndEndDate.toFixed(
2
)}
Max. total investment: ${maxTotalInvestment.toFixed(2)} Max. total investment: ${maxTotalInvestment.toFixed(2)}
Gross performance: ${totalGrossPerformance.toFixed( Gross performance: ${totalGrossPerformance.toFixed(
2 2
@ -1349,9 +1418,12 @@ export class PortfolioCalculator {
maxInvestmentValues, maxInvestmentValues,
netPerformancePercentage, netPerformancePercentage,
netPerformanceValues, netPerformanceValues,
timeWeightedInvestmentValues,
grossPerformance: totalGrossPerformance, grossPerformance: totalGrossPerformance,
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate), hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
netPerformance: totalNetPerformance netPerformance: totalNetPerformance,
timeWeightedInvestment:
timeWeightedAverageInvestmentBetweenStartAndEndDate
}; };
} }

View File

@ -1,5 +1,6 @@
import { AccessService } from '@ghostfolio/api/app/access/access.service'; import { AccessService } from '@ghostfolio/api/app/access/access.service';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { import {
hasNotDefinedValuesInObject, hasNotDefinedValuesInObject,
nullifyValuesInObject nullifyValuesInObject
@ -61,7 +62,7 @@ export class PortfolioController {
) {} ) {}
@Get('details') @Get('details')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getDetails( public async getDetails(
@ -204,7 +205,7 @@ export class PortfolioController {
} }
@Get('dividends') @Get('dividends')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getDividends( public async getDividends(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@ -254,7 +255,7 @@ export class PortfolioController {
} }
@Get('investments') @Get('investments')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getInvestments( public async getInvestments(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@ -315,7 +316,7 @@ export class PortfolioController {
} }
@Get('performance') @Get('performance')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
@Version('2') @Version('2')
public async getPerformanceV2( public async getPerformanceV2(
@ -405,7 +406,7 @@ export class PortfolioController {
} }
@Get('positions') @Get('positions')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPositions( public async getPositions(
@ -500,7 +501,7 @@ export class PortfolioController {
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getPosition( public async getPosition(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('dataSource') dataSource, @Param('dataSource') dataSource,
@ -523,7 +524,7 @@ export class PortfolioController {
} }
@Get('report') @Get('report')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getReport( public async getReport(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string
): Promise<PortfolioReport> { ): Promise<PortfolioReport> {

View File

@ -1035,25 +1035,44 @@ export class PortfolioService {
return { return {
hasErrors: currentPositions.hasErrors, hasErrors: currentPositions.hasErrors,
positions: positions.map((position) => { positions: positions.map(
return { ({
...position, averagePrice,
assetClass: symbolProfileMap[position.symbol].assetClass, currency,
assetSubClass: symbolProfileMap[position.symbol].assetSubClass, dataSource,
averagePrice: new Big(position.averagePrice).toNumber(), firstBuyDate,
grossPerformance: position.grossPerformance?.toNumber() ?? null, investment,
grossPerformancePercentage: grossPerformance,
position.grossPerformancePercentage?.toNumber() ?? null, grossPerformancePercentage,
investment: new Big(position.investment).toNumber(), netPerformance,
marketState: netPerformancePercentage,
dataProviderResponses[position.symbol]?.marketState ?? 'delayed', quantity,
name: symbolProfileMap[position.symbol].name, symbol,
netPerformance: position.netPerformance?.toNumber() ?? null, transactionCount
netPerformancePercentage: }) => {
position.netPerformancePercentage?.toNumber() ?? null, return {
quantity: new Big(position.quantity).toNumber() currency,
}; dataSource,
}) firstBuyDate,
symbol,
transactionCount,
assetClass: symbolProfileMap[symbol].assetClass,
assetSubClass: symbolProfileMap[symbol].assetSubClass,
averagePrice: averagePrice.toNumber(),
grossPerformance: grossPerformance?.toNumber() ?? null,
grossPerformancePercentage:
grossPerformancePercentage?.toNumber() ?? null,
investment: investment.toNumber(),
marketState:
dataProviderResponses[symbol]?.marketState ?? 'delayed',
name: symbolProfileMap[symbol].name,
netPerformance: netPerformance?.toNumber() ?? null,
netPerformancePercentage:
netPerformancePercentage?.toNumber() ?? null,
quantity: quantity.toNumber()
};
}
)
}; };
} }

View File

@ -1,3 +1,4 @@
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { import {
@ -37,7 +38,7 @@ export class SubscriptionController {
@Post('redeem-coupon') @Post('redeem-coupon')
@HttpCode(StatusCodes.OK) @HttpCode(StatusCodes.OK)
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async redeemCoupon(@Body() { couponCode }: { couponCode: string }) { public async redeemCoupon(@Body() { couponCode }: { couponCode: string }) {
if (!this.request.user) { if (!this.request.user) {
throw new HttpException( throw new HttpException(
@ -109,7 +110,7 @@ export class SubscriptionController {
} }
@Post('stripe/checkout-session') @Post('stripe/checkout-session')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async createCheckoutSession( public async createCheckoutSession(
@Body() { couponId, priceId }: { couponId: string; priceId: string } @Body() { couponId, priceId }: { couponId: string; priceId: string }
) { ) {

View File

@ -1,3 +1,4 @@
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
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 { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
@ -34,7 +35,7 @@ export class SymbolController {
* Must be before /:symbol * Must be before /:symbol
*/ */
@Get('lookup') @Get('lookup')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async lookupSymbol( public async lookupSymbol(
@Query('includeIndices') includeIndices: boolean = false, @Query('includeIndices') includeIndices: boolean = false,
@ -88,7 +89,7 @@ export class SymbolController {
} }
@Get(':dataSource/:symbol/:dateString') @Get(':dataSource/:symbol/:dateString')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherSymbolForDate( public async gatherSymbolForDate(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('dateString') dateString: string, @Param('dateString') dateString: string,

View File

@ -1,18 +1,17 @@
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import type { RequestWithUser } from '@ghostfolio/common/types'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { permissions } from '@ghostfolio/common/permissions';
import { import {
Body, Body,
Controller, Controller,
Delete, Delete,
Get, Get,
HttpException, HttpException,
Inject,
Param, Param,
Post, Post,
Put, Put,
UseGuards UseGuards
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Tag } from '@prisma/client'; import { Tag } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -23,40 +22,25 @@ import { UpdateTagDto } from './update-tag.dto';
@Controller('tag') @Controller('tag')
export class TagController { export class TagController {
public constructor( public constructor(private readonly tagService: TagService) {}
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly tagService: TagService
) {}
@Get() @Get()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getTags() { public async getTags() {
return this.tagService.getTagsWithActivityCount(); return this.tagService.getTagsWithActivityCount();
} }
@Post() @Post()
@UseGuards(AuthGuard('jwt')) @HasPermission(permissions.createTag)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async createTag(@Body() data: CreateTagDto): Promise<Tag> { public async createTag(@Body() data: CreateTagDto): Promise<Tag> {
if (!hasPermission(this.request.user.permissions, permissions.createTag)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.tagService.createTag(data); return this.tagService.createTag(data);
} }
@HasPermission(permissions.updateTag)
@Put(':id') @Put(':id')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateTag(@Param('id') id: string, @Body() data: UpdateTagDto) { public async updateTag(@Param('id') id: string, @Body() data: UpdateTagDto) {
if (!hasPermission(this.request.user.permissions, permissions.updateTag)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalTag = await this.tagService.getTag({ const originalTag = await this.tagService.getTag({
id id
}); });
@ -79,15 +63,9 @@ export class TagController {
} }
@Delete(':id') @Delete(':id')
@UseGuards(AuthGuard('jwt')) @HasPermission(permissions.deleteTag)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteTag(@Param('id') id: string) { public async deleteTag(@Param('id') id: string) {
if (!hasPermission(this.request.user.permissions, permissions.deleteTag)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalTag = await this.tagService.getTag({ const originalTag = await this.tagService.getTag({
id id
}); });

View File

@ -4,6 +4,7 @@ import type {
ViewMode ViewMode
} from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
import { import {
IsArray,
IsBoolean, IsBoolean,
IsISO8601, IsISO8601,
IsIn, IsIn,
@ -37,6 +38,10 @@ export class UpdateUserSettingDto {
@IsOptional() @IsOptional()
emergencyFund?: number; emergencyFund?: number;
@IsArray()
@IsOptional()
'filters.tags'?: string[];
@IsBoolean() @IsBoolean()
@IsOptional() @IsOptional()
isExperimentalFeatures?: boolean; isExperimentalFeatures?: boolean;

View File

@ -1,3 +1,5 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { User, UserSettings } from '@ghostfolio/common/interfaces'; import { User, UserSettings } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -36,12 +38,10 @@ export class UserController {
) {} ) {}
@Delete(':id') @Delete(':id')
@UseGuards(AuthGuard('jwt')) @HasPermission(permissions.deleteUser)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteUser(@Param('id') id: string): Promise<UserModel> { public async deleteUser(@Param('id') id: string): Promise<UserModel> {
if ( if (id === this.request.user.id) {
!hasPermission(this.request.user.permissions, permissions.deleteUser) ||
id === this.request.user.id
) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
@ -54,7 +54,7 @@ export class UserController {
} }
@Get() @Get()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getUser( public async getUser(
@Headers('accept-language') acceptLanguage: string @Headers('accept-language') acceptLanguage: string
): Promise<User> { ): Promise<User> {
@ -92,7 +92,7 @@ export class UserController {
} }
@Put('setting') @Put('setting')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateUserSetting(@Body() data: UpdateUserSettingDto) { public async updateUserSetting(@Body() data: UpdateUserSettingDto) {
if ( if (
size(data) === 1 && size(data) === 1 &&

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,6 @@
import { HttpException } from '@nestjs/common'; import { HttpException } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host'; import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host';
import { Test, TestingModule } from '@nestjs/testing';
import { HasPermissionGuard } from './has-permission.guard'; import { HasPermissionGuard } from './has-permission.guard';
@ -10,12 +9,8 @@ describe('HasPermissionGuard', () => {
let reflector: Reflector; let reflector: Reflector;
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ reflector = new Reflector();
providers: [HasPermissionGuard, Reflector] guard = new HasPermissionGuard(reflector);
}).compile();
guard = module.get<HasPermissionGuard>(HasPermissionGuard);
reflector = module.get<Reflector>(Reflector);
}); });
function setupReflectorSpy(returnValue: string) { function setupReflectorSpy(returnValue: string) {

View File

@ -14,17 +14,17 @@ export class HasPermissionGuard implements CanActivate {
public constructor(private reflector: Reflector) {} public constructor(private reflector: Reflector) {}
public canActivate(context: ExecutionContext): boolean { public canActivate(context: ExecutionContext): boolean {
const { user } = context.switchToHttp().getRequest();
const requiredPermission = this.reflector.get<string>( const requiredPermission = this.reflector.get<string>(
HAS_PERMISSION_KEY, HAS_PERMISSION_KEY,
context.getHandler() context.getHandler()
); );
if (!requiredPermission) { if (!requiredPermission) {
return true; // No specific permissions required // No specific permissions required
return true;
} }
const { user } = context.switchToHttp().getRequest();
if (!user || !hasPermission(user.permissions, requiredPermission)) { if (!user || !hasPermission(user.permissions, requiredPermission)) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),

View File

@ -32,9 +32,11 @@ export function nullifyValuesInObjects<T>(aObjects: T[], keys: string[]): T[] {
} }
export function redactAttributes({ export function redactAttributes({
isFirstRun = true,
object, object,
options options
}: { }: {
isFirstRun?: boolean;
object: any; object: any;
options: { attribute: string; valueMap: { [key: string]: any } }[]; options: { attribute: string; valueMap: { [key: string]: any } }[];
}): any { }): any {
@ -42,7 +44,10 @@ export function redactAttributes({
return object; return object;
} }
const redactedObject = cloneDeep(object); // Create deep clone
const redactedObject = isFirstRun
? JSON.parse(JSON.stringify(object))
: object;
for (const option of options) { for (const option of options) {
if (redactedObject.hasOwnProperty(option.attribute)) { if (redactedObject.hasOwnProperty(option.attribute)) {
@ -59,7 +64,11 @@ export function redactAttributes({
if (isArray(redactedObject[property])) { if (isArray(redactedObject[property])) {
redactedObject[property] = redactedObject[property].map( redactedObject[property] = redactedObject[property].map(
(currentObject) => { (currentObject) => {
return redactAttributes({ options, object: currentObject }); return redactAttributes({
options,
isFirstRun: false,
object: currentObject
});
} }
); );
} else if ( } else if (
@ -69,6 +78,7 @@ export function redactAttributes({
// Recursively call the function on the nested object // Recursively call the function on the nested object
redactedObject[property] = redactAttributes({ redactedObject[property] = redactAttributes({
options, options,
isFirstRun: false,
object: redactedObject[property] object: redactedObject[property]
}); });
} }

View File

@ -12,6 +12,8 @@ export class ConfigurationService {
this.environmentConfiguration = cleanEnv(process.env, { this.environmentConfiguration = cleanEnv(process.env, {
ACCESS_TOKEN_SALT: str(), ACCESS_TOKEN_SALT: str(),
ALPHA_VANTAGE_API_KEY: str({ default: '' }), ALPHA_VANTAGE_API_KEY: str({ default: '' }),
API_KEY_COINGECKO_DEMO: str({ default: '' }),
API_KEY_COINGECKO_PRO: str({ default: '' }),
BETTER_UPTIME_API_KEY: str({ default: '' }), BETTER_UPTIME_API_KEY: str({ default: '' }),
CACHE_QUOTES_TTL: num({ default: 1 }), CACHE_QUOTES_TTL: num({ default: 1 }),
CACHE_TTL: num({ default: 1 }), CACHE_TTL: num({ default: 1 }),
@ -44,6 +46,7 @@ export class ConfigurationService {
REDIS_HOST: str({ default: 'localhost' }), REDIS_HOST: str({ default: 'localhost' }),
REDIS_PASSWORD: str({ default: '' }), REDIS_PASSWORD: str({ default: '' }),
REDIS_PORT: port({ default: 6379 }), REDIS_PORT: port({ default: 6379 }),
REQUEST_TIMEOUT: num({ default: 2000 }),
ROOT_URL: str({ default: DEFAULT_ROOT_URL }), ROOT_URL: str({ default: DEFAULT_ROOT_URL }),
STRIPE_PUBLIC_KEY: str({ default: '' }), STRIPE_PUBLIC_KEY: str({ default: '' }),
STRIPE_SECRET_KEY: str({ default: '' }), STRIPE_SECRET_KEY: str({ default: '' }),

View File

@ -5,7 +5,6 @@ import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
@ -107,7 +106,7 @@ export class AlphaVantageService implements DataProviderInterface {
} }
public async getQuotes({ public async getQuotes({
requestTimeout = DEFAULT_REQUEST_TIMEOUT, requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbols symbols
}: { }: {
requestTimeout?: number; requestTimeout?: number;

View File

@ -1,13 +1,11 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
DEFAULT_CURRENCY,
DEFAULT_REQUEST_TIMEOUT
} from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces'; import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
@ -19,13 +17,30 @@ import {
SymbolProfile SymbolProfile
} from '@prisma/client'; } from '@prisma/client';
import { format, fromUnixTime, getUnixTime } from 'date-fns'; import { format, fromUnixTime, getUnixTime } from 'date-fns';
import got from 'got'; import got, { Headers } from 'got';
@Injectable() @Injectable()
export class CoinGeckoService implements DataProviderInterface { export class CoinGeckoService implements DataProviderInterface {
private readonly URL = 'https://api.coingecko.com/api/v3'; private readonly apiUrl: string;
private readonly headers: Headers = {};
public constructor() {} public constructor(
private readonly configurationService: ConfigurationService
) {
const apiKeyDemo = this.configurationService.get('API_KEY_COINGECKO_DEMO');
const apiKeyPro = this.configurationService.get('API_KEY_COINGECKO_PRO');
this.apiUrl = 'https://api.coingecko.com/api/v3';
if (apiKeyDemo) {
this.headers['x-cg-demo-api-key'] = apiKeyDemo;
}
if (apiKeyPro) {
this.apiUrl = 'https://pro-api.coingecko.com/api/v3';
this.headers['x-cg-pro-api-key'] = apiKeyPro;
}
}
public canHandle(symbol: string) { public canHandle(symbol: string) {
return true; return true;
@ -47,9 +62,10 @@ export class CoinGeckoService implements DataProviderInterface {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, this.configurationService.get('REQUEST_TIMEOUT'));
const { name } = await got(`${this.URL}/coins/${aSymbol}`, { const { name } = await got(`${this.apiUrl}/coins/${aSymbol}`, {
headers: this.headers,
// @ts-ignore // @ts-ignore
signal: abortController.signal signal: abortController.signal
}).json<any>(); }).json<any>();
@ -59,7 +75,9 @@ export class CoinGeckoService implements DataProviderInterface {
let message = error; let message = error;
if (error?.code === 'ABORT_ERR') { if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`; message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
'REQUEST_TIMEOUT'
)}ms`;
} }
Logger.error(message, 'CoinGeckoService'); Logger.error(message, 'CoinGeckoService');
@ -95,15 +113,16 @@ export class CoinGeckoService implements DataProviderInterface {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, this.configurationService.get('REQUEST_TIMEOUT'));
const { prices } = await got( const { prices } = await got(
`${ `${
this.URL this.apiUrl
}/coins/${aSymbol}/market_chart/range?vs_currency=${DEFAULT_CURRENCY.toLowerCase()}&from=${getUnixTime( }/coins/${aSymbol}/market_chart/range?vs_currency=${DEFAULT_CURRENCY.toLowerCase()}&from=${getUnixTime(
from from
)}&to=${getUnixTime(to)}`, )}&to=${getUnixTime(to)}`,
{ {
headers: this.headers,
// @ts-ignore // @ts-ignore
signal: abortController.signal signal: abortController.signal
} }
@ -141,7 +160,7 @@ export class CoinGeckoService implements DataProviderInterface {
} }
public async getQuotes({ public async getQuotes({
requestTimeout = DEFAULT_REQUEST_TIMEOUT, requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbols symbols
}: { }: {
requestTimeout?: number; requestTimeout?: number;
@ -161,10 +180,11 @@ export class CoinGeckoService implements DataProviderInterface {
}, requestTimeout); }, requestTimeout);
const quotes = await got( const quotes = await got(
`${this.URL}/simple/price?ids=${symbols.join( `${this.apiUrl}/simple/price?ids=${symbols.join(
',' ','
)}&vs_currencies=${DEFAULT_CURRENCY.toLowerCase()}`, )}&vs_currencies=${DEFAULT_CURRENCY.toLowerCase()}`,
{ {
headers: this.headers,
// @ts-ignore // @ts-ignore
signal: abortController.signal signal: abortController.signal
} }
@ -183,7 +203,9 @@ export class CoinGeckoService implements DataProviderInterface {
let message = error; let message = error;
if (error?.code === 'ABORT_ERR') { if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`; message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
'REQUEST_TIMEOUT'
)}ms`;
} }
Logger.error(message, 'CoinGeckoService'); Logger.error(message, 'CoinGeckoService');
@ -210,9 +232,10 @@ export class CoinGeckoService implements DataProviderInterface {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, this.configurationService.get('REQUEST_TIMEOUT'));
const { coins } = await got(`${this.URL}/search?query=${query}`, { const { coins } = await got(`${this.apiUrl}/search?query=${query}`, {
headers: this.headers,
// @ts-ignore // @ts-ignore
signal: abortController.signal signal: abortController.signal
}).json<any>(); }).json<any>();
@ -231,7 +254,9 @@ export class CoinGeckoService implements DataProviderInterface {
let message = error; let message = error;
if (error?.code === 'ABORT_ERR') { if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`; message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
'REQUEST_TIMEOUT'
)}ms`;
} }
Logger.error(message, 'CoinGeckoService'); Logger.error(message, 'CoinGeckoService');

View File

@ -1,6 +1,5 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
import { parseSymbol } from '@ghostfolio/common/helper'; import { parseSymbol } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { SymbolProfile } from '@prisma/client'; import { SymbolProfile } from '@prisma/client';
@ -15,7 +14,7 @@ export class OpenFigiDataEnhancerService implements DataEnhancerInterface {
) {} ) {}
public async enhance({ public async enhance({
requestTimeout = DEFAULT_REQUEST_TIMEOUT, requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
response, response,
symbol symbol
}: { }: {

View File

@ -1,5 +1,5 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
import { Country } from '@ghostfolio/common/interfaces/country.interface'; import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
@ -21,8 +21,12 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
'Information Technology': 'Technology' 'Information Technology': 'Technology'
}; };
public constructor(
private readonly configurationService: ConfigurationService
) {}
public async enhance({ public async enhance({
requestTimeout = DEFAULT_REQUEST_TIMEOUT, requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
response, response,
symbol symbol
}: { }: {
@ -55,7 +59,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, this.configurationService.get('REQUEST_TIMEOUT'));
return got( return got(
`${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol.split( `${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol.split(
@ -82,7 +86,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, this.configurationService.get('REQUEST_TIMEOUT'));
const holdings = await got( const holdings = await got(
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol}.json`, `${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol}.json`,
@ -97,7 +101,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, this.configurationService.get('REQUEST_TIMEOUT'));
return got( return got(
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol.split( `${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol.split(

View File

@ -1,3 +1,4 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { YahooFinanceDataEnhancerService } from './yahoo-finance.service'; import { YahooFinanceDataEnhancerService } from './yahoo-finance.service';
@ -25,13 +26,16 @@ jest.mock(
); );
describe('YahooFinanceDataEnhancerService', () => { describe('YahooFinanceDataEnhancerService', () => {
let configurationService: ConfigurationService;
let cryptocurrencyService: CryptocurrencyService; let cryptocurrencyService: CryptocurrencyService;
let yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService; let yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService;
beforeAll(async () => { beforeAll(async () => {
configurationService = new ConfigurationService();
cryptocurrencyService = new CryptocurrencyService(); cryptocurrencyService = new CryptocurrencyService();
yahooFinanceDataEnhancerService = new YahooFinanceDataEnhancerService( yahooFinanceDataEnhancerService = new YahooFinanceDataEnhancerService(
configurationService,
cryptocurrencyService cryptocurrencyService
); );
}); });

View File

@ -1,10 +1,7 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { import { DEFAULT_CURRENCY, UNKNOWN_KEY } from '@ghostfolio/common/config';
DEFAULT_CURRENCY,
DEFAULT_REQUEST_TIMEOUT,
UNKNOWN_KEY
} from '@ghostfolio/common/config';
import { isCurrency } from '@ghostfolio/common/helper'; import { isCurrency } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { import {
@ -22,6 +19,7 @@ import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-ifa
@Injectable() @Injectable()
export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
public constructor( public constructor(
private readonly configurationService: ConfigurationService,
private readonly cryptocurrencyService: CryptocurrencyService private readonly cryptocurrencyService: CryptocurrencyService
) {} ) {}
@ -76,7 +74,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
} }
public async enhance({ public async enhance({
requestTimeout = DEFAULT_REQUEST_TIMEOUT, requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
response, response,
symbol symbol
}: { }: {

View File

@ -74,6 +74,6 @@ import { DataProviderService } from './data-provider.service';
}, },
YahooFinanceDataEnhancerService YahooFinanceDataEnhancerService
], ],
exports: [DataProviderService, YahooFinanceService] exports: [DataProviderService, ManualService, YahooFinanceService]
}) })
export class DataProviderModule {} export class DataProviderModule {}

View File

@ -5,10 +5,7 @@ import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
DEFAULT_CURRENCY,
DEFAULT_REQUEST_TIMEOUT
} from '@ghostfolio/common/config';
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper'; import { DATE_FORMAT, isCurrency } 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';
@ -82,7 +79,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, this.configurationService.get('REQUEST_TIMEOUT'));
const response = await got( const response = await got(
`${this.URL}/eod/${symbol}?api_token=${ `${this.URL}/eod/${symbol}?api_token=${
@ -132,7 +129,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
} }
public async getQuotes({ public async getQuotes({
requestTimeout = DEFAULT_REQUEST_TIMEOUT, requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbols symbols
}: { }: {
requestTimeout?: number; requestTimeout?: number;
@ -194,7 +191,9 @@ export class EodHistoricalDataService implements DataProviderInterface {
})?.currency; })?.currency;
result[this.convertFromEodSymbol(code)] = { result[this.convertFromEodSymbol(code)] = {
currency: currency ?? DEFAULT_CURRENCY, currency:
currency ??
this.convertFromEodSymbol(code)?.replace(DEFAULT_CURRENCY, ''),
dataSource: DataSource.EOD_HISTORICAL_DATA, dataSource: DataSource.EOD_HISTORICAL_DATA,
marketPrice: close, marketPrice: close,
marketState: isToday(new Date(timestamp * 1000)) ? 'open' : 'closed' marketState: isToday(new Date(timestamp * 1000)) ? 'open' : 'closed'
@ -208,7 +207,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
if (response[`${DEFAULT_CURRENCY}GBP`]) { if (response[`${DEFAULT_CURRENCY}GBP`]) {
response[`${DEFAULT_CURRENCY}GBp`] = { response[`${DEFAULT_CURRENCY}GBp`] = {
...response[`${DEFAULT_CURRENCY}GBP`], ...response[`${DEFAULT_CURRENCY}GBP`],
currency: `${DEFAULT_CURRENCY}GBp`, currency: 'GBp',
marketPrice: this.getConvertedValue({ marketPrice: this.getConvertedValue({
symbol: `${DEFAULT_CURRENCY}GBp`, symbol: `${DEFAULT_CURRENCY}GBp`,
value: response[`${DEFAULT_CURRENCY}GBP`].marketPrice value: response[`${DEFAULT_CURRENCY}GBP`].marketPrice
@ -219,7 +218,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
if (response[`${DEFAULT_CURRENCY}ILS`]) { if (response[`${DEFAULT_CURRENCY}ILS`]) {
response[`${DEFAULT_CURRENCY}ILA`] = { response[`${DEFAULT_CURRENCY}ILA`] = {
...response[`${DEFAULT_CURRENCY}ILS`], ...response[`${DEFAULT_CURRENCY}ILS`],
currency: `${DEFAULT_CURRENCY}ILA`, currency: 'ILA',
marketPrice: this.getConvertedValue({ marketPrice: this.getConvertedValue({
symbol: `${DEFAULT_CURRENCY}ILA`, symbol: `${DEFAULT_CURRENCY}ILA`,
value: response[`${DEFAULT_CURRENCY}ILS`].marketPrice value: response[`${DEFAULT_CURRENCY}ILS`].marketPrice
@ -227,12 +226,23 @@ export class EodHistoricalDataService implements DataProviderInterface {
}; };
} }
if (response[`${DEFAULT_CURRENCY}USX`]) {
response[`${DEFAULT_CURRENCY}USX`] = {
currency: 'USX',
dataSource: this.getName(),
marketPrice: new Big(1).mul(100).toNumber(),
marketState: 'open'
};
}
return response; return response;
} catch (error) { } catch (error) {
let message = error; let message = error;
if (error?.code === 'ABORT_ERR') { if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`; message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
'REQUEST_TIMEOUT'
)}ms`;
} }
Logger.error(message, 'EodHistoricalDataService'); Logger.error(message, 'EodHistoricalDataService');
@ -359,7 +369,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, this.configurationService.get('REQUEST_TIMEOUT'));
const response = await got( const response = await got(
`${this.URL}/search/${aQuery}?api_token=${this.apiKey}`, `${this.URL}/search/${aQuery}?api_token=${this.apiKey}`,
@ -391,7 +401,9 @@ export class EodHistoricalDataService implements DataProviderInterface {
let message = error; let message = error;
if (error?.code === 'ABORT_ERR') { if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`; message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
'REQUEST_TIMEOUT'
)}ms`;
} }
Logger.error(message, 'EodHistoricalDataService'); Logger.error(message, 'EodHistoricalDataService');

View File

@ -5,10 +5,7 @@ import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
DEFAULT_CURRENCY,
DEFAULT_REQUEST_TIMEOUT
} from '@ghostfolio/common/config';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces'; import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
@ -70,7 +67,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, this.configurationService.get('REQUEST_TIMEOUT'));
const { historical } = await got( const { historical } = await got(
`${this.URL}/historical-price-full/${aSymbol}?apikey=${this.apiKey}`, `${this.URL}/historical-price-full/${aSymbol}?apikey=${this.apiKey}`,
@ -114,7 +111,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
} }
public async getQuotes({ public async getQuotes({
requestTimeout = DEFAULT_REQUEST_TIMEOUT, requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbols symbols
}: { }: {
requestTimeout?: number; requestTimeout?: number;
@ -154,7 +151,9 @@ export class FinancialModelingPrepService implements DataProviderInterface {
let message = error; let message = error;
if (error?.code === 'ABORT_ERR') { if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`; message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
'REQUEST_TIMEOUT'
)}ms`;
} }
Logger.error(message, 'FinancialModelingPrepService'); Logger.error(message, 'FinancialModelingPrepService');
@ -181,7 +180,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, this.configurationService.get('REQUEST_TIMEOUT'));
const result = await got( const result = await got(
`${this.URL}/search?query=${query}&apikey=${this.apiKey}`, `${this.URL}/search?query=${query}&apikey=${this.apiKey}`,
@ -205,7 +204,9 @@ export class FinancialModelingPrepService implements DataProviderInterface {
let message = error; let message = error;
if (error?.code === 'ABORT_ERR') { if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`; message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
'REQUEST_TIMEOUT'
)}ms`;
} }
Logger.error(message, 'FinancialModelingPrepService'); Logger.error(message, 'FinancialModelingPrepService');

View File

@ -7,7 +7,6 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; import { DATE_FORMAT, parseDate } 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';
@ -101,7 +100,7 @@ export class GoogleSheetsService implements DataProviderInterface {
} }
public async getQuotes({ public async getQuotes({
requestTimeout = DEFAULT_REQUEST_TIMEOUT, requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbols symbols
}: { }: {
requestTimeout?: number; requestTimeout?: number;

View File

@ -1,4 +1,5 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
@ -6,23 +7,25 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
import { import {
DATE_FORMAT, DATE_FORMAT,
extractNumberFromString, extractNumberFromString,
getYesterday getYesterday
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { ScraperConfiguration } from '@ghostfolio/common/interfaces';
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 * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import { isUUID } from 'class-validator'; import { isUUID } from 'class-validator';
import { addDays, format, isBefore } from 'date-fns'; import { addDays, format, isBefore } from 'date-fns';
import got from 'got'; import got, { Headers } from 'got';
import jsonpath from 'jsonpath';
@Injectable() @Injectable()
export class ManualService implements DataProviderInterface { export class ManualService implements DataProviderInterface {
public constructor( public constructor(
private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) {} ) {}
@ -96,21 +99,7 @@ export class ManualService implements DataProviderInterface {
return {}; return {};
} }
const abortController = new AbortController(); const value = await this.scrape(symbolProfile.scraperConfiguration);
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const { body } = await got(url, {
headers,
// @ts-ignore
signal: abortController.signal
});
const $ = cheerio.load(body);
const value = extractNumberFromString($(selector).text());
return { return {
[symbol]: { [symbol]: {
@ -134,7 +123,7 @@ export class ManualService implements DataProviderInterface {
} }
public async getQuotes({ public async getQuotes({
requestTimeout = DEFAULT_REQUEST_TIMEOUT, requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbols symbols
}: { }: {
requestTimeout?: number; requestTimeout?: number;
@ -232,4 +221,43 @@ export class ManualService implements DataProviderInterface {
return { items }; return { items };
} }
public async test(scraperConfiguration: ScraperConfiguration) {
return this.scrape(scraperConfiguration);
}
private async scrape(
scraperConfiguration: ScraperConfiguration
): Promise<number> {
try {
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, this.configurationService.get('REQUEST_TIMEOUT'));
const { body, headers } = await got(scraperConfiguration.url, {
headers: scraperConfiguration.headers as Headers,
// @ts-ignore
signal: abortController.signal
});
if (headers['content-type'] === 'application/json') {
const data = JSON.parse(body);
const value = String(
jsonpath.query(data, scraperConfiguration.selector)[0]
);
return extractNumberFromString(value);
} else {
const $ = cheerio.load(body);
return extractNumberFromString(
$(scraperConfiguration.selector).first().text()
);
}
} catch (error) {
throw error;
}
}
} }

View File

@ -5,10 +5,7 @@ import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
DEFAULT_REQUEST_TIMEOUT,
ghostfolioFearAndGreedIndexSymbol
} from '@ghostfolio/common/config';
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper'; import { DATE_FORMAT, getYesterday } 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';
@ -88,7 +85,7 @@ export class RapidApiService implements DataProviderInterface {
} }
public async getQuotes({ public async getQuotes({
requestTimeout = DEFAULT_REQUEST_TIMEOUT, requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbols symbols
}: { }: {
requestTimeout?: number; requestTimeout?: number;
@ -146,7 +143,7 @@ export class RapidApiService implements DataProviderInterface {
setTimeout(() => { setTimeout(() => {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, this.configurationService.get('REQUEST_TIMEOUT'));
const { fgi } = await got( const { fgi } = await got(
`https://fear-and-greed-index.p.rapidapi.com/v1/fgi`, `https://fear-and-greed-index.p.rapidapi.com/v1/fgi`,
@ -166,7 +163,9 @@ export class RapidApiService implements DataProviderInterface {
let message = error; let message = error;
if (error?.code === 'ABORT_ERR') { if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation was aborted because the request to the data provider took more than ${DEFAULT_REQUEST_TIMEOUT}ms`; message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
'REQUEST_TIMEOUT'
)}ms`;
} }
Logger.error(message, 'RapidApiService'); Logger.error(message, 'RapidApiService');

View File

@ -1,4 +1,5 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service'; import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
@ -6,10 +7,7 @@ import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
DEFAULT_CURRENCY,
DEFAULT_REQUEST_TIMEOUT
} from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; 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';
@ -22,6 +20,7 @@ import { Quote } from 'yahoo-finance2/dist/esm/src/modules/quote';
@Injectable() @Injectable()
export class YahooFinanceService implements DataProviderInterface { export class YahooFinanceService implements DataProviderInterface {
public constructor( public constructor(
private readonly configurationService: ConfigurationService,
private readonly cryptocurrencyService: CryptocurrencyService, private readonly cryptocurrencyService: CryptocurrencyService,
private readonly yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService private readonly yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService
) {} ) {}
@ -160,7 +159,7 @@ export class YahooFinanceService implements DataProviderInterface {
} }
public async getQuotes({ public async getQuotes({
requestTimeout = DEFAULT_REQUEST_TIMEOUT, requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbols symbols
}: { }: {
requestTimeout?: number; requestTimeout?: number;

View File

@ -11,6 +11,7 @@ import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { format, isToday } from 'date-fns'; import { format, isToday } from 'date-fns';
import { isNumber, uniq } from 'lodash'; import { isNumber, uniq } from 'lodash';
import ms from 'ms';
@Injectable() @Injectable()
export class ExchangeRateDataService { export class ExchangeRateDataService {
@ -33,6 +34,125 @@ export class ExchangeRateDataService {
return this.currencyPairs; return this.currencyPairs;
} }
public async getExchangeRates({
currencyFrom,
currencyTo,
dates
}: {
currencyFrom: string;
currencyTo: string;
dates: Date[];
}) {
let factors: { [dateString: string]: number } = {};
if (currencyFrom === currencyTo) {
for (const date of dates) {
factors[format(date, DATE_FORMAT)] = 1;
}
} else {
const dataSource =
this.dataProviderService.getDataSourceForExchangeRates();
const symbol = `${currencyFrom}${currencyTo}`;
const marketData = await this.marketDataService.getRange({
dateQuery: { in: dates },
uniqueAssets: [
{
dataSource,
symbol
}
]
});
if (marketData?.length > 0) {
for (const { date, marketPrice } of marketData) {
factors[format(date, DATE_FORMAT)] = marketPrice;
}
} else {
// Calculate indirectly via base currency
let marketPriceBaseCurrencyFromCurrency: {
[dateString: string]: number;
} = {};
let marketPriceBaseCurrencyToCurrency: {
[dateString: string]: number;
} = {};
try {
if (currencyFrom === DEFAULT_CURRENCY) {
for (const date of dates) {
marketPriceBaseCurrencyFromCurrency[format(date, DATE_FORMAT)] =
1;
}
} else {
const marketData = await this.marketDataService.getRange({
dateQuery: { in: dates },
uniqueAssets: [
{
dataSource,
symbol: `${DEFAULT_CURRENCY}${currencyFrom}`
}
]
});
for (const { date, marketPrice } of marketData) {
marketPriceBaseCurrencyFromCurrency[format(date, DATE_FORMAT)] =
marketPrice;
}
}
} catch {}
try {
if (currencyTo === DEFAULT_CURRENCY) {
for (const date of dates) {
marketPriceBaseCurrencyToCurrency[format(date, DATE_FORMAT)] = 1;
}
} else {
const marketData = await this.marketDataService.getRange({
dateQuery: {
in: dates
},
uniqueAssets: [
{
dataSource,
symbol: `${DEFAULT_CURRENCY}${currencyTo}`
}
]
});
for (const { date, marketPrice } of marketData) {
marketPriceBaseCurrencyToCurrency[format(date, DATE_FORMAT)] =
marketPrice;
}
}
} catch {}
for (const date of dates) {
try {
const factor =
(1 /
marketPriceBaseCurrencyFromCurrency[
format(date, DATE_FORMAT)
]) *
marketPriceBaseCurrencyToCurrency[format(date, DATE_FORMAT)];
factors[format(date, DATE_FORMAT)] = factor;
} catch {
Logger.error(
`No exchange rate has been found for ${currencyFrom}${currencyTo} at ${format(
date,
DATE_FORMAT
)}`,
'ExchangeRateDataService'
);
}
}
}
}
return factors;
}
public hasCurrencyPair(currency1: string, currency2: string) { public hasCurrencyPair(currency1: string, currency2: string) {
return this.currencyPairs.some(({ symbol }) => { return this.currencyPairs.some(({ symbol }) => {
return ( return (
@ -75,7 +195,8 @@ export class ExchangeRateDataService {
const quotes = await this.dataProviderService.getQuotes({ const quotes = await this.dataProviderService.getQuotes({
items: this.currencyPairs.map(({ dataSource, symbol }) => { items: this.currencyPairs.map(({ dataSource, symbol }) => {
return { dataSource, symbol }; return { dataSource, symbol };
}) }),
requestTimeout: ms('30 seconds')
}); });
for (const symbol of Object.keys(quotes)) { for (const symbol of Object.keys(quotes)) {

View File

@ -3,6 +3,8 @@ import { CleanedEnvAccessors } from 'envalid';
export interface Environment extends CleanedEnvAccessors { export interface Environment extends CleanedEnvAccessors {
ACCESS_TOKEN_SALT: string; ACCESS_TOKEN_SALT: string;
ALPHA_VANTAGE_API_KEY: string; ALPHA_VANTAGE_API_KEY: string;
API_KEY_COINGECKO_DEMO: string;
API_KEY_COINGECKO_PRO: string;
BETTER_UPTIME_API_KEY: string; BETTER_UPTIME_API_KEY: string;
CACHE_QUOTES_TTL: number; CACHE_QUOTES_TTL: number;
CACHE_TTL: number; CACHE_TTL: number;
@ -32,6 +34,7 @@ export interface Environment extends CleanedEnvAccessors {
REDIS_HOST: string; REDIS_HOST: string;
REDIS_PASSWORD: string; REDIS_PASSWORD: string;
REDIS_PORT: number; REDIS_PORT: number;
REQUEST_TIMEOUT: number;
ROOT_URL: string; ROOT_URL: string;
STRIPE_PUBLIC_KEY: string; STRIPE_PUBLIC_KEY: string;
STRIPE_SECRET_KEY: string; STRIPE_SECRET_KEY: string;

View File

@ -89,6 +89,7 @@ export class SymbolProfileService {
assetClass, assetClass,
assetSubClass, assetSubClass,
comment, comment,
currency,
dataSource, dataSource,
name, name,
scraperConfiguration, scraperConfiguration,
@ -100,6 +101,7 @@ export class SymbolProfileService {
assetClass, assetClass,
assetSubClass, assetSubClass,
comment, comment,
currency,
name, name,
scraperConfiguration, scraperConfiguration,
symbolMapping symbolMapping

View File

@ -0,0 +1,6 @@
const { composePlugins, withNx } = require('@nx/webpack');
module.exports = composePlugins(withNx(), (config, { options, context }) => {
// Customize webpack config here
return config;
});

View File

@ -150,41 +150,41 @@
} }
}, },
"serve": { "serve": {
"executor": "@nx/angular:webpack-dev-server", "executor": "@nx/angular:dev-server",
"options": { "options": {
"proxyConfig": "apps/client/proxy.conf.json", "proxyConfig": "apps/client/proxy.conf.json",
"browserTarget": "client:build" "buildTarget": "client:build"
}, },
"configurations": { "configurations": {
"development-de": { "development-de": {
"browserTarget": "client:build:development-de" "buildTarget": "client:build:development-de"
}, },
"development-en": { "development-en": {
"browserTarget": "client:build:development-en" "buildTarget": "client:build:development-en"
}, },
"development-es": { "development-es": {
"browserTarget": "client:build:development-es" "buildTarget": "client:build:development-es"
}, },
"development-fr": { "development-fr": {
"browserTarget": "client:build:development-fr" "buildTarget": "client:build:development-fr"
}, },
"development-it": { "development-it": {
"browserTarget": "client:build:development-it" "buildTarget": "client:build:development-it"
}, },
"development-nl": { "development-nl": {
"browserTarget": "client:build:development-nl" "buildTarget": "client:build:development-nl"
}, },
"development-pl": { "development-pl": {
"browserTarget": "client:build:development-pl" "buildTarget": "client:build:development-pl"
}, },
"development-pt": { "development-pt": {
"browserTarget": "client:build:development-pt" "buildTarget": "client:build:development-pt"
}, },
"development-tr": { "development-tr": {
"browserTarget": "client:build:development-tr" "buildTarget": "client:build:development-tr"
}, },
"production": { "production": {
"browserTarget": "client:build:production" "buildTarget": "client:build:production"
} }
} }
}, },
@ -207,7 +207,7 @@
} }
}, },
"lint": { "lint": {
"executor": "@nrwl/linter:eslint", "executor": "@nx/eslint:lint",
"options": { "options": {
"lintFilePatterns": ["apps/client/**/*.ts"] "lintFilePatterns": ["apps/client/**/*.ts"]
} }

View File

@ -96,8 +96,8 @@
href="https://status.ghostfol.io" href="https://status.ghostfol.io"
target="_blank" target="_blank"
title="Ghostfolio Status" title="Ghostfolio Status"
>Status<ion-icon class="ml-1" name="open-outline"></ion-icon >Status<ion-icon class="ml-1" name="open-outline"
></a> /></a>
</li> </li>
</ul> </ul>
</div> </div>
@ -110,8 +110,8 @@
href="https://github.com/ghostfolio/ghostfolio" href="https://github.com/ghostfolio/ghostfolio"
target="_blank" target="_blank"
title="Find Ghostfolio on GitHub" title="Find Ghostfolio on GitHub"
>GitHub<ion-icon class="ml-1" name="open-outline"></ion-icon >GitHub<ion-icon class="ml-1" name="open-outline"
></a> /></a>
</li> </li>
<li> <li>
<a <a
@ -119,8 +119,8 @@
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg" href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
target="_blank" target="_blank"
title="Join the Ghostfolio Slack community" title="Join the Ghostfolio Slack community"
>Slack<ion-icon class="ml-1" name="open-outline"></ion-icon >Slack<ion-icon class="ml-1" name="open-outline"
></a> /></a>
</li> </li>
<li> <li>
<a <a
@ -128,11 +128,8 @@
href="https://twitter.com/ghostfolio_" href="https://twitter.com/ghostfolio_"
target="_blank" target="_blank"
title="Follow Ghostfolio on X (formerly Twitter)" title="Follow Ghostfolio on X (formerly Twitter)"
>X (formerly Twitter)<ion-icon >X (formerly Twitter)<ion-icon class="ml-1" name="open-outline"
class="ml-1" /></a>
name="open-outline"
></ion-icon
></a>
</li> </li>
<li>&nbsp;</li> <li>&nbsp;</li>
<li> <li>

View File

@ -2,7 +2,7 @@
:host { :host {
display: block; display: block;
min-height: 100vh; min-height: 100svh;
&.has-info-message { &.has-info-message {
header { header {
@ -30,7 +30,7 @@
} }
main { main {
min-height: calc(100vh - 2 * var(--mat-toolbar-standard-height)); min-height: calc(100svh - 2 * var(--mat-toolbar-standard-height));
} }
} }
@ -44,7 +44,7 @@
} }
main { main {
min-height: calc(100vh - var(--mat-toolbar-standard-height)); min-height: calc(100svh - var(--mat-toolbar-standard-height));
} }
} }

View File

@ -14,26 +14,26 @@
</ng-container> </ng-container>
<ng-container matColumnDef="type"> <ng-container matColumnDef="type">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Type</th> <th *matHeaderCellDef class="px-1" i18n mat-header-cell>Permission</th>
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell> <td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
<ng-container> <div class="align-items-center d-flex">
<ion-icon class="mr-1" name="lock-closed-outline"></ion-icon> <ion-icon class="mr-1" name="lock-closed-outline" />
Restricted View <ng-container i18n>Restricted View</ng-container>
</ng-container> </div>
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="details"> <ng-container matColumnDef="details">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Details</th> <th *matHeaderCellDef class="px-1" i18n mat-header-cell>Details</th>
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell> <td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
<ng-container *ngIf="element.type === 'PUBLIC'"> <div *ngIf="element.type === 'PUBLIC'" class="align-items-center d-flex">
<ion-icon class="mr-1" name="link-outline"></ion-icon> <ion-icon class="mr-1" name="link-outline" />
<a <a
href="{{ baseUrl }}/{{ defaultLanguageCode }}/p/{{ element.id }}" href="{{ baseUrl }}/{{ defaultLanguageCode }}/p/{{ element.id }}"
target="_blank" target="_blank"
>{{ baseUrl }}/{{ defaultLanguageCode }}/p/{{ element.id }}</a >{{ baseUrl }}/{{ defaultLanguageCode }}/p/{{ element.id }}</a
> >
</ng-container> </div>
</td> </td>
</ng-container> </ng-container>
@ -47,7 +47,7 @@
[matMenuTriggerFor]="transactionMenu" [matMenuTriggerFor]="transactionMenu"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >
<ion-icon name="ellipsis-horizontal"></ion-icon> <ion-icon name="ellipsis-horizontal" />
</button> </button>
<mat-menu #transactionMenu="matMenu" xPosition="before"> <mat-menu #transactionMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onDeleteAccess(element.id)"> <button mat-menu-item (click)="onDeleteAccess(element.id)">

View File

@ -7,6 +7,8 @@ import {
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Sort, SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
@ -16,6 +18,7 @@ import {
HistoricalDataItem, HistoricalDataItem,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import Big from 'big.js'; import Big from 'big.js';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
@ -24,7 +27,6 @@ import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
import { AccountDetailDialogParams } from './interfaces/interfaces'; import { AccountDetailDialogParams } from './interfaces/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@Component({ @Component({
host: { class: 'd-flex flex-column h-100' }, host: { class: 'd-flex flex-column h-100' },
@ -38,6 +40,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
public activities: OrderWithAccount[]; public activities: OrderWithAccount[];
public balance: number; public balance: number;
public currency: string; public currency: string;
public dataSource: MatTableDataSource<OrderWithAccount>;
public equity: number; public equity: number;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public hasPermissionToDeleteAccountBalance: boolean; public hasPermissionToDeleteAccountBalance: boolean;
@ -46,6 +49,9 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
public isLoadingChart: boolean; public isLoadingChart: boolean;
public name: string; public name: string;
public platformName: string; public platformName: string;
public sortColumn = 'date';
public sortDirection: SortDirection = 'desc';
public totalItems: number;
public transactionCount: number; public transactionCount: number;
public user: User; public user: User;
public valueInBaseCurrency: number; public valueInBaseCurrency: number;
@ -77,8 +83,6 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
} }
public ngOnInit() { public ngOnInit() {
this.isLoadingActivities = true;
this.dataService this.dataService
.fetchAccount(this.data.accountId) .fetchAccount(this.data.accountId)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
@ -110,19 +114,6 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
} }
); );
this.dataService
.fetchActivities({
filters: [{ id: this.data.accountId, type: 'ACCOUNT' }]
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ activities }) => {
this.activities = activities;
this.isLoadingActivities = false;
this.changeDetectorRef.markForCheck();
});
this.impersonationStorageService this.impersonationStorageService
.onChangeHasImpersonation() .onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
@ -131,6 +122,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
}); });
this.fetchAccountBalances(); this.fetchAccountBalances();
this.fetchActivities();
this.fetchPortfolioPerformance(); this.fetchPortfolioPerformance();
} }
@ -151,12 +143,20 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
} }
public onExport() { public onExport() {
let activityIds = [];
if (this.user?.settings?.isExperimentalFeatures === true) {
activityIds = this.dataSource.data.map(({ id }) => {
return id;
});
} else {
activityIds = this.activities.map(({ id }) => {
return id;
});
}
this.dataService this.dataService
.fetchExport( .fetchExport(activityIds)
this.activities.map(({ id }) => {
return id;
})
)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => { .subscribe((data) => {
downloadAsFile({ downloadAsFile({
@ -172,6 +172,13 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
}); });
} }
public onSortChanged({ active, direction }: Sort) {
this.sortColumn = active;
this.sortDirection = direction;
this.fetchActivities();
}
private fetchAccountBalances() { private fetchAccountBalances() {
this.dataService this.dataService
.fetchAccountBalances(this.data.accountId) .fetchAccountBalances(this.data.accountId)
@ -183,6 +190,41 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
}); });
} }
private fetchActivities() {
this.isLoadingActivities = true;
if (this.user?.settings?.isExperimentalFeatures === true) {
this.dataService
.fetchActivities({
filters: [{ id: this.data.accountId, type: 'ACCOUNT' }],
sortColumn: this.sortColumn,
sortDirection: this.sortDirection
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ activities, count }) => {
this.dataSource = new MatTableDataSource(activities);
this.totalItems = count;
this.isLoadingActivities = false;
this.changeDetectorRef.markForCheck();
});
} else {
this.dataService
.fetchActivities({
filters: [{ id: this.data.accountId, type: 'ACCOUNT' }]
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ activities }) => {
this.activities = activities;
this.isLoadingActivities = false;
this.changeDetectorRef.markForCheck();
});
}
}
private fetchPortfolioPerformance() { private fetchPortfolioPerformance() {
this.isLoadingChart = true; this.isLoadingChart = true;

View File

@ -71,7 +71,25 @@
> >
<mat-tab> <mat-tab>
<ng-template i18n mat-tab-label>Activities</ng-template> <ng-template i18n mat-tab-label>Activities</ng-template>
<gf-activities-table-lazy
*ngIf="user?.settings?.isExperimentalFeatures === true"
[baseCurrency]="user?.settings?.baseCurrency"
[dataSource]="dataSource"
[deviceType]="deviceType"
[hasPermissionToCreateActivity]="false"
[hasPermissionToExportActivities]="!hasImpersonationId && !user.settings.isRestrictedView"
[hasPermissionToFilter]="false"
[hasPermissionToOpenDetails]="false"
[locale]="user?.settings?.locale"
[showActions]="false"
[sortColumn]="sortColumn"
[sortDirection]="sortDirection"
[totalItems]="totalItems"
(export)="onExport()"
(sortChanged)="onSortChanged($event)"
></gf-activities-table-lazy>
<gf-activities-table <gf-activities-table
*ngIf="user?.settings?.isExperimentalFeatures !== true"
[activities]="activities" [activities]="activities"
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="data.deviceType" [deviceType]="data.deviceType"

View File

@ -7,6 +7,7 @@ import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-foote
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module'; import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
import { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module'; import { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module';
import { GfAccountBalancesModule } from '@ghostfolio/ui/account-balances/account-balances.module'; import { GfAccountBalancesModule } from '@ghostfolio/ui/account-balances/account-balances.module';
import { GfActivitiesTableLazyModule } from '@ghostfolio/ui/activities-table-lazy/activities-table-lazy.module';
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module'; import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
import { GfValueModule } from '@ghostfolio/ui/value'; import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -19,6 +20,7 @@ import { AccountDetailDialog } from './account-detail-dialog.component';
CommonModule, CommonModule,
GfAccountBalancesModule, GfAccountBalancesModule,
GfActivitiesTableModule, GfActivitiesTableModule,
GfActivitiesTableLazyModule,
GfDialogFooterModule, GfDialogFooterModule,
GfDialogHeaderModule, GfDialogHeaderModule,
GfInvestmentChartModule, GfInvestmentChartModule,

View File

@ -5,7 +5,7 @@
[disabled]="dataSource?.data.length < 2" [disabled]="dataSource?.data.length < 2"
(click)="onTransferBalance()" (click)="onTransferBalance()"
> >
<ion-icon class="mr-2" name="arrow-redo-outline"></ion-icon> <ion-icon class="mr-2" name="arrow-redo-outline" />
<ng-container i18n>Transfer Cash Balance</ng-container>... <ng-container i18n>Transfer Cash Balance</ng-container>...
</button> </button>
</div> </div>
@ -19,7 +19,7 @@
></th> ></th>
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell> <td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
<div class="d-flex justify-content-center"> <div class="d-flex justify-content-center">
<ion-icon *ngIf="element.isExcluded" name="eye-off-outline"></ion-icon> <ion-icon *ngIf="element.isExcluded" name="eye-off-outline" />
</div> </div>
</td> </td>
<td <td
@ -231,7 +231,7 @@
title="Note" title="Note"
(click)="onOpenComment(element.comment); $event.stopPropagation()" (click)="onOpenComment(element.comment); $event.stopPropagation()"
> >
<ion-icon name="document-text-outline"></ion-icon> <ion-icon name="document-text-outline" />
</button> </button>
</td> </td>
<td <td
@ -250,12 +250,12 @@
[matMenuTriggerFor]="accountMenu" [matMenuTriggerFor]="accountMenu"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >
<ion-icon name="ellipsis-horizontal"></ion-icon> <ion-icon name="ellipsis-horizontal" />
</button> </button>
<mat-menu #accountMenu="matMenu" xPosition="before"> <mat-menu #accountMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onUpdateAccount(element)"> <button mat-menu-item (click)="onUpdateAccount(element)">
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline"></ion-icon> <ion-icon class="mr-2" name="create-outline" />
<span i18n>Edit</span> <span i18n>Edit</span>
</span> </span>
</button> </button>
@ -265,7 +265,7 @@
(click)="onDeleteAccount(element.id)" (click)="onDeleteAccount(element.id)"
> >
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline"></ion-icon> <ion-icon class="mr-2" name="trash-outline" />
<span i18n>Delete</span> <span i18n>Delete</span>
</span> </span>
</button> </button>

View File

@ -90,33 +90,24 @@
<ng-container i18n>Status</ng-container> <ng-container i18n>Status</ng-container>
</th> </th>
<td *matCellDef="let element" class="px-1 py-2" mat-cell> <td *matCellDef="let element" class="px-1 py-2" mat-cell>
<ion-icon <ion-icon *ngIf="element.state === 'active'" name="play-outline" />
*ngIf="element.state === 'active'"
name="play-outline"
></ion-icon>
<ion-icon <ion-icon
*ngIf="element.state === 'completed'" *ngIf="element.state === 'completed'"
class="text-success" class="text-success"
name="checkmark-circle-outline" name="checkmark-circle-outline"
></ion-icon> />
<ion-icon <ion-icon
*ngIf="element.state === 'delayed'" *ngIf="element.state === 'delayed'"
name="time-outline" name="time-outline"
[ngClass]="{ 'text-danger': element.stacktrace?.length > 0 }" [ngClass]="{ 'text-danger': element.stacktrace?.length > 0 }"
></ion-icon> />
<ion-icon <ion-icon
*ngIf="element.state === 'failed'" *ngIf="element.state === 'failed'"
class="text-danger" class="text-danger"
name="alert-circle-outline" name="alert-circle-outline"
></ion-icon> />
<ion-icon <ion-icon *ngIf="element.state === 'paused'" name="pause-outline" />
*ngIf="element.state === 'paused'" <ion-icon *ngIf="element.state === 'waiting'" name="cafe-outline" />
name="pause-outline"
></ion-icon>
<ion-icon
*ngIf="element.state === 'waiting'"
name="cafe-outline"
></ion-icon>
</td> </td>
</ng-container> </ng-container>
@ -128,7 +119,7 @@
[matMenuTriggerFor]="jobsActionsMenu" [matMenuTriggerFor]="jobsActionsMenu"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >
<ion-icon name="ellipsis-vertical"></ion-icon> <ion-icon name="ellipsis-vertical" />
</button> </button>
<mat-menu #jobsActionsMenu="matMenu" xPosition="before"> <mat-menu #jobsActionsMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onDeleteJobs()"> <button mat-menu-item (click)="onDeleteJobs()">
@ -143,7 +134,7 @@
[matMenuTriggerFor]="jobActionsMenu" [matMenuTriggerFor]="jobActionsMenu"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >
<ion-icon name="ellipsis-horizontal"></ion-icon> <ion-icon name="ellipsis-horizontal" />
</button> </button>
<mat-menu #jobActionsMenu="matMenu" xPosition="before"> <mat-menu #jobActionsMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onViewData(element.data)"> <button mat-menu-item (click)="onViewData(element.data)">

View File

@ -4,6 +4,10 @@
display: block; display: block;
font-size: 0.9rem; font-size: 0.9rem;
gf-line-chart {
aspect-ratio: 16/9;
}
.date { .date {
font-feature-settings: 'tnum'; font-feature-settings: 'tnum';
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;

View File

@ -16,7 +16,7 @@
class="text-muted" class="text-muted"
matDatepickerToggleIcon matDatepickerToggleIcon
name="calendar-clear-outline" name="calendar-clear-outline"
></ion-icon> />
</mat-datepicker-toggle> </mat-datepicker-toggle>
<mat-datepicker #date disabled="true"></mat-datepicker> <mat-datepicker #date disabled="true"></mat-datepicker>
</mat-form-field> </mat-form-field>
@ -38,7 +38,7 @@
title="Fetch market price" title="Fetch market price"
(click)="onFetchSymbolForDate()" (click)="onFetchSymbolForDate()"
> >
<ion-icon class="text-muted" name="refresh-outline"></ion-icon> <ion-icon class="text-muted" name="refresh-outline" />
</button> </button>
</div> </div>
</div> </div>

View File

@ -125,7 +125,7 @@
*ngIf="element.comment" *ngIf="element.comment"
class="d-block" class="d-block"
name="document-text-outline" name="document-text-outline"
></ion-icon> />
</td> </td>
</ng-container> </ng-container>
@ -137,7 +137,7 @@
[matMenuTriggerFor]="assetProfilesActionsMenu" [matMenuTriggerFor]="assetProfilesActionsMenu"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >
<ion-icon name="ellipsis-vertical"></ion-icon> <ion-icon name="ellipsis-vertical" />
</button> </button>
<mat-menu #assetProfilesActionsMenu="matMenu" xPosition="before"> <mat-menu #assetProfilesActionsMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onGather7Days()"> <button mat-menu-item (click)="onGather7Days()">
@ -158,7 +158,7 @@
[matMenuTriggerFor]="assetProfileActionsMenu" [matMenuTriggerFor]="assetProfileActionsMenu"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >
<ion-icon name="ellipsis-horizontal"></ion-icon> <ion-icon name="ellipsis-horizontal" />
</button> </button>
<mat-menu #assetProfileActionsMenu="matMenu" xPosition="before"> <mat-menu #assetProfileActionsMenu="matMenu" xPosition="before">
<button <button
@ -166,7 +166,7 @@
(click)="onOpenAssetProfileDialog({ dataSource: element.dataSource, symbol: element.symbol })" (click)="onOpenAssetProfileDialog({ dataSource: element.dataSource, symbol: element.symbol })"
> >
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline"></ion-icon> <ion-icon class="mr-2" name="create-outline" />
<span i18n>Edit</span> <span i18n>Edit</span>
</span> </span>
</button> </button>
@ -176,7 +176,7 @@
(click)="onDeleteProfileData({dataSource: element.dataSource, symbol: element.symbol})" (click)="onDeleteProfileData({dataSource: element.dataSource, symbol: element.symbol})"
> >
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline"></ion-icon> <ion-icon class="mr-2" name="trash-outline" />
<span i18n>Delete</span> <span i18n>Delete</span>
</span> </span>
</button> </button>
@ -225,7 +225,7 @@
[queryParams]="{ createAssetProfileDialog: true }" [queryParams]="{ createAssetProfileDialog: true }"
[routerLink]="[]" [routerLink]="[]"
> >
<ion-icon name="add-outline" size="large"></ion-icon> <ion-icon name="add-outline" size="large" />
</a> </a>
</div> </div>
</div> </div>

View File

@ -15,6 +15,7 @@ import { DataService } from '@ghostfolio/client/services/data.service';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { import {
AdminMarketDataDetails, AdminMarketDataDetails,
Currency,
UniqueAsset UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
@ -51,6 +52,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
assetClass: new FormControl<AssetClass>(undefined), assetClass: new FormControl<AssetClass>(undefined),
assetSubClass: new FormControl<AssetSubClass>(undefined), assetSubClass: new FormControl<AssetSubClass>(undefined),
comment: '', comment: '',
currency: '',
historicalData: this.formBuilder.group({ historicalData: this.formBuilder.group({
csvString: '' csvString: ''
}), }),
@ -63,6 +65,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
public countries: { public countries: {
[code: string]: { name: string; value: number }; [code: string]: { name: string; value: number };
}; };
public currencies: Currency[] = [];
public isBenchmark = false; public isBenchmark = false;
public marketDataDetails: MarketData[] = []; public marketDataDetails: MarketData[] = [];
public sectors: { public sectors: {
@ -86,7 +89,13 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
) {} ) {}
public ngOnInit(): void { public ngOnInit(): void {
this.benchmarks = this.dataService.fetchInfo().benchmarks; const { benchmarks, currencies } = this.dataService.fetchInfo();
this.benchmarks = benchmarks;
this.currencies = currencies.map((currency) => ({
label: currency,
value: currency
}));
this.initialize(); this.initialize();
} }
@ -132,6 +141,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
assetClass: this.assetProfile.assetClass ?? null, assetClass: this.assetProfile.assetClass ?? null,
assetSubClass: this.assetProfile.assetSubClass ?? null, assetSubClass: this.assetProfile.assetSubClass ?? null,
comment: this.assetProfile?.comment ?? '', comment: this.assetProfile?.comment ?? '',
currency: this.assetProfile?.currency,
historicalData: { historicalData: {
csvString: AssetProfileDialog.HISTORICAL_DATA_TEMPLATE csvString: AssetProfileDialog.HISTORICAL_DATA_TEMPLATE
}, },
@ -245,12 +255,15 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
} catch {} } catch {}
const assetProfileData: UpdateAssetProfileDto = { const assetProfileData: UpdateAssetProfileDto = {
scraperConfiguration,
symbolMapping,
assetClass: this.assetProfileForm.controls['assetClass'].value, assetClass: this.assetProfileForm.controls['assetClass'].value,
assetSubClass: this.assetProfileForm.controls['assetSubClass'].value, assetSubClass: this.assetProfileForm.controls['assetSubClass'].value,
comment: this.assetProfileForm.controls['comment'].value ?? null, comment: this.assetProfileForm.controls['comment'].value ?? null,
name: this.assetProfileForm.controls['name'].value, currency: (<Currency>(
scraperConfiguration, (<unknown>this.assetProfileForm.controls['currency'].value)
symbolMapping ))?.value,
name: this.assetProfileForm.controls['name'].value
}; };
this.adminService this.adminService
@ -264,6 +277,34 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
}); });
} }
public onTestMarketData() {
this.adminService
.testMarketData({
dataSource: this.data.dataSource,
scraperConfiguration:
this.assetProfileForm.controls['scraperConfiguration'].value,
symbol: this.data.symbol
})
.pipe(
catchError(({ error }) => {
alert(`Error: ${error?.message}`);
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(({ price }) => {
alert(
$localize`The current market price is` +
' ' +
price +
' ' +
(<Currency>(
(<unknown>this.assetProfileForm.controls['currency'].value)
))?.value
);
});
}
public onUnsetBenchmark({ dataSource, symbol }: UniqueAsset) { public onUnsetBenchmark({ dataSource, symbol }: UniqueAsset) {
this.dataService this.dataService
.deleteBenchmark({ dataSource, symbol }) .deleteBenchmark({ dataSource, symbol })

View File

@ -15,7 +15,7 @@
[matMenuTriggerFor]="assetProfileActionsMenu" [matMenuTriggerFor]="assetProfileActionsMenu"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >
<ion-icon name="ellipsis-vertical"></ion-icon> <ion-icon name="ellipsis-vertical" />
</button> </button>
<mat-menu #assetProfileActionsMenu="matMenu" xPosition="before"> <mat-menu #assetProfileActionsMenu="matMenu" xPosition="before">
<button mat-menu-item type="button" (click)="initialize()"> <button mat-menu-item type="button" (click)="initialize()">
@ -183,6 +183,15 @@
<input formControlName="name" matInput type="text" /> <input formControlName="name" matInput type="text" />
</mat-form-field> </mat-form-field>
</div> </div>
<div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Currency</mat-label>
<gf-currency-selector
formControlName="currency"
[currencies]="currencies"
/>
</mat-form-field>
</div>
<div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3"> <div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3">
<mat-form-field appearance="outline" class="w-100 without-hint"> <mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Asset Class</mat-label> <mat-label i18n>Asset Class</mat-label>
@ -234,12 +243,24 @@
<div *ngIf="assetProfile?.dataSource === 'MANUAL'"> <div *ngIf="assetProfile?.dataSource === 'MANUAL'">
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Scraper Configuration</mat-label> <mat-label i18n>Scraper Configuration</mat-label>
<textarea <div class="align-items-end d-flex">
cdkTextareaAutosize <textarea
formControlName="scraperConfiguration" cdkTextareaAutosize
matInput formControlName="scraperConfiguration"
type="text" matInput
></textarea> type="text"
(keyup.enter)="$event.stopPropagation()"
></textarea>
<button
color="accent"
mat-flat-button
type="button"
[disabled]="assetProfileForm.controls['scraperConfiguration'].value === '{}'"
(click)="onTestMarketData()"
>
<ng-container i18n>Test</ng-container>
</button>
</div>
</mat-form-field> </mat-form-field>
</div> </div>
<div> <div>

View File

@ -10,6 +10,7 @@ import { MatMenuModule } from '@angular/material/menu';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { MatSnackBarModule } from '@angular/material/snack-bar'; import { MatSnackBarModule } from '@angular/material/snack-bar';
import { GfAdminMarketDataDetailModule } from '@ghostfolio/client/components/admin-market-data-detail/admin-market-data-detail.module'; import { GfAdminMarketDataDetailModule } from '@ghostfolio/client/components/admin-market-data-detail/admin-market-data-detail.module';
import { GfCurrencySelectorModule } from '@ghostfolio/ui/currency-selector/currency-selector.module';
import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module'; import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
import { GfValueModule } from '@ghostfolio/ui/value'; import { GfValueModule } from '@ghostfolio/ui/value';
@ -21,6 +22,7 @@ import { AssetProfileDialog } from './asset-profile-dialog.component';
CommonModule, CommonModule,
FormsModule, FormsModule,
GfAdminMarketDataDetailModule, GfAdminMarketDataDetailModule,
GfCurrencySelectorModule,
GfPortfolioProportionChartModule, GfPortfolioProportionChartModule,
GfValueModule, GfValueModule,
MatButtonModule, MatButtonModule,

View File

@ -119,8 +119,12 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
const currency = prompt($localize`Please add a currency:`); const currency = prompt($localize`Please add a currency:`);
if (currency) { if (currency) {
const currencies = uniq([...this.customCurrencies, currency]); if (currency.length === 3) {
this.putAdminSetting({ key: PROPERTY_CURRENCIES, value: currencies }); const currencies = uniq([...this.customCurrencies, currency]);
this.putAdminSetting({ key: PROPERTY_CURRENCIES, value: currencies });
} else {
alert($localize`${currency} is an invalid currency!`);
}
} }
} }

View File

@ -62,7 +62,7 @@
[matMenuTriggerFor]="exchangeRateActionsMenu" [matMenuTriggerFor]="exchangeRateActionsMenu"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >
<ion-icon name="ellipsis-horizontal"></ion-icon> <ion-icon name="ellipsis-horizontal" />
</button> </button>
<mat-menu <mat-menu
#exchangeRateActionsMenu="matMenu" #exchangeRateActionsMenu="matMenu"
@ -79,10 +79,7 @@
[routerLink]="['/admin', 'market-data']" [routerLink]="['/admin', 'market-data']"
> >
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon <ion-icon class="mr-2" name="create-outline" />
class="mr-2"
name="create-outline"
></ion-icon>
<span i18n>Edit</span> <span i18n>Edit</span>
</span> </span>
</a> </a>
@ -92,10 +89,7 @@
(click)="onDeleteCurrency(exchangeRate.label2)" (click)="onDeleteCurrency(exchangeRate.label2)"
> >
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon <ion-icon class="mr-2" name="trash-outline" />
class="mr-2"
name="trash-outline"
></ion-icon>
<span i18n>Delete</span> <span i18n>Delete</span>
</span> </span>
</button> </button>
@ -109,7 +103,7 @@
mat-flat-button mat-flat-button
(click)="onAddCurrency()" (click)="onAddCurrency()"
> >
<ion-icon class="mr-1" name="add-outline"></ion-icon> <ion-icon class="mr-1" name="add-outline" />
<span i18n>Add Currency</span> <span i18n>Add Currency</span>
</button> </button>
</div> </div>
@ -147,7 +141,7 @@
mat-button mat-button
(click)="onDeleteSystemMessage()" (click)="onDeleteSystemMessage()"
> >
<ion-icon name="trash-outline"></ion-icon> <ion-icon name="trash-outline" />
</button> </button>
</div> </div>
<button <button
@ -157,10 +151,7 @@
mat-flat-button mat-flat-button
(click)="onSetSystemMessage()" (click)="onSetSystemMessage()"
> >
<ion-icon <ion-icon class="mr-1" name="information-circle-outline" />
class="mr-1"
name="information-circle-outline"
></ion-icon>
<span i18n>Set Message</span> <span i18n>Set Message</span>
</button> </button>
</div> </div>
@ -182,7 +173,7 @@
[matMenuTriggerFor]="couponActionsMenu" [matMenuTriggerFor]="couponActionsMenu"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >
<ion-icon name="ellipsis-horizontal"></ion-icon> <ion-icon name="ellipsis-horizontal" />
</button> </button>
<mat-menu <mat-menu
#couponActionsMenu="matMenu" #couponActionsMenu="matMenu"
@ -194,10 +185,7 @@
(click)="onDeleteCoupon(coupon.code)" (click)="onDeleteCoupon(coupon.code)"
> >
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon <ion-icon class="mr-2" name="trash-outline" />
class="mr-2"
name="trash-outline"
></ion-icon>
<span i18n>Delete</span> <span i18n>Delete</span>
</span> </span>
</button> </button>
@ -240,7 +228,7 @@
<div class="w-50" i18n>Housekeeping</div> <div class="w-50" i18n>Housekeeping</div>
<div class="w-50"> <div class="w-50">
<button color="warn" mat-flat-button (click)="onFlushCache()"> <button color="warn" mat-flat-button (click)="onFlushCache()">
<ion-icon class="mr-1" name="close-circle-outline"></ion-icon> <ion-icon class="mr-1" name="close-circle-outline" />
<span i18n>Flush Cache</span> <span i18n>Flush Cache</span>
</button> </button>
</div> </div>

View File

@ -82,18 +82,18 @@
[matMenuTriggerFor]="platformMenu" [matMenuTriggerFor]="platformMenu"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >
<ion-icon name="ellipsis-horizontal"></ion-icon> <ion-icon name="ellipsis-horizontal" />
</button> </button>
<mat-menu #platformMenu="matMenu" xPosition="before"> <mat-menu #platformMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onUpdatePlatform(element)"> <button mat-menu-item (click)="onUpdatePlatform(element)">
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline"></ion-icon> <ion-icon class="mr-2" name="create-outline" />
<span i18n>Edit</span> <span i18n>Edit</span>
</span> </span>
</button> </button>
<button mat-menu-item (click)="onDeletePlatform(element.id)"> <button mat-menu-item (click)="onDeletePlatform(element.id)">
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline"></ion-icon> <ion-icon class="mr-2" name="trash-outline" />
<span i18n>Delete</span> <span i18n>Delete</span>
</span> </span>
</button> </button>

View File

@ -62,18 +62,18 @@
[matMenuTriggerFor]="tagMenu" [matMenuTriggerFor]="tagMenu"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >
<ion-icon name="ellipsis-horizontal"></ion-icon> <ion-icon name="ellipsis-horizontal" />
</button> </button>
<mat-menu #tagMenu="matMenu" xPosition="before"> <mat-menu #tagMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onUpdateTag(element)"> <button mat-menu-item (click)="onUpdateTag(element)">
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline"></ion-icon> <ion-icon class="mr-2" name="create-outline" />
<span i18n>Edit</span> <span i18n>Edit</span>
</span> </span>
</button> </button>
<button mat-menu-item (click)="onDeleteTag(element.id)"> <button mat-menu-item (click)="onDeleteTag(element.id)">
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline"></ion-icon> <ion-icon class="mr-2" name="trash-outline" />
<span i18n>Delete</span> <span i18n>Delete</span>
</span> </span>
</button> </button>

View File

@ -195,7 +195,7 @@
[matMenuTriggerFor]="userMenu" [matMenuTriggerFor]="userMenu"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >
<ion-icon name="ellipsis-horizontal"></ion-icon> <ion-icon name="ellipsis-horizontal" />
</button> </button>
<mat-menu #userMenu="matMenu" xPosition="before"> <mat-menu #userMenu="matMenu" xPosition="before">
<button <button
@ -204,7 +204,7 @@
(click)="onImpersonateUser(element.id)" (click)="onImpersonateUser(element.id)"
> >
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="contract-outline"></ion-icon> <ion-icon class="mr-2" name="contract-outline" />
<span i18n>Impersonate User</span> <span i18n>Impersonate User</span>
</span> </span>
</button> </button>
@ -214,7 +214,7 @@
(click)="onDeleteUser(element.id)" (click)="onDeleteUser(element.id)"
> >
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline"></ion-icon> <ion-icon class="mr-2" name="trash-outline" />
<span i18n>Delete User</span> <span i18n>Delete User</span>
</span> </span>
</button> </button>

View File

@ -3,5 +3,5 @@
mat-button mat-button
(click)="onClickCloseButton()" (click)="onClickCloseButton()"
> >
<ion-icon name="close" size="large"></ion-icon> <ion-icon name="close" size="large" />
</button> </button>

View File

@ -9,5 +9,5 @@
mat-button mat-button
(click)="onClickCloseButton()" (click)="onClickCloseButton()"
> >
<ion-icon name="close" size="large"></ion-icon> <ion-icon name="close" size="large" />
</button> </button>

View File

@ -119,7 +119,11 @@
[matMenuTriggerRestoreFocus]="false" [matMenuTriggerRestoreFocus]="false"
(menuOpened)="onOpenAssistant()" (menuOpened)="onOpenAssistant()"
> >
<ion-icon name="search-outline"></ion-icon> @if (user?.settings?.isExperimentalFeatures) {
<ion-icon class="rotate-90" name="options-outline" />
} @else {
<ion-icon name="search-outline" />
}
</button> </button>
<mat-menu <mat-menu
#assistantMenu="matMenu" #assistantMenu="matMenu"
@ -134,7 +138,10 @@
[hasPermissionToAccessAdminControl]=" [hasPermissionToAccessAdminControl]="
hasPermissionToAccessAdminControl hasPermissionToAccessAdminControl
" "
[user]="user"
(closed)="closeAssistant()" (closed)="closeAssistant()"
(dateRangeChanged)="onDateRangeChange($event)"
(selectedTagChanged)="onSelectedTagChanged($event)"
/> />
</mat-menu> </mat-menu>
</li> </li>
@ -150,12 +157,12 @@
class="d-none d-sm-block" class="d-none d-sm-block"
name="person-circle-outline" name="person-circle-outline"
size="large" size="large"
></ion-icon> />
<ion-icon <ion-icon
class="d-block d-sm-none" class="d-block d-sm-none"
size="large" size="large"
[name]="isMenuOpen ? 'close-outline' : 'menu-outline'" [name]="isMenuOpen ? 'close-outline' : 'menu-outline'"
></ion-icon> />
</button> </button>
<mat-menu #accountMenu="matMenu" xPosition="before"> <mat-menu #accountMenu="matMenu" xPosition="before">
<ng-container *ngIf="user?.access?.length > 0"> <ng-container *ngIf="user?.access?.length > 0">
@ -169,7 +176,7 @@
? 'radio-button-off-outline' ? 'radio-button-off-outline'
: 'radio-button-on-outline' : 'radio-button-on-outline'
" "
></ion-icon> />
<span i18n>Me</span> <span i18n>Me</span>
</span> </span>
</button> </button>
@ -187,7 +194,7 @@
? 'radio-button-on-outline' ? 'radio-button-on-outline'
: 'radio-button-off-outline' : 'radio-button-off-outline'
" "
></ion-icon> />
<span *ngIf="accessItem.alias">{{ accessItem.alias }}</span> <span *ngIf="accessItem.alias">{{ accessItem.alias }}</span>
<span *ngIf="!accessItem.alias" i18n>User</span> <span *ngIf="!accessItem.alias" i18n>User</span>
</span> </span>

View File

@ -32,6 +32,10 @@
ion-icon { ion-icon {
font-size: 1.5rem; font-size: 1.5rem;
&.rotate-90 {
transform: rotate(90deg);
}
} }
} }

View File

@ -19,9 +19,12 @@ import {
SettingsStorageService SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service'; } from '@ghostfolio/client/services/settings-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { InfoItem, User } from '@ghostfolio/common/interfaces'; import { InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types';
import { AssistantComponent } from '@ghostfolio/ui/assistant/assistant.component'; import { AssistantComponent } from '@ghostfolio/ui/assistant/assistant.component';
import { Tag } from '@prisma/client';
import { EMPTY, Subject } from 'rxjs'; import { EMPTY, Subject } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators'; import { catchError, takeUntil } from 'rxjs/operators';
@ -88,7 +91,8 @@ export class HeaderComponent implements OnChanges {
private impersonationStorageService: ImpersonationStorageService, private impersonationStorageService: ImpersonationStorageService,
private router: Router, private router: Router,
private settingsStorageService: SettingsStorageService, private settingsStorageService: SettingsStorageService,
private tokenStorageService: TokenStorageService private tokenStorageService: TokenStorageService,
private userService: UserService
) { ) {
this.impersonationStorageService this.impersonationStorageService
.onChangeHasImpersonation() .onChangeHasImpersonation()
@ -144,6 +148,20 @@ export class HeaderComponent implements OnChanges {
window.location.reload(); window.location.reload();
} }
public onDateRangeChange(dateRange: DateRange) {
this.dataService
.putUserSetting({ dateRange })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService.remove();
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
});
}
public onMenuClosed() { public onMenuClosed() {
this.isMenuOpen = false; this.isMenuOpen = false;
} }
@ -156,6 +174,20 @@ export class HeaderComponent implements OnChanges {
this.assistantElement.initialize(); this.assistantElement.initialize();
} }
public onSelectedTagChanged(tag: Tag) {
this.dataService
.putUserSetting({ 'filters.tags': tag ? [tag.id] : null })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService.remove();
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
});
}
public onSignOut() { public onSignOut() {
this.signOut.next(); this.signOut.next();
} }

View File

@ -1,5 +1,5 @@
<div class="container justify-content-center p-3"> <div class="container justify-content-center p-3">
<div class="mb-3 text-center"> <div *ngIf="!user?.settings?.isExperimentalFeatures" class="mb-3 text-center">
<gf-toggle <gf-toggle
[defaultValue]="user?.settings?.dateRange" [defaultValue]="user?.settings?.dateRange"
[isLoading]="positions === undefined" [isLoading]="positions === undefined"

View File

@ -96,7 +96,10 @@
[showDetails]="showDetails" [showDetails]="showDetails"
[unit]="unit" [unit]="unit"
></gf-portfolio-performance> ></gf-portfolio-performance>
<div *ngIf="showDetails" class="text-center"> <div
*ngIf="showDetails && !user?.settings?.isExperimentalFeatures"
class="text-center"
>
<gf-toggle <gf-toggle
[defaultValue]="user?.settings?.dateRange" [defaultValue]="user?.settings?.dateRange"
[isLoading]="isLoadingPerformance" [isLoading]="isLoadingPerformance"

View File

@ -23,7 +23,7 @@
> >
<ion-icon <ion-icon
[name]="isAccessTokenHidden ? 'eye-outline' : 'eye-off-outline'" [name]="isAccessTokenHidden ? 'eye-outline' : 'eye-off-outline'"
></ion-icon> />
</button> </button>
</mat-form-field> </mat-form-field>
</form> </form>

View File

@ -12,7 +12,7 @@
<ion-icon <ion-icon
*ngIf="errors?.length > 0 && !isLoading" *ngIf="errors?.length > 0 && !isLoading"
name="alert-circle-outline" name="alert-circle-outline"
></ion-icon> />
</div> </div>
<div *ngIf="isLoading" class="align-items-center d-flex"> <div *ngIf="isLoading" class="align-items-center d-flex">
<ngx-skeleton-loader <ngx-skeleton-loader

View File

@ -69,7 +69,10 @@
</div> </div>
</div> </div>
<div class="flex-nowrap px-3 py-1 row"> <div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 ml-3 text-truncate" i18n>Gross Performance</div> <div class="align-items-center d-flex flex-grow-1 ml-3 text-truncate">
<ng-container i18n>Gross Performance</ng-container>
<abbr class="initialism ml-2 text-muted" title="Time-Weighted Rate of Return">(TWR)</abbr>
</div>
<div class="flex-column flex-wrap justify-content-end"> <div class="flex-column flex-wrap justify-content-end">
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
@ -112,7 +115,10 @@
</div> </div>
</div> </div>
<div class="flex-nowrap px-3 py-1 row"> <div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate ml-3" i18n>Net Performance</div> <div class="flex-grow-1 text-truncate ml-3">
<ng-container i18n>Net Performance</ng-container>
<abbr class="initialism ml-2 text-muted" title="Time-Weighted Rate of Return">(TWR)</abbr>
</div>
<div class="flex-column flex-wrap justify-content-end"> <div class="flex-column flex-wrap justify-content-end">
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
@ -163,7 +169,7 @@
*ngIf="hasPermissionToUpdateUserSettings && !isLoading" *ngIf="hasPermissionToUpdateUserSettings && !isLoading"
class="mr-1 text-muted" class="mr-1 text-muted"
name="ellipsis-horizontal-circle-outline" name="ellipsis-horizontal-circle-outline"
></ion-icon> />
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
[isCurrency]="true" [isCurrency]="true"

View File

@ -7,12 +7,16 @@ import {
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Sort, SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper'; import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
import { import {
DataProviderInfo, DataProviderInfo,
EnhancedSymbolProfile, EnhancedSymbolProfile,
LineChartItem LineChartItem,
User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
@ -31,6 +35,7 @@ import { PositionDetailDialogParams } from './interfaces/interfaces';
styleUrls: ['./position-detail-dialog.component.scss'] styleUrls: ['./position-detail-dialog.component.scss']
}) })
export class PositionDetailDialog implements OnDestroy, OnInit { export class PositionDetailDialog implements OnDestroy, OnInit {
public activities: OrderWithAccount[];
public assetClass: string; public assetClass: string;
public assetSubClass: string; public assetSubClass: string;
public averagePrice: number; public averagePrice: number;
@ -39,6 +44,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
[code: string]: { name: string; value: number }; [code: string]: { name: string; value: number };
}; };
public dataProviderInfo: DataProviderInfo; public dataProviderInfo: DataProviderInfo;
public dataSource: MatTableDataSource<OrderWithAccount>;
public dividendInBaseCurrency: number; public dividendInBaseCurrency: number;
public feeInBaseCurrency: number; public feeInBaseCurrency: number;
public firstBuyDate: string; public firstBuyDate: string;
@ -51,16 +57,19 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
public minPrice: number; public minPrice: number;
public netPerformance: number; public netPerformance: number;
public netPerformancePercent: number; public netPerformancePercent: number;
public orders: OrderWithAccount[];
public quantity: number; public quantity: number;
public quantityPrecision = 2; public quantityPrecision = 2;
public reportDataGlitchMail: string; public reportDataGlitchMail: string;
public sectors: { public sectors: {
[name: string]: { name: string; value: number }; [name: string]: { name: string; value: number };
}; };
public sortColumn = 'date';
public sortDirection: SortDirection = 'desc';
public SymbolProfile: EnhancedSymbolProfile; public SymbolProfile: EnhancedSymbolProfile;
public tags: Tag[]; public tags: Tag[];
public totalItems: number;
public transactionCount: number; public transactionCount: number;
public user: User;
public value: number; public value: number;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -69,7 +78,8 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
public dialogRef: MatDialogRef<PositionDetailDialog>, public dialogRef: MatDialogRef<PositionDetailDialog>,
@Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams @Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams,
private userService: UserService
) {} ) {}
public ngOnInit(): void { public ngOnInit(): void {
@ -102,10 +112,12 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
transactionCount, transactionCount,
value value
}) => { }) => {
this.activities = orders;
this.averagePrice = averagePrice; this.averagePrice = averagePrice;
this.benchmarkDataItems = []; this.benchmarkDataItems = [];
this.countries = {}; this.countries = {};
this.dataProviderInfo = dataProviderInfo; this.dataProviderInfo = dataProviderInfo;
this.dataSource = new MatTableDataSource(orders.reverse());
this.dividendInBaseCurrency = dividendInBaseCurrency; this.dividendInBaseCurrency = dividendInBaseCurrency;
this.feeInBaseCurrency = feeInBaseCurrency; this.feeInBaseCurrency = feeInBaseCurrency;
this.firstBuyDate = firstBuyDate; this.firstBuyDate = firstBuyDate;
@ -130,7 +142,6 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
this.minPrice = minPrice; this.minPrice = minPrice;
this.netPerformance = netPerformance; this.netPerformance = netPerformance;
this.netPerformancePercent = netPerformancePercent; this.netPerformancePercent = netPerformancePercent;
this.orders = orders;
this.quantity = quantity; this.quantity = quantity;
this.reportDataGlitchMail = `mailto:hi@ghostfol.io?Subject=Ghostfolio Data Glitch Report&body=Hello%0D%0DI would like to report a data glitch for%0D%0DSymbol: ${SymbolProfile?.symbol}%0DData Source: ${SymbolProfile?.dataSource}%0D%0DAdditional notes:%0D%0DCan you please take a look?%0D%0DKind regards`; this.reportDataGlitchMail = `mailto:hi@ghostfol.io?Subject=Ghostfolio Data Glitch Report&body=Hello%0D%0DI would like to report a data glitch for%0D%0DSymbol: ${SymbolProfile?.symbol}%0DData Source: ${SymbolProfile?.dataSource}%0D%0DAdditional notes:%0D%0DCan you please take a look?%0D%0DKind regards`;
this.sectors = {}; this.sectors = {};
@ -142,6 +153,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
}; };
}); });
this.transactionCount = transactionCount; this.transactionCount = transactionCount;
this.totalItems = transactionCount;
this.value = value; this.value = value;
if (SymbolProfile?.assetClass) { if (SymbolProfile?.assetClass) {
@ -239,6 +251,16 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
); );
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.changeDetectorRef.markForCheck();
}
});
} }
public onClose(): void { public onClose(): void {
@ -246,12 +268,20 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
} }
public onExport() { public onExport() {
let activityIds = [];
if (this.user?.settings?.isExperimentalFeatures === true) {
activityIds = this.dataSource.data.map(({ id }) => {
return id;
});
} else {
activityIds = this.activities.map(({ id }) => {
return id;
});
}
this.dataService this.dataService
.fetchExport( .fetchExport(activityIds)
this.orders.map((order) => {
return order.id;
})
)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => { .subscribe((data) => {
downloadAsFile({ downloadAsFile({

View File

@ -246,11 +246,30 @@
</div> </div>
</div> </div>
<div class="row" [ngClass]="{ 'd-none': !orders?.length }"> <div class="row" [ngClass]="{ 'd-none': !activities?.length }">
<div class="col mb-3"> <div class="col mb-3">
<div class="h5 mb-0" i18n>Activities</div> <div class="h5 mb-0" i18n>Activities</div>
<gf-activities-table-lazy
*ngIf="user?.settings?.isExperimentalFeatures === true"
[baseCurrency]="data.baseCurrency"
[dataSource]="dataSource"
[deviceType]="data.deviceType"
[hasPermissionToCreateActivity]="false"
[hasPermissionToExportActivities]="!hasImpersonationId && !user.settings.isRestrictedView"
[hasPermissionToFilter]="false"
[hasPermissionToOpenDetails]="false"
[locale]="data.locale"
[showActions]="false"
[showNameColumn]="false"
[sortColumn]="sortColumn"
[sortDirection]="sortDirection"
[sortDisabled]="true"
[totalItems]="totalItems"
(export)="onExport()"
></gf-activities-table-lazy>
<gf-activities-table <gf-activities-table
[activities]="orders" *ngIf="user?.settings?.isExperimentalFeatures !== true"
[activities]="activities"
[baseCurrency]="data.baseCurrency" [baseCurrency]="data.baseCurrency"
[deviceType]="data.deviceType" [deviceType]="data.deviceType"
[hasPermissionToCreateActivity]="false" [hasPermissionToCreateActivity]="false"
@ -277,7 +296,7 @@
</div> </div>
<div <div
*ngIf="data.hasPermissionToReportDataGlitch === true && orders?.length > 0" *ngIf="activities?.length > 0 && data.hasPermissionToReportDataGlitch === true"
class="row" class="row"
> >
<div class="col"> <div class="col">

View File

@ -5,6 +5,7 @@ import { MatChipsModule } from '@angular/material/chips';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module'; import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module'; import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
import { GfActivitiesTableLazyModule } from '@ghostfolio/ui/activities-table-lazy/activities-table-lazy.module';
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module'; import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
import { GfDataProviderCreditsModule } from '@ghostfolio/ui/data-provider-credits/data-provider-credits.module'; import { GfDataProviderCreditsModule } from '@ghostfolio/ui/data-provider-credits/data-provider-credits.module';
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module'; import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
@ -19,6 +20,7 @@ import { PositionDetailDialog } from './position-detail-dialog.component';
imports: [ imports: [
CommonModule, CommonModule,
GfActivitiesTableModule, GfActivitiesTableModule,
GfActivitiesTableLazyModule,
GfDataProviderCreditsModule, GfDataProviderCreditsModule,
GfDialogFooterModule, GfDialogFooterModule,
GfDialogHeaderModule, GfDialogHeaderModule,

View File

@ -65,7 +65,7 @@
class="chevron text-muted" class="chevron text-muted"
name="chevron-forward-outline" name="chevron-forward-outline"
size="small" size="small"
></ion-icon> />
</div> </div>
</a> </a>
</div> </div>

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