Compare commits

...

92 Commits

Author SHA1 Message Date
Thomas Kaul
52df0c62ab Release 2.18.0 (#2600) 2023-11-05 11:53:38 +01:00
Lukas Möller
e8e1bb83bf Fix get quotes in CoinGecko service (#2595)
* Fix get quotes in CoinGecko service

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-11-05 11:52:09 +01:00
Thomas Kaul
45510702d0 Feature/add hacktoberfest 2023 debriefing blog post (#2599)
* Add blog post: Hacktoberfest 2023 Debriefing

* Update changelog
2023-11-05 11:36:52 +01:00
Thomas Kaul
1b7e3a1e47 Feature/support activities import by isin for yahoo finance (#2597)
* Add support to import activities by isin

* Update changelog
2023-11-05 09:58:57 +01:00
Thomas Kaul
35f98b9d2d Bugfix/handle failing database query for account find many (#2598)
* Handle issue with account.findMany() -> where: { id: { in: [ null ] } }

* Update changelog
2023-11-05 09:57:23 +01:00
Thomas Kaul
e980aed9e7 Reorder functions (#2594) 2023-11-05 08:50:43 +01:00
Thomas Kaul
d993067e9a Feature/extend personal finance tools pages 20231104 2 (#2591)
* Add Vyzer

* Add FinWise
2023-11-05 08:50:27 +01:00
Thomas Kaul
3d09bfdb0c Feature/upgrade angular to version 16.2.12 (#2590)
* Add upgrade guides for Angular

* Upgrade Angular dependencies to version 16.2.12

* Update changelog
2023-11-04 11:59:49 +01:00
Thomas Kaul
3fbc4f500f Add empty columns (#2589) 2023-11-04 10:39:36 +01:00
Ian Schmitz
373201a98f Add major version to docker tags (#2586)
* Add major version to docker tags

* Update changelog
2023-11-04 10:38:35 +01:00
Thomas Kaul
681f88f002 Clean up import (#2492) 2023-11-04 10:19:15 +01:00
Thomas Kaul
8a523a981a Bugfix/fix fees on account level (#2588)
* Fix fees on account level

* Update changelog
2023-11-04 10:17:58 +01:00
Thomas Kaul
81ded53363 Center membership card (#2582) 2023-11-04 10:17:35 +01:00
Thomas Kaul
5272407af8 Feature/extend personal finance tools pages 20231104 (#2587)
* Introduce alias

* Add Rocket Money

* Add 8FIGURES
2023-11-04 10:17:17 +01:00
Thomas Kaul
c48f89d117 Add empty columns (#2583) 2023-11-04 10:11:58 +01:00
Thomas Kaul
046fdd3ae7 Release 2.17.0 (#2579) 2023-11-02 19:36:59 +01:00
Thomas Kaul
e69c7a753c Feature/add edit exchange rate button to admin control (#2577)
* Ad edit button

* Update changelog
2023-11-02 19:35:03 +01:00
Thomas Kaul
5191415b5a Add Intuit Mint (#2578) 2023-11-02 19:34:42 +01:00
Thomas Kaul
a704378702 Refactor interface of getQuotes() to object (#2570) 2023-11-01 13:55:48 +01:00
Thomas Kaul
cf7ce64de7 Bugfix/improve alignment of menu item icons (#2566)
* Improve alignment

* Update changelog
2023-10-31 14:01:33 +01:00
Thomas Kaul
8c1b45f35b Bugfix/fix exception in webauthn page (#2564)
* Remove useBrowserAutofill option in startAuthentication()

* Update changelog
2023-10-30 19:23:55 +01:00
Thomas Kaul
6ad1528d01 Feature/improve language localization for german 20231029 (#2565)
* Update locales

* Update changelog
2023-10-29 19:33:42 +01:00
Thomas Kaul
4a6fbe4d30 Release 2.16.0 (#2563) 2023-10-29 08:59:00 +01:00
Thomas Kaul
e31741f0c7 Add Capitally (#2562) 2023-10-29 08:57:33 +01:00
Thomas Kaul
b26aa7f51d Feature/improve duplicate check in activities import (#2561)
* Allow different accounts

* Update changelog
2023-10-29 08:40:42 +01:00
Thomas Kaul
c0fccd186f Feature/upgrade prisma to version 5.5.2 (#2560)
* Upgrade prisma to version 5.5.2

* Update changelog
2023-10-29 08:20:55 +01:00
Thomas Kaul
a7baad10d1 Feature/improve import of historical market data (#2559)
* Improve historical market data import

* Update changelog
2023-10-28 21:08:44 +02:00
Thomas Kaul
16f1b16e41 Feature/change checkboxes to slide toggles in admin control panel (#2551)
* Change checkboxes to slide toggles

* Update changelog
2023-10-28 20:46:44 +02:00
Thomas Kaul
409ddc90ce Feature/improve language localization for german 20231028 (#2556)
* Improve localization

* Update changelog
2023-10-28 20:46:13 +02:00
Thomas Kaul
95bc84956e Feature/localize keywords of meta data (#2555)
* Localize keywords

* Update changelog
2023-10-28 15:39:01 +02:00
Ajay
20cefaba19 Improve usability and validation in cash balance transfer (#2552)
* Improve usability and validation in cash balance transfer

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-28 15:09:01 +02:00
Thomas Kaul
379c651ce0 Add translation for product slogan (#2554) 2023-10-28 15:07:15 +02:00
Thomas Kaul
7804c6879d Clean up (#2543) 2023-10-28 11:24:50 +02:00
Thomas Kaul
de2255f9ba Release 2.15.0 (#2548) 2023-10-26 19:38:44 +02:00
Thomas Kaul
e4ec5f213e Feature/extend personal finance tools pages 20231026 (#2547)
* Improve wording (vs)

* Improve breadcrumb (vs)

* Add Beanvest

* Add Wealthica

* Update locales
2023-10-26 19:36:34 +02:00
Rahul RK
f3c2fb853d Upgrade to Nx 17 (#2545)
* Upgrade to Nx 17

* Update changelog
2023-10-26 19:35:56 +02:00
Thomas Kaul
f5ad1d2d24 Feature/set validation rule to positive number in cash balance transfer (#2544)
* Add validation rule (positive number)

* Update changelog
2023-10-26 19:19:43 +02:00
Aldrin
0af37ca1d7 Extend asset profile dialog form (#2535)
* Extend asset profile dialog form

* Update changelog
2023-10-25 20:28:51 +02:00
Basim Mohammed
2992a0da4c Verify current benchmark before loading it (#2541)
* Verify current benchmark before loading it

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-25 20:27:41 +02:00
Rafael
2dcc7e161c Improve validation of activities import (#2496)
* Improve validation of activities import: expects positive values for fee, quantity and unitPrice

* Update changelog

---------

Co-authored-by: Rafael Claudio <rafacla@github.com>
Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-24 20:51:48 +02:00
Thomas Kaul
fa627f686f Bugfix/fix chart for account excluded from analysis (#2534)
* Fix chart for account excluded from analysis

* Update changelog
2023-10-24 20:13:57 +02:00
Thomas Kaul
0567083fc1 Feature/upgrade UUID to version 9.0.1 (#2537)
* Upgrade uuid to version 9.0.1

* Update changelog
2023-10-24 20:12:00 +02:00
Thomas Kaul
3212efef17 Feature/upgrade yahoo finance2 to version 2.8.1 (#2536)
* Upgrade yahoo-finance2 to version 2.8.1

* Update changelog
2023-10-24 19:40:53 +02:00
Thomas Kaul
6077e7c2f9 Feature/improve position detail dialog (#2532)
* Improve style and wording

* Update locales

* Update changelog
2023-10-23 20:50:40 +02:00
Aldrin
96b5dcfaf8 Create reusable currency selector component using mat-autocomplete (#2487)
* Create reusable currency selector component using mat-autocomplete

* Update changelog
2023-10-23 20:30:05 +02:00
Thomas Kaul
c4e8e37884 Release 2.14.0 (#2530) 2023-10-21 20:07:40 +02:00
Thomas Kaul
281d33f825 Feature/update oss friends 20231021 (#2529)
* Update OSS friends

* Improve style of sub title
2023-10-21 20:05:20 +02:00
Thomas Kaul
5822e4d186 Bugfix/fix style of active page in header navigation (#2528)
* Fix style of active page

* Update changelog
2023-10-21 19:34:44 +02:00
Thomas Kaul
cb166dcc78 Redirect to membership page (#2527) 2023-10-21 18:43:33 +02:00
Thomas Kaul
4e7b7375a9 Feature/setup open figi (#2526)
* Setup OpenFIGI

* Update changelog
2023-10-21 18:12:50 +02:00
Thomas Kaul
b8626c2086 Feature/change fees interest and search to general availability (#2525)
* Change features to general availability

* Fees on account level
* Interest on account level
* Search for a holding

* Update changelog

* Add documentation for experimental features
2023-10-21 10:25:05 +02:00
Thomas Kaul
a59f9fa037 Feature/remove version from client (#2522)
* Remove version

* Update changelog
2023-10-21 09:41:07 +02:00
Thomas Kaul
1666486940 Bugfix/trim text in i18n service (#2520)
* Trim text

* Update changelog
2023-10-20 22:36:03 +02:00
Don L
ac0ad48a65 Display invalid activity in csv import (#2460)
* Display invalid activity in csv import

* Update changelog
2023-10-20 22:07:57 +02:00
Thomas Kaul
6a19eab425 Feature/improve membership card (#2517)
* Improve style

* Add animated border
2023-10-20 22:05:27 +02:00
Thomas Kaul
750c627613 Feature/allow to edit market data of today (#2515)
* Allow to edit today's market data

* Update changelog
2023-10-20 17:37:55 +02:00
Thomas Kaul
60b2115e3b Release 2.13.0 (#2514) 2023-10-20 08:24:49 +02:00
Arshad Jamal
e7956943ba Make holdings request only once (#2453)
* Make holdings request only once

* Update changelog
2023-10-20 08:21:23 +02:00
Basim Mohammed
f66edf8de0 Add membership card component (#2507)
* Add membership card component

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-20 08:18:34 +02:00
Aldrin
29028a81f5 Add i18n service to query XML files (#2503)
* Add i18n service to query XML files

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-19 17:13:40 +02:00
LIYANMUBARAK
c9878c9050 Migrated users table in Admin Control to mat-table (#2469)
* Migrated users table in Admin Control to mat-table

* Update changelog
2023-10-19 16:58:01 +02:00
Basim Mohammed
73ac4b4197 Add chart to account detail dialog (#2502)
* Add chart to account detail dialog

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-19 15:51:31 +02:00
Thomas Kaul
016634a77f Feature/setup i18n page (#2508)
* Setup i18n page

* Add meta description
2023-10-18 17:35:07 +02:00
Thomas Kaul
ea65dc5034 Release 2.12.0 (#2505) 2023-10-17 20:52:02 +02:00
RaviTejaVattem
84db54babd Change checkboxes to slide toggles on user settings page (#2497)
* Change checkboxes to slide toggles on user settings page

* Update changelog
2023-10-17 20:49:54 +02:00
Thomas Kaul
653c9c62a8 Sort imports (#2490) 2023-10-17 20:42:32 +02:00
Thomas Kaul
74278073b3 Bugfix/fix query to get asset profiles matching data source and symbol (#2504)
* Match dataSource and symbol

* Update changelog
2023-10-17 20:36:01 +02:00
Thomas Kaul
0375b938a2 Add confirmation dialog (#2501) 2023-10-17 20:14:46 +02:00
Manushreshta B L
32df7620d9 Add support for creating asset profiles with MANUAL data source (#2479)
* Add support for creating asset profiles with MANUAL data source

* Refactoring

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-17 18:33:22 +02:00
Kevin
8492a8fed0 Upgrade simplewebauthn (#2498)
* Upgrade simplewebauthn to new major version

* Update changelog
2023-10-17 09:17:44 +02:00
Thomas Kaul
30e561c06f Feature/extend assistant with search for asset profile (#2499)
* Extend assistant with search for asset profile

* Extend search results by currency, symbol and asset sub class

* Update changelog
2023-10-16 21:54:36 +02:00
Thomas Kaul
7243090c0e Feature/copy client locales to assets of server (#2493)
* Copy client locales to server’s assets

* Update changelog
2023-10-15 18:08:44 +02:00
Pavol Kolcun
7ae49eb839 Add endpoint for account balances (#2484)
* Add endpoint for account balances

* Update changelog

---------

Co-authored-by: Pavol Kolcun <pavol.kolcun@student.tuke.sk>
Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-15 17:09:47 +02:00
Thomas Kaul
bf816c3b89 Bugfix/show transfer balance button based on permission (#2489)
* Show button depending on permission

* Update changelog
2023-10-15 10:02:55 +02:00
Thomas Kaul
20f9225daa Release 2.11.0 (#2488) 2023-10-14 19:12:37 +02:00
Kevin
b6101c6375 Feature/import historical data (#2448)
* Import historical data for an asset

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-14 19:06:27 +02:00
Thomas Kaul
e1022846b9 Bugfix/fix displayed currency of cash balance in account dialog (#2480)
* Fix currency

* Update changelog
2023-10-14 15:01:29 +02:00
Thomas Kaul
9ba79f6721 Improve style (#2478) 2023-10-13 22:11:05 +02:00
Aldrin
0ac97bd112 Transfer cash balance between accounts (#2455)
* Transfer cash balance between accounts

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-13 21:46:49 +02:00
Thomas Kaul
827270704a Bugfix/fix import for activities of type fee and interest (#2474)
* Fix import for activities of type fee and interest

* Update changelog
2023-10-13 19:51:02 +02:00
Thomas Kaul
8634463597 Feature/upgrade prisma to version 5.4.2 (#2477)
* Upgrade prisma to version 5.4.2

* Update changelog
2023-10-13 19:50:06 +02:00
Thomas Kaul
3905782ad6 Fix fab-container (#2476) 2023-10-13 19:49:30 +02:00
Aldrin
5db984ffef Add date column to benchmark component (#2466)
* Add date column to benchmark component
2023-10-12 10:21:00 +02:00
Thomas Kaul
fb3cd4b689 Remove icon (#2467) 2023-10-11 09:58:18 +02:00
Aldrin
3b5a34f6f3 Use fab-button in access management tab (#2456)
* Use fab-button in access management tab

* Refactor fab container

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-10-11 09:57:35 +02:00
Thomas Kaul
22b43b5bfc Feature/extract locales 20231010 (#2462)
* Update locales

* Update changelog
2023-10-10 20:17:45 +02:00
Kevin
6c66033eb4 Add date to markets overview by benchmarks (#2436)
* Add date

* Update changelog
2023-10-10 17:31:53 +02:00
Thomas Kaul
162fc25e23 Release 2.10.0 (#2459) 2023-10-09 20:31:34 +02:00
Thomas Kaul
45f385a483 Feature/improve symbol conversion in eod historical data service (#2457)
* Improve conversion of currency symbols

* Update changelog
2023-10-09 20:29:56 +02:00
Thomas Kaul
e9ef911548 Feature/improve search results display in assistant (#2458)
* Only show search results if search is active

* Update changelog
2023-10-09 20:28:39 +02:00
Basim Mohammed
d8d4d8f001 Change jobs table in admin control to mat-table (#2444)
* Change jobs table in admin control to mat-table

* Update changelog
2023-10-09 19:38:33 +02:00
Sanjeev Sharma
f47c7313af Support enter key press to submit access dialog form (#2437)
* Support enter key press to submit access dialog form

* Update changelog
2023-10-09 19:11:09 +02:00
195 changed files with 26416 additions and 14437 deletions

View File

@@ -21,6 +21,7 @@ jobs:
with: with:
images: ghostfolio/ghostfolio images: ghostfolio/ghostfolio
tags: | tags: |
type=semver,pattern={{major}}
type=semver,pattern={{version}} type=semver,pattern={{version}}
- name: Set up QEMU - name: Set up QEMU

1
.gitignore vendored
View File

@@ -27,6 +27,7 @@
/.angular/cache /.angular/cache
.env .env
.env.prod .env.prod
.nx/cache
/.sass-cache /.sass-cache
/connect.lock /connect.lock
/coverage /coverage

View File

@@ -1,2 +1,3 @@
/.nx/cache
/dist /dist
/test/import /test/import

View File

@@ -5,6 +5,161 @@ 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.18.0 - 2023-11-05
### Added
- Added support to import activities by `isin` in the _Yahoo Finance_ service
- Added a new tag with the major version to the docker image on _Docker Hub_
- Added a blog post: _Hacktoberfest 2023 Debriefing_
### Changed
- Upgraded `angular` from version `16.2.1` to `16.2.12`
### Fixed
- Fixed an issue to get quotes in the _CoinGecko_ service
- Loosened the validation in the activities import (expects values greater than or equal to 0 for `fee`, `quantity` and `unitPrice`)
- Handled an issue with a failing database query (`account.findMany()`) related to activities without account
## 2.17.0 - 2023-11-02
### Added
- Added a button to edit the exchange rates in the admin control panel
### Changed
- Improved the language localization for German (`de`)
### Fixed
- Fixed an issue in the biometric authentication
- Fixed the alignment of the icons in various menus
## 2.16.0 - 2023-10-29
### Changed
- Improved the check for duplicates in the preview step of the activities import (allow different accounts)
- Improved the usability and validation in the cash balance transfer from one to another account
- Changed the checkboxes to slide toggles in the overview of the admin control panel
- Switched from the deprecated (`PUT`) to the new endpoint (`POST`) to manage historical market data in the asset profile details dialog of the admin control panel
- Improved the date parsing in the import historical market data of the admin control panel
- Improved the localized meta data (keywords) in `html` files
- Improved the language localization for German (`de`)
- Upgraded `prisma` from version `5.4.2` to `5.5.2`
## 2.15.0 - 2023-10-26
### Added
- Added support to edit the name, asset class and asset sub class of asset profiles with `MANUAL` data source in the asset profile details dialog of the admin control panel
### Changed
- Improved the style and wording of the position detail dialog
- Improved the validation in the activities import (expects positive values for `fee`, `quantity` and `unitPrice`)
- Improved the validation in the cash balance transfer from one to another account (expects a positive value)
- Changed the currency selector in the create or update account dialog to `@angular/material/autocomplete`
- Upgraded `Nx` from version `16.7.4` to `17.0.2`
- Upgraded `uuid` from version `9.0.0` to `9.0.1`
- Upgraded `yahoo-finance2` from version `2.8.0` to `2.8.1`
### Fixed
- Fixed the chart in the account detail dialog for accounts excluded from analysis
- Verified the current benchmark before loading it on the analysis page
## 2.14.0 - 2023-10-21
### Added
- Added the _OpenFIGI_ data enhancer for _Financial Instrument Global Identifier_ (FIGI)
- Added `figi`, `figiComposite` and `figiShareClass` to the asset profile model
### Changed
- Moved the fees on account level feature from experimental to general availability
- Moved the interest on account level feature from experimental to general availability
- Moved the search for a holding from experimental to general availability
- Improved the error message in the activities import for `csv` files
- Removed the application version from the client
- Allowed to edit todays historical market data in the asset profile details dialog of the admin control panel
### Fixed
- Fixed the style of the active page in the header navigation
- Trimmed text in `i18n` service to query `messages.*.xlf` files on the server
## 2.13.0 - 2023-10-20
### Added
- Added a chart to the account detail dialog
- Added an `i18n` service to query `messages.*.xlf` files on the server
### Changed
- Changed the users table in the admin control panel to an `@angular/material` data table
- Improved the styling of the membership status
### Fixed
- Fixed an issue where holdings were requested twice from the server
## 2.12.0 - 2023-10-17
### Added
- Added the endpoint `GET api/v1/account/:id/balances` which provides historical cash balances
- Added support to search for an asset profile by `isin`, `name` and `symbol` as an administrator (experimental)
- Added support for creating asset profiles with `MANUAL` data source
### Changed
- Changed the checkboxes to slide toggles in the user settings of the user account page
- Extended the `copy-assets` `Nx` target to copy the locales to the servers assets
- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `5.2.1` to `8.3`
### Fixed
- Displayed the transfer cash balance button based on a permission
- Fixed the biometric authentication
- Fixed the query to get asset profiles that match both the `dataSource` and `symbol` values
## 2.11.0 - 2023-10-14
### Added
- Added support to transfer a part of the cash balance from one to another account
- Extended the markets overview by benchmarks (date of last all time high)
- Added support to import historical market data in the admin control panel
### Changed
- Harmonized the style of the create button on the page for granting and revoking public access to share the portfolio
- Improved the language localization for German (`de`)
- Upgraded `prisma` from version `5.3.1` to `5.4.2`
### Fixed
- Fixed `FEE` and `INTEREST` types in the activities import of `csv` files
- Fixed the displayed currency of the cash balance in the create or update account dialog
## 2.10.0 - 2023-10-09
### Added
- Supported enter key press to submit the form of the create or update access dialog
### Changed
- Improved the display of the results in the search for a holding
- Changed the queue jobs view in the admin control panel to an `@angular/material` data table
- Improved the symbol conversion in the _EOD Historical Data_ service
## 2.9.0 - 2023-10-08 ## 2.9.0 - 2023-10-08
### Added ### Added
@@ -95,13 +250,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Improved the preselected currency based on the account's currency in the create or edit activity dialog - Improved the preselected currency based on the accounts currency in the create or edit activity dialog
- Unlocked the experimental features setting for all users - Unlocked the experimental features setting for all users
- Upgraded `prisma` from version `5.2.0` to `5.3.1` - Upgraded `prisma` from version `5.2.0` to `5.3.1`
### Fixed ### Fixed
- Fixed a memory leak related to the server's timezone (behind UTC) in the data gathering - Fixed a memory leak related to the servers timezone (behind UTC) in the data gathering
## 2.3.0 - 2023-09-17 ## 2.3.0 - 2023-09-17
@@ -252,7 +407,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Optimized the activities import by allowing a different currency than the asset's official one - Optimized the activities import by allowing a different currency than the assets official one
- Added a timeout to the _EOD Historical Data_ requests - Added a timeout to the _EOD Historical Data_ requests
- Migrated the requests from `bent` to `got` in the _EOD Historical Data_ service - Migrated the requests from `bent` to `got` in the _EOD Historical Data_ service
@@ -759,7 +914,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Persisted today's market data continuously - Persisted todays market data continuously
### Fixed ### Fixed
@@ -993,7 +1148,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Filtered activities with type `ITEM` from search results - Filtered activities with type `ITEM` from search results
- Considered the user's language in the _Stripe_ checkout - Considered the users language in the _Stripe_ checkout
- Upgraded the _Stripe_ dependencies - Upgraded the _Stripe_ dependencies
- Upgraded `twitter-api-v2` from version `1.10.3` to `1.14.2` - Upgraded `twitter-api-v2` from version `1.10.3` to `1.14.2`
@@ -2667,7 +2822,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Moved the countries and sectors charts in the position detail dialog - Moved the countries and sectors charts in the position detail dialog
- Distinguished today's data point of historical data in the admin control panel - Distinguished todays data point of historical data in the admin control panel
- Restructured the server modules - Restructured the server modules
### Fixed ### Fixed

View File

@@ -1,5 +1,17 @@
# Ghostfolio Development Guide # Ghostfolio Development Guide
## Experimental Features
New functionality can be enabled using a feature flag switch from the user settings.
### Backend
Remove permission in `UserService` using `without()`
### Frontend
Use `*ngIf="user?.settings?.isExperimentalFeatures"` in HTML template
## Git ## Git
### Rebase ### Rebase
@@ -8,6 +20,12 @@
## Dependencies ## Dependencies
### Angular
#### Upgrade (minor versions)
1. Run `npx npm-check-updates --upgrade --target "minor" --filter "/@angular.*/"`
### Nx ### Nx
#### Upgrade #### Upgrade

View File

@@ -8,4 +8,8 @@ export class CreateAccessDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
granteeUserId?: string; granteeUserId?: string;
@IsOptional()
@IsString()
type?: 'PUBLIC';
} }

View File

@@ -1,8 +1,12 @@
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
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';
import { Accounts } from '@ghostfolio/common/interfaces'; import {
AccountBalancesResponse,
Accounts
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { import type {
AccountWithValue, AccountWithValue,
@@ -29,11 +33,13 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccountService } from './account.service'; import { AccountService } from './account.service';
import { CreateAccountDto } from './create-account.dto'; import { CreateAccountDto } from './create-account.dto';
import { TransferBalanceDto } from './transfer-balance.dto';
import { UpdateAccountDto } from './update-account.dto'; import { UpdateAccountDto } from './update-account.dto';
@Controller('account') @Controller('account')
export class AccountController { export class AccountController {
public constructor( public constructor(
private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly impersonationService: ImpersonationService, private readonly impersonationService: ImpersonationService,
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
@@ -115,6 +121,18 @@ export class AccountController {
return accountsWithAggregations.accounts[0]; return accountsWithAggregations.accounts[0];
} }
@Get(':id/balances')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(RedactValuesInResponseInterceptor)
public async getAccountBalancesById(
@Param('id') id: string
): Promise<AccountBalancesResponse> {
return this.accountBalanceService.getAccountBalances({
accountId: id,
userId: this.request.user.id
});
}
@Post() @Post()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async createAccount( public async createAccount(
@@ -154,6 +172,68 @@ export class AccountController {
} }
} }
@Post('transfer-balance')
@UseGuards(AuthGuard('jwt'))
public async transferAccountBalance(
@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(
this.request.user.id
);
const accountFrom = accountsOfUser.find(({ id }) => {
return id === accountIdFrom;
});
const accountTo = accountsOfUser.find(({ id }) => {
return id === accountIdTo;
});
if (!accountFrom || !accountTo) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
if (accountFrom.id === accountTo.id) {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
if (accountFrom.balance < balance) {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
await this.accountService.updateAccountBalance({
accountId: accountFrom.id,
amount: -balance,
currency: accountFrom.currency,
userId: this.request.user.id
});
await this.accountService.updateAccountBalance({
accountId: accountTo.id,
amount: balance,
currency: accountFrom.currency,
userId: this.request.user.id
});
}
@Put(':id') @Put(':id')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) { public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {

View File

@@ -109,7 +109,7 @@ export class AccountService {
}); });
} }
public async getAccounts(aUserId: string) { public async getAccounts(aUserId: string): Promise<Account[]> {
const accounts = await this.accounts({ const accounts = await this.accounts({
include: { Order: true, Platform: true }, include: { Order: true, Platform: true },
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
@@ -218,13 +218,13 @@ export class AccountService {
accountId, accountId,
amount, amount,
currency, currency,
date, date = new Date(),
userId userId
}: { }: {
accountId: string; accountId: string;
amount: number; amount: number;
currency: string; currency: string;
date: Date; date?: Date;
userId: string; userId: string;
}) { }) {
const { balance, currency: currencyOfAccount } = await this.account({ const { balance, currency: currencyOfAccount } = await this.account({

View File

@@ -1,4 +1,4 @@
import { IsNumber, IsString } from 'class-validator'; import { IsNumber, IsPositive, IsString } from 'class-validator';
export class TransferBalanceDto { export class TransferBalanceDto {
@IsString() @IsString()
@@ -8,5 +8,6 @@ export class TransferBalanceDto {
accountIdTo: string; accountIdTo: string;
@IsNumber() @IsNumber()
@IsPositive()
balance: number; balance: number;
} }

View File

@@ -1,19 +1,21 @@
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 { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.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 {
DEFAULT_PAGE_SIZE,
GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import {
getAssetProfileIdentifier,
resetHours
} from '@ghostfolio/common/helper';
import { import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
AdminMarketDataDetails, AdminMarketDataDetails,
EnhancedSymbolProfile, EnhancedSymbolProfile
Filter
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { import type {
@@ -43,12 +45,14 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AdminService } from './admin.service'; import { AdminService } from './admin.service';
import { UpdateAssetProfileDto } from './update-asset-profile.dto'; import { UpdateAssetProfileDto } from './update-asset-profile.dto';
import { UpdateBulkMarketDataDto } from './update-bulk-market-data.dto';
import { UpdateMarketDataDto } from './update-market-data.dto'; import { UpdateMarketDataDto } from './update-market-data.dto';
@Controller('admin') @Controller('admin')
export class AdminController { export class AdminController {
public constructor( public constructor(
private readonly adminService: AdminService, private readonly adminService: AdminService,
private readonly apiService: ApiService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
@@ -254,6 +258,7 @@ export class AdminController {
public async getMarketData( public async getMarketData(
@Query('assetSubClasses') filterByAssetSubClasses?: string, @Query('assetSubClasses') filterByAssetSubClasses?: string,
@Query('presetId') presetId?: MarketDataPreset, @Query('presetId') presetId?: MarketDataPreset,
@Query('query') filterBySearchQuery?: string,
@Query('skip') skip?: number, @Query('skip') skip?: number,
@Query('sortColumn') sortColumn?: string, @Query('sortColumn') sortColumn?: string,
@Query('sortDirection') sortDirection?: Prisma.SortOrder, @Query('sortDirection') sortDirection?: Prisma.SortOrder,
@@ -271,16 +276,10 @@ export class AdminController {
); );
} }
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? []; const filters = this.apiService.buildFiltersFromQueryParams({
filterByAssetSubClasses,
const filters: Filter[] = [ filterBySearchQuery
...assetSubClasses.map((assetSubClass) => { });
return <Filter>{
id: assetSubClass,
type: 'ASSET_SUB_CLASS'
};
})
];
return this.adminService.getMarketData({ return this.adminService.getMarketData({
filters, filters,
@@ -313,6 +312,43 @@ export class AdminController {
return this.adminService.getMarketDataBySymbol({ dataSource, symbol }); return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
} }
@Post('market-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
public async updateMarketData(
@Body() data: UpdateBulkMarketDataDto,
@Param('dataSource') dataSource: DataSource,
@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(
({ date, marketPrice }) => ({
dataSource,
marketPrice,
symbol,
date: resetHours(parseISO(date)),
state: 'CLOSE'
})
);
return this.marketDataService.updateMany({
data: dataBulkUpdate
});
}
/**
* @deprecated
*/
@Put('market-data/:dataSource/:symbol/:dateString') @Put('market-data/:dataSource/:symbol/:dateString')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async update( public async update(
@@ -365,8 +401,11 @@ export class AdminController {
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
); );
} }
return this.adminService.addAssetProfile({
return this.adminService.addAssetProfile({ dataSource, symbol }); dataSource,
symbol,
currency: this.request.user.Settings.settings.baseCurrency
});
} }
@Delete('profile-data/:dataSource/:symbol') @Delete('profile-data/:dataSource/:symbol')

View File

@@ -1,4 +1,5 @@
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
@@ -15,6 +16,7 @@ import { QueueModule } from './queue/queue.module';
@Module({ @Module({
imports: [ imports: [
ApiModule,
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,

View File

@@ -23,7 +23,13 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { MarketDataPreset } from '@ghostfolio/common/types'; import { MarketDataPreset } from '@ghostfolio/common/types';
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { AssetSubClass, Prisma, Property, SymbolProfile } from '@prisma/client'; import {
AssetSubClass,
DataSource,
Prisma,
Property,
SymbolProfile
} from '@prisma/client';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
import { groupBy } from 'lodash'; import { groupBy } from 'lodash';
@@ -41,10 +47,19 @@ export class AdminService {
) {} ) {}
public async addAssetProfile({ public async addAssetProfile({
currency,
dataSource, dataSource,
symbol symbol
}: UniqueAsset): Promise<SymbolProfile | never> { }: UniqueAsset & { currency?: string }): Promise<SymbolProfile | never> {
try { try {
if (dataSource === 'MANUAL') {
return this.symbolProfileService.add({
currency,
dataSource,
symbol
});
}
const assetProfiles = await this.dataProviderService.getAssetProfiles([ const assetProfiles = await this.dataProviderService.getAssetProfiles([
{ dataSource, symbol } { dataSource, symbol }
]); ]);
@@ -85,9 +100,17 @@ export class AdminService {
return currency !== DEFAULT_CURRENCY; return currency !== DEFAULT_CURRENCY;
}) })
.map((currency) => { .map((currency) => {
const label1 = DEFAULT_CURRENCY;
const label2 = currency;
return { return {
label1: DEFAULT_CURRENCY, label1,
label2: currency, label2,
dataSource:
DataSource[
this.configurationService.get('DATA_SOURCE_EXCHANGE_RATES')
],
symbol: `${label1}${label2}`,
value: this.exchangeRateDataService.toCurrency( value: this.exchangeRateDataService.toCurrency(
1, 1,
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
@@ -131,10 +154,14 @@ export class AdminService {
filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }]; filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }];
} }
const searchQuery = filters.find(({ type }) => {
return type === 'SEARCH_QUERY';
})?.id;
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy( const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
filters, filters,
(filter) => { ({ type }) => {
return filter.type; return type;
} }
); );
@@ -147,6 +174,14 @@ export class AdminService {
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id]; where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
} }
if (searchQuery) {
where.OR = [
{ isin: { mode: 'insensitive', startsWith: searchQuery } },
{ name: { mode: 'insensitive', startsWith: searchQuery } },
{ symbol: { mode: 'insensitive', startsWith: searchQuery } }
];
}
if (sortColumn) { if (sortColumn) {
orderBy = [{ [sortColumn]: sortDirection }]; orderBy = [{ [sortColumn]: sortDirection }];
@@ -173,7 +208,9 @@ export class AdminService {
assetSubClass: true, assetSubClass: true,
comment: true, comment: true,
countries: true, countries: true,
currency: true,
dataSource: true, dataSource: true,
name: true,
Order: { Order: {
orderBy: [{ date: 'asc' }], orderBy: [{ date: 'asc' }],
select: { date: true }, select: { date: true },
@@ -194,7 +231,9 @@ export class AdminService {
assetSubClass, assetSubClass,
comment, comment,
countries, countries,
currency,
dataSource, dataSource,
name,
Order, Order,
sectors, sectors,
symbol symbol
@@ -213,8 +252,10 @@ export class AdminService {
assetClass, assetClass,
assetSubClass, assetSubClass,
comment, comment,
currency,
countriesCount, countriesCount,
dataSource, dataSource,
name,
symbol, symbol,
marketDataItemCount, marketDataItemCount,
sectorsCount, sectorsCount,
@@ -276,15 +317,21 @@ export class AdminService {
} }
public async patchAssetProfileData({ public async patchAssetProfileData({
assetClass,
assetSubClass,
comment, comment,
dataSource, dataSource,
name,
scraperConfiguration, scraperConfiguration,
symbol, symbol,
symbolMapping symbolMapping
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) { }: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
await this.symbolProfileService.updateSymbolProfile({ await this.symbolProfileService.updateSymbolProfile({
assetClass,
assetSubClass,
comment, comment,
dataSource, dataSource,
name,
scraperConfiguration, scraperConfiguration,
symbol, symbol,
symbolMapping symbolMapping
@@ -341,6 +388,8 @@ export class AdminService {
symbol, symbol,
assetClass: 'CASH', assetClass: 'CASH',
countriesCount: 0, countriesCount: 0,
currency: symbol.replace(DEFAULT_CURRENCY, ''),
name: symbol,
sectorsCount: 0 sectorsCount: 0
}; };
}); });

View File

@@ -1,11 +1,23 @@
import { Prisma } from '@prisma/client'; import { AssetClass, AssetSubClass, Prisma } from '@prisma/client';
import { IsObject, IsOptional, IsString } from 'class-validator'; import { IsEnum, IsObject, IsOptional, IsString } from 'class-validator';
export class UpdateAssetProfileDto { export class UpdateAssetProfileDto {
@IsEnum(AssetClass, { each: true })
@IsOptional()
assetClass?: AssetClass;
@IsEnum(AssetSubClass, { each: true })
@IsOptional()
assetSubClass?: AssetSubClass;
@IsString() @IsString()
@IsOptional() @IsOptional()
comment?: string; comment?: string;
@IsString()
@IsOptional()
name?: string;
@IsObject() @IsObject()
@IsOptional() @IsOptional()
scraperConfiguration?: Prisma.InputJsonObject; scraperConfiguration?: Prisma.InputJsonObject;

View File

@@ -0,0 +1,11 @@
import { Type } from 'class-transformer';
import { ArrayNotEmpty, IsArray, isNotEmptyObject } from 'class-validator';
import { UpdateMarketDataDto } from './update-market-data.dto';
export class UpdateBulkMarketDataDto {
@ArrayNotEmpty()
@IsArray()
@Type(() => UpdateMarketDataDto)
marketData: UpdateMarketDataDto[];
}

View File

@@ -1,6 +1,10 @@
import { IsNumber } from 'class-validator'; import { IsISO8601, IsNumber, IsOptional } from 'class-validator';
export class UpdateMarketDataDto { export class UpdateMarketDataDto {
@IsISO8601()
@IsOptional()
date?: string;
@IsNumber() @IsNumber()
marketPrice: number; marketPrice: number;
} }

View File

@@ -64,7 +64,7 @@ export class WebAuthService {
} }
}; };
const options = generateRegistrationOptions(opts); const options = await generateRegistrationOptions(opts);
await this.userService.updateUser({ await this.userService.updateUser({
data: { data: {
@@ -88,10 +88,16 @@ export class WebAuthService {
let verification: VerifiedRegistrationResponse; let verification: VerifiedRegistrationResponse;
try { try {
const opts: VerifyRegistrationResponseOpts = { const opts: VerifyRegistrationResponseOpts = {
credential,
expectedChallenge, expectedChallenge,
expectedOrigin: this.expectedOrigin, expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID expectedRPID: this.rpID,
response: {
clientExtensionResults: credential.clientExtensionResults,
id: credential.id,
rawId: credential.rawId,
response: credential.response,
type: 'public-key'
}
}; };
verification = await verifyRegistrationResponse(opts); verification = await verifyRegistrationResponse(opts);
} catch (error) { } catch (error) {
@@ -117,8 +123,8 @@ export class WebAuthService {
*/ */
existingDevice = await this.deviceService.createAuthDevice({ existingDevice = await this.deviceService.createAuthDevice({
counter, counter,
credentialPublicKey, credentialId: Buffer.from(credentialID),
credentialId: credentialID, credentialPublicKey: Buffer.from(credentialPublicKey),
User: { connect: { id: user.id } } User: { connect: { id: user.id } }
}); });
} }
@@ -152,7 +158,7 @@ export class WebAuthService {
userVerification: 'preferred' userVerification: 'preferred'
}; };
const options = generateAuthenticationOptions(opts); const options = await generateAuthenticationOptions(opts);
await this.userService.updateUser({ await this.userService.updateUser({
data: { data: {
@@ -181,7 +187,6 @@ export class WebAuthService {
let verification: VerifiedAuthenticationResponse; let verification: VerifiedAuthenticationResponse;
try { try {
const opts: VerifyAuthenticationResponseOpts = { const opts: VerifyAuthenticationResponseOpts = {
credential,
authenticator: { authenticator: {
credentialID: device.credentialId, credentialID: device.credentialId,
credentialPublicKey: device.credentialPublicKey, credentialPublicKey: device.credentialPublicKey,
@@ -189,9 +194,16 @@ export class WebAuthService {
}, },
expectedChallenge: `${user.authChallenge}`, expectedChallenge: `${user.authChallenge}`,
expectedOrigin: this.expectedOrigin, expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID expectedRPID: this.rpID,
response: {
clientExtensionResults: credential.clientExtensionResults,
id: credential.id,
rawId: credential.rawId,
response: credential.response,
type: 'public-key'
}
}; };
verification = verifyAuthenticationResponse(opts); verification = await verifyAuthenticationResponse(opts);
} catch (error) { } catch (error) {
Logger.error(error, 'WebAuthService'); Logger.error(error, 'WebAuthService');
throw new InternalServerErrorException({ error: error.message }); throw new InternalServerErrorException({ error: error.message });

View File

@@ -64,7 +64,7 @@ export class BenchmarkService {
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles(); const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles();
const promises: Promise<number>[] = []; const promises: Promise<{ date: Date; marketPrice: number }>[] = [];
const quotes = await this.dataProviderService.getQuotes({ const quotes = await this.dataProviderService.getQuotes({
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => { items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
@@ -85,15 +85,14 @@ export class BenchmarkService {
let performancePercentFromAllTimeHigh = 0; let performancePercentFromAllTimeHigh = 0;
if (allTimeHigh && marketPrice) { if (allTimeHigh?.marketPrice && marketPrice) {
performancePercentFromAllTimeHigh = this.calculateChangeInPercentage( performancePercentFromAllTimeHigh = this.calculateChangeInPercentage(
allTimeHigh, allTimeHigh.marketPrice,
marketPrice marketPrice
); );
} else { } else {
storeInCache = false; storeInCache = false;
} }
return { return {
marketCondition: this.getMarketCondition( marketCondition: this.getMarketCondition(
performancePercentFromAllTimeHigh performancePercentFromAllTimeHigh
@@ -101,6 +100,7 @@ export class BenchmarkService {
name: benchmarkAssetProfiles[index].name, name: benchmarkAssetProfiles[index].name,
performances: { performances: {
allTimeHigh: { allTimeHigh: {
date: allTimeHigh.date,
performancePercent: performancePercentFromAllTimeHigh performancePercent: performancePercentFromAllTimeHigh
} }
} }

View File

@@ -83,6 +83,7 @@ export class ImportService {
const isDuplicate = orders.some((activity) => { const isDuplicate = orders.some((activity) => {
return ( return (
activity.accountId === Account?.id &&
activity.SymbolProfile.currency === assetProfile.currency && activity.SymbolProfile.currency === assetProfile.currency &&
activity.SymbolProfile.dataSource === assetProfile.dataSource && activity.SymbolProfile.dataSource === assetProfile.dataSource &&
isSameDay(activity.date, parseDate(dateString)) && isSameDay(activity.date, parseDate(dateString)) &&
@@ -280,6 +281,9 @@ export class ImportService {
createdAt, createdAt,
currency, currency,
dataSource, dataSource,
figi,
figiComposite,
figiShareClass,
id, id,
isin, isin,
name, name,
@@ -350,6 +354,9 @@ export class ImportService {
createdAt, createdAt,
currency, currency,
dataSource, dataSource,
figi,
figiComposite,
figiShareClass,
id, id,
isin, isin,
name, name,
@@ -476,6 +483,7 @@ export class ImportService {
const date = parseISO(<string>(<unknown>dateString)); const date = parseISO(<string>(<unknown>dateString));
const isDuplicate = existingActivities.some((activity) => { const isDuplicate = existingActivities.some((activity) => {
return ( return (
activity.accountId === accountId &&
activity.SymbolProfile.currency === currency && activity.SymbolProfile.currency === currency &&
activity.SymbolProfile.dataSource === dataSource && activity.SymbolProfile.dataSource === dataSource &&
isSameDay(activity.date, date) && isSameDay(activity.date, date) &&
@@ -509,6 +517,9 @@ export class ImportService {
comment: null, comment: null,
countries: null, countries: null,
createdAt: undefined, createdAt: undefined,
figi: null,
figiComposite: null,
figiShareClass: null,
id: undefined, id: undefined,
isin: null, isin: null,
name: null, name: null,

View File

@@ -13,7 +13,8 @@ import {
IsISO8601, IsISO8601,
IsNumber, IsNumber,
IsOptional, IsOptional,
IsString IsString,
Min
} from 'class-validator'; } from 'class-validator';
import { isString } from 'lodash'; import { isString } from 'lodash';
@@ -48,9 +49,11 @@ export class CreateOrderDto {
date: string; date: string;
@IsNumber() @IsNumber()
@Min(0)
fee: number; fee: number;
@IsNumber() @IsNumber()
@Min(0)
quantity: number; quantity: number;
@IsString() @IsString()
@@ -64,6 +67,7 @@ export class CreateOrderDto {
type: Type; type: Type;
@IsNumber() @IsNumber()
@Min(0)
unitPrice: number; unitPrice: number;
@IsBoolean() @IsBoolean()

View File

@@ -8,12 +8,12 @@ import {
import { Transform, TransformFnParams } from 'class-transformer'; import { Transform, TransformFnParams } from 'class-transformer';
import { import {
IsArray, IsArray,
IsBoolean,
IsEnum, IsEnum,
IsISO8601, IsISO8601,
IsNumber, IsNumber,
IsOptional, IsOptional,
IsString IsString,
Min
} from 'class-validator'; } from 'class-validator';
import { isString } from 'lodash'; import { isString } from 'lodash';
@@ -47,12 +47,14 @@ export class UpdateOrderDto {
date: string; date: string;
@IsNumber() @IsNumber()
@Min(0)
fee: number; fee: number;
@IsString() @IsString()
id: string; id: string;
@IsNumber() @IsNumber()
@Min(0)
quantity: number; quantity: number;
@IsString() @IsString()
@@ -66,5 +68,6 @@ export class UpdateOrderDto {
type: Type; type: Type;
@IsNumber() @IsNumber()
@Min(0)
unitPrice: number; unitPrice: number;
} }

View File

@@ -323,7 +323,8 @@ export class PortfolioController {
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('range') dateRange: DateRange = 'max', @Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string @Query('tags') filterByTags?: string,
@Query('withExcludedAccounts') withExcludedAccounts = false
): Promise<PortfolioPerformanceResponse> { ): Promise<PortfolioPerformanceResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
@@ -335,6 +336,7 @@ export class PortfolioController {
dateRange, dateRange,
filters, filters,
impersonationId, impersonationId,
withExcludedAccounts,
userId: this.request.user.id userId: this.request.user.id
}); });

View File

@@ -372,20 +372,23 @@ export class PortfolioService {
filters, filters,
impersonationId, impersonationId,
userCurrency, userCurrency,
userId userId,
withExcludedAccounts = false
}: { }: {
dateRange?: DateRange; dateRange?: DateRange;
filters?: Filter[]; filters?: Filter[];
impersonationId: string; impersonationId: string;
userCurrency: string; userCurrency: string;
userId: string; userId: string;
withExcludedAccounts?: boolean;
}): Promise<HistoricalDataContainer> { }): Promise<HistoricalDataContainer> {
userId = await this.getUserId(impersonationId, userId); userId = await this.getUserId(impersonationId, userId);
const { portfolioOrders, transactionPoints } = const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({ await this.getTransactionPoints({
filters, filters,
userId userId,
withExcludedAccounts
}); });
const portfolioCalculator = new PortfolioCalculator({ const portfolioCalculator = new PortfolioCalculator({
@@ -1088,6 +1091,7 @@ export class PortfolioService {
return { return {
...position, ...position,
assetClass: symbolProfileMap[position.symbol].assetClass, assetClass: symbolProfileMap[position.symbol].assetClass,
assetSubClass: symbolProfileMap[position.symbol].assetSubClass,
averagePrice: new Big(position.averagePrice).toNumber(), averagePrice: new Big(position.averagePrice).toNumber(),
grossPerformance: position.grossPerformance?.toNumber() ?? null, grossPerformance: position.grossPerformance?.toNumber() ?? null,
grossPerformancePercentage: grossPerformancePercentage:
@@ -1109,12 +1113,14 @@ export class PortfolioService {
dateRange = 'max', dateRange = 'max',
filters, filters,
impersonationId, impersonationId,
userId userId,
withExcludedAccounts = false
}: { }: {
dateRange?: DateRange; dateRange?: DateRange;
filters?: Filter[]; filters?: Filter[];
impersonationId: string; impersonationId: string;
userId: string; userId: string;
withExcludedAccounts?: boolean;
}): Promise<PortfolioPerformanceResponse> { }): Promise<PortfolioPerformanceResponse> {
userId = await this.getUserId(impersonationId, userId); userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
@@ -1123,7 +1129,8 @@ export class PortfolioService {
const { portfolioOrders, transactionPoints } = const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({ await this.getTransactionPoints({
filters, filters,
userId userId,
withExcludedAccounts
}); });
const portfolioCalculator = new PortfolioCalculator({ const portfolioCalculator = new PortfolioCalculator({
@@ -1173,7 +1180,8 @@ export class PortfolioService {
filters, filters,
impersonationId, impersonationId,
userCurrency, userCurrency,
userId userId,
withExcludedAccounts
}); });
const itemOfToday = historicalDataContainer.items.find((item) => { const itemOfToday = historicalDataContainer.items.find((item) => {
@@ -1762,7 +1770,7 @@ export class PortfolioService {
filters, filters,
includeDrafts = false, includeDrafts = false,
userId, userId,
withExcludedAccounts withExcludedAccounts = false
}: { }: {
filters?: Filter[]; filters?: Filter[];
includeDrafts?: boolean; includeDrafts?: boolean;
@@ -1850,7 +1858,7 @@ export class PortfolioService {
portfolioItemsNow, portfolioItemsNow,
userCurrency, userCurrency,
userId, userId,
withExcludedAccounts withExcludedAccounts = false
}: { }: {
filters?: Filter[]; filters?: Filter[];
orders: OrderWithAccount[]; orders: OrderWithAccount[];
@@ -1884,7 +1892,11 @@ export class PortfolioService {
}); });
} else { } else {
const accountIds = uniq( const accountIds = uniq(
orders.map(({ accountId }) => { orders
.filter(({ accountId }) => {
return accountId;
})
.map(({ accountId }) => {
return accountId; return accountId;
}) })
); );

View File

@@ -104,7 +104,7 @@ export class SubscriptionController {
response.redirect( response.redirect(
`${this.configurationService.get( `${this.configurationService.get(
'ROOT_URL' 'ROOT_URL'
)}/${DEFAULT_LANGUAGE_CODE}/account` )}/${DEFAULT_LANGUAGE_CODE}/account/membership`
); );
} }

View File

@@ -164,10 +164,10 @@ export class UserService {
let currentPermissions = getPermissions(user.role); let currentPermissions = getPermissions(user.role);
if (!(user.Settings.settings as UserSettings).isExperimentalFeatures) { if (!(user.Settings.settings as UserSettings).isExperimentalFeatures) {
currentPermissions = without( // currentPermissions = without(
currentPermissions, // currentPermissions,
permissions.accessAssistant // permissions.xyz
); // );
} }
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {

View File

@@ -54,10 +54,22 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-8figures</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-altoo</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-beanvest</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-capitally</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-capmon</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-capmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@@ -82,6 +94,10 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-finary</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-finary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-finwise</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-folishare</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-folishare</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@@ -94,6 +110,10 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-gospatz</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-gospatz</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-intuit-mint</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-justetf</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-justetf</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@@ -134,6 +154,10 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-projectionlab</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-projectionlab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-rocket-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-seeking-alpha</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-seeking-alpha</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@@ -166,6 +190,14 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-utluna</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-utluna</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-vyzer</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-wealthica</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-yeekatee</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-yeekatee</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@@ -274,6 +306,10 @@
<loc>https://ghostfol.io/en/blog/2023/09/hacktoberfest-2023</loc> <loc>https://ghostfol.io/en/blog/2023/09/hacktoberfest-2023</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/blog/2023/11/hacktoberfest-2023-debriefing</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/faq</loc> <loc>https://ghostfol.io/en/faq</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@@ -308,10 +344,22 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-8figures</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-altoo</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-beanvest</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-capitally</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-capmon</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-capmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@@ -336,6 +384,10 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-finary</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-finary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-finwise</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-folishare</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-folishare</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@@ -348,6 +400,10 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-gospatz</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-gospatz</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-intuit-mint</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-justetf</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-justetf</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@@ -388,6 +444,10 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-projectionlab</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-projectionlab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-rocket-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-seeking-alpha</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-seeking-alpha</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@@ -420,6 +480,14 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-utluna</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-utluna</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-vyzer</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-wealthica</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-yeekatee</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-yeekatee</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@@ -590,12 +658,24 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-8figures</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-altoo</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url> <url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-campmon</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-beanvest</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-capitally</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-capmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url> <url>
@@ -618,6 +698,10 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-finary</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-finary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-finwise</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-folishare</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-folishare</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@@ -630,6 +714,10 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-gospatz</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-gospatz</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-intuit-mint</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-justetf</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-justetf</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@@ -670,6 +758,10 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-projectionlab</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-projectionlab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-rocket-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-seeking-alpha</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-seeking-alpha</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@@ -702,6 +794,14 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-utluna</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-utluna</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-vyzer</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-wealthica</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-yeekatee</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-yeekatee</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@@ -718,10 +818,22 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-8figures</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-altoo</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-beanvest</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-capitally</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-capmon</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-capmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@@ -746,6 +858,10 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-finary</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-finary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-finwise</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-folishare</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-folishare</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@@ -758,6 +874,10 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-gospatz</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-gospatz</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-intuit-mint</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-justetf</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-justetf</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@@ -798,6 +918,10 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-projectionlab</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-projectionlab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-rocket-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-seeking-alpha</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-seeking-alpha</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@@ -830,6 +954,14 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-utluna</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-utluna</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-vyzer</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-wealthica</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-yeekatee</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-yeekatee</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>

View File

@@ -2,6 +2,7 @@ import * as fs from 'fs';
import { join } from 'path'; import { join } from 'path';
import { environment } from '@ghostfolio/api/environments/environment'; import { environment } from '@ghostfolio/api/environments/environment';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { import {
DEFAULT_LANGUAGE_CODE, DEFAULT_LANGUAGE_CODE,
DEFAULT_ROOT_URL, DEFAULT_ROOT_URL,
@@ -11,22 +12,12 @@ import { DATE_FORMAT, interpolate } from '@ghostfolio/common/helper';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { NextFunction, Request, Response } from 'express'; import { NextFunction, Request, Response } from 'express';
const descriptions = { const i18nService = new I18nService();
de: 'Mit dem Finanz-Dashboard Ghostfolio können Sie Ihr Vermögen in Form von Aktien, ETFs oder Kryptowährungen verteilt über mehrere Finanzinstitute überwachen.',
en: 'Ghostfolio is a personal finance dashboard to keep track of your assets like stocks, ETFs or cryptocurrencies across multiple platforms.',
es: 'Ghostfolio es un dashboard de finanzas personales para hacer un seguimiento de tus activos como acciones, ETFs o criptodivisas a través de múltiples plataformas.',
fr: 'Ghostfolio est un dashboard de finances personnelles qui permet de suivre vos actifs comme les actions, les ETF ou les crypto-monnaies sur plusieurs plateformes.',
it: 'Ghostfolio è un dashboard di finanza personale per tenere traccia delle vostre attività come azioni, ETF o criptovalute su più piattaforme.',
nl: 'Ghostfolio is een persoonlijk financieel dashboard om uw activa zoals aandelen, ETFs of cryptocurrencies over meerdere platforms bij te houden.',
pt: 'Ghostfolio é um dashboard de finanças pessoais para acompanhar os seus activos como acções, ETFs ou criptomoedas em múltiplas plataformas.',
tr: 'Ghostfolio, hisse senetleri, ETFler veya kripto para birimleri gibi varlıklarınızı birden fazla platformda takip etmenizi sağlayan bir kişisel finans panosudur.'
};
const title = 'Ghostfolio Open Source Wealth Management Software';
const titleShort = 'Ghostfolio';
let indexHtmlMap: { [languageCode: string]: string } = {}; let indexHtmlMap: { [languageCode: string]: string } = {};
const title = 'Ghostfolio';
try { try {
indexHtmlMap = SUPPORTED_LANGUAGE_CODES.reduce( indexHtmlMap = SUPPORTED_LANGUAGE_CODES.reduce(
(map, languageCode) => ({ (map, languageCode) => ({
@@ -43,47 +34,51 @@ try {
const locales = { const locales = {
'/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt': { '/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt': {
featureGraphicPath: 'assets/images/blog/ghostfolio-x-sackgeld.png', featureGraphicPath: 'assets/images/blog/ghostfolio-x-sackgeld.png',
title: `Ghostfolio auf Sackgeld.com vorgestellt - ${titleShort}` title: `Ghostfolio auf Sackgeld.com vorgestellt - ${title}`
}, },
'/en/blog/2022/08/500-stars-on-github': { '/en/blog/2022/08/500-stars-on-github': {
featureGraphicPath: 'assets/images/blog/500-stars-on-github.jpg', featureGraphicPath: 'assets/images/blog/500-stars-on-github.jpg',
title: `500 Stars - ${titleShort}` title: `500 Stars - ${title}`
}, },
'/en/blog/2022/10/hacktoberfest-2022': { '/en/blog/2022/10/hacktoberfest-2022': {
featureGraphicPath: 'assets/images/blog/hacktoberfest-2022.png', featureGraphicPath: 'assets/images/blog/hacktoberfest-2022.png',
title: `Hacktoberfest 2022 - ${titleShort}` title: `Hacktoberfest 2022 - ${title}`
}, },
'/en/blog/2022/12/the-importance-of-tracking-your-personal-finances': { '/en/blog/2022/12/the-importance-of-tracking-your-personal-finances': {
featureGraphicPath: 'assets/images/blog/20221226.jpg', featureGraphicPath: 'assets/images/blog/20221226.jpg',
title: `The importance of tracking your personal finances - ${titleShort}` title: `The importance of tracking your personal finances - ${title}`
}, },
'/en/blog/2023/02/ghostfolio-meets-umbrel': { '/en/blog/2023/02/ghostfolio-meets-umbrel': {
featureGraphicPath: 'assets/images/blog/ghostfolio-x-umbrel.png', featureGraphicPath: 'assets/images/blog/ghostfolio-x-umbrel.png',
title: `Ghostfolio meets Umbrel - ${titleShort}` title: `Ghostfolio meets Umbrel - ${title}`
}, },
'/en/blog/2023/03/ghostfolio-reaches-1000-stars-on-github': { '/en/blog/2023/03/ghostfolio-reaches-1000-stars-on-github': {
featureGraphicPath: 'assets/images/blog/1000-stars-on-github.jpg', featureGraphicPath: 'assets/images/blog/1000-stars-on-github.jpg',
title: `Ghostfolio reaches 1000 Stars on GitHub - ${titleShort}` title: `Ghostfolio reaches 1000 Stars on GitHub - ${title}`
}, },
'/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio': { '/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio': {
featureGraphicPath: 'assets/images/blog/20230520.jpg', featureGraphicPath: 'assets/images/blog/20230520.jpg',
title: `Unlock your Financial Potential with Ghostfolio - ${titleShort}` title: `Unlock your Financial Potential with Ghostfolio - ${title}`
}, },
'/en/blog/2023/07/exploring-the-path-to-fire': { '/en/blog/2023/07/exploring-the-path-to-fire': {
featureGraphicPath: 'assets/images/blog/20230701.jpg', featureGraphicPath: 'assets/images/blog/20230701.jpg',
title: `Exploring the Path to FIRE - ${titleShort}` title: `Exploring the Path to FIRE - ${title}`
}, },
'/en/blog/2023/08/ghostfolio-joins-oss-friends': { '/en/blog/2023/08/ghostfolio-joins-oss-friends': {
featureGraphicPath: 'assets/images/blog/ghostfolio-joins-oss-friends.png', featureGraphicPath: 'assets/images/blog/ghostfolio-joins-oss-friends.png',
title: `Ghostfolio joins OSS Friends - ${titleShort}` title: `Ghostfolio joins OSS Friends - ${title}`
}, },
'/en/blog/2023/09/ghostfolio-2': { '/en/blog/2023/09/ghostfolio-2': {
featureGraphicPath: 'assets/images/blog/ghostfolio-2.jpg', featureGraphicPath: 'assets/images/blog/ghostfolio-2.jpg',
title: `Announcing Ghostfolio 2.0 - ${titleShort}` title: `Announcing Ghostfolio 2.0 - ${title}`
}, },
'/en/blog/2023/09/hacktoberfest-2023': { '/en/blog/2023/09/hacktoberfest-2023': {
featureGraphicPath: 'assets/images/blog/hacktoberfest-2023.png', featureGraphicPath: 'assets/images/blog/hacktoberfest-2023.png',
title: `Hacktoberfest 2023 - ${titleShort}` title: `Hacktoberfest 2023 - ${title}`
},
'/en/blog/2023/11/hacktoberfest-2023-debriefing': {
featureGraphicPath: 'assets/images/blog/hacktoberfest-2023.png',
title: `Hacktoberfest 2023 Debriefing - ${title}`
} }
}; };
@@ -130,10 +125,22 @@ export const HtmlTemplateMiddleware = async (
languageCode, languageCode,
path, path,
rootUrl, rootUrl,
description: descriptions[languageCode], description: i18nService.getTranslation({
languageCode,
id: 'metaDescription'
}),
featureGraphicPath: featureGraphicPath:
locales[path]?.featureGraphicPath ?? 'assets/cover.png', locales[path]?.featureGraphicPath ?? 'assets/cover.png',
title: locales[path]?.title ?? title keywords: i18nService.getTranslation({
languageCode,
id: 'metaKeywords'
}),
title:
locales[path]?.title ??
`${title} ${i18nService.getTranslation({
languageCode,
id: 'slogan'
})}`
}); });
return response.send(indexHtml); return response.send(indexHtml);

View File

@@ -1,4 +1,5 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { AccountBalancesResponse } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { AccountBalance, Prisma } from '@prisma/client'; import { AccountBalance, Prisma } from '@prisma/client';
@@ -13,4 +14,29 @@ export class AccountBalanceService {
data data
}); });
} }
public async getAccountBalances({
accountId,
userId
}: {
accountId: string;
userId: string;
}): Promise<AccountBalancesResponse> {
const balances = await this.prismaService.accountBalance.findMany({
orderBy: {
date: 'asc'
},
select: {
date: true,
id: true,
value: true
},
where: {
accountId,
userId
}
});
return { balances };
}
} }

View File

@@ -8,16 +8,19 @@ export class ApiService {
public buildFiltersFromQueryParams({ public buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
filterByAssetSubClasses,
filterBySearchQuery, filterBySearchQuery,
filterByTags filterByTags
}: { }: {
filterByAccounts?: string; filterByAccounts?: string;
filterByAssetClasses?: string; filterByAssetClasses?: string;
filterByAssetSubClasses?: string;
filterBySearchQuery?: string; filterBySearchQuery?: string;
filterByTags?: string; filterByTags?: string;
}): Filter[] { }): Filter[] {
const accountIds = filterByAccounts?.split(',') ?? []; const accountIds = filterByAccounts?.split(',') ?? [];
const assetClasses = filterByAssetClasses?.split(',') ?? []; const assetClasses = filterByAssetClasses?.split(',') ?? [];
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
const searchQuery = filterBySearchQuery?.toLowerCase(); const searchQuery = filterBySearchQuery?.toLowerCase();
const tagIds = filterByTags?.split(',') ?? []; const tagIds = filterByTags?.split(',') ?? [];
@@ -34,6 +37,12 @@ export class ApiService {
type: 'ASSET_CLASS' type: 'ASSET_CLASS'
}; };
}), }),
...assetSubClasses.map((assetClass) => {
return <Filter>{
id: assetClass,
type: 'ASSET_SUB_CLASS'
};
}),
{ {
id: searchQuery, id: searchQuery,
type: 'SEARCH_QUERY' type: 'SEARCH_QUERY'

View File

@@ -38,6 +38,7 @@ export class ConfigurationService {
JWT_SECRET_KEY: str({}), JWT_SECRET_KEY: str({}),
MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }), MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
MAX_ITEM_IN_CACHE: num({ default: 9999 }), MAX_ITEM_IN_CACHE: num({ default: 9999 }),
OPEN_FIGI_API_KEY: str({ default: '' }),
PORT: port({ default: 3333 }), PORT: port({ default: 3333 }),
RAPID_API_API_KEY: str({ default: '' }), RAPID_API_API_KEY: str({ default: '' }),
REDIS_HOST: str({ default: 'localhost' }), REDIS_HOST: str({ default: 'localhost' }),

View File

@@ -164,6 +164,9 @@ export class DataGatheringService {
countries, countries,
currency, currency,
dataSource, dataSource,
figi,
figiComposite,
figiShareClass,
isin, isin,
name, name,
sectors, sectors,
@@ -178,6 +181,9 @@ export class DataGatheringService {
countries, countries,
currency, currency,
dataSource, dataSource,
figi,
figiComposite,
figiShareClass,
isin, isin,
name, name,
sectors, sectors,
@@ -189,6 +195,9 @@ export class DataGatheringService {
assetSubClass, assetSubClass,
countries, countries,
currency, currency,
figi,
figiComposite,
figiShareClass,
isin, isin,
name, name,
sectors, sectors,

View File

@@ -105,9 +105,11 @@ export class AlphaVantageService implements DataProviderInterface {
return DataSource.ALPHA_VANTAGE; return DataSource.ALPHA_VANTAGE;
} }
public async getQuotes( public async getQuotes({
aSymbols: string[] symbols
): Promise<{ [symbol: string]: IDataProviderResponse }> { }: {
symbols: string[];
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
return {}; return {};
} }

View File

@@ -134,13 +134,15 @@ export class CoinGeckoService implements DataProviderInterface {
return DataSource.COINGECKO; return DataSource.COINGECKO;
} }
public async getQuotes( public async getQuotes({
aSymbols: string[] symbols
): Promise<{ [symbol: string]: IDataProviderResponse }> { }: {
const results: { [symbol: string]: IDataProviderResponse } = {}; symbols: string[];
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
const response: { [symbol: string]: IDataProviderResponse } = {};
if (aSymbols.length <= 0) { if (symbols.length <= 0) {
return {}; return response;
} }
try { try {
@@ -150,8 +152,8 @@ export class CoinGeckoService implements DataProviderInterface {
abortController.abort(); abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT); }, DEFAULT_REQUEST_TIMEOUT);
const response = await got( const quotes = await got(
`${this.URL}/simple/price?ids=${aSymbols.join( `${this.URL}/simple/price?ids=${symbols.join(
',' ','
)}&vs_currencies=${DEFAULT_CURRENCY.toLowerCase()}`, )}&vs_currencies=${DEFAULT_CURRENCY.toLowerCase()}`,
{ {
@@ -160,22 +162,20 @@ export class CoinGeckoService implements DataProviderInterface {
} }
).json<any>(); ).json<any>();
for (const symbol in response) { for (const symbol in quotes) {
if (Object.prototype.hasOwnProperty.call(response, symbol)) { response[symbol] = {
results[symbol] = {
currency: DEFAULT_CURRENCY, currency: DEFAULT_CURRENCY,
dataProviderInfo: this.getDataProviderInfo(), dataProviderInfo: this.getDataProviderInfo(),
dataSource: DataSource.COINGECKO, dataSource: DataSource.COINGECKO,
marketPrice: response[symbol][DEFAULT_CURRENCY.toLowerCase()], marketPrice: quotes[symbol][DEFAULT_CURRENCY.toLowerCase()],
marketState: 'open' marketState: 'open'
}; };
} }
}
} catch (error) { } catch (error) {
Logger.error(error, 'CoinGeckoService'); Logger.error(error, 'CoinGeckoService');
} }
return results; return response;
} }
public getTestSymbol() { public getTestSymbol() {

View File

@@ -1,5 +1,6 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module'; import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
import { OpenFigiDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/openfigi/openfigi.service';
import { TrackinsightDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/trackinsight/trackinsight.service'; import { TrackinsightDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/trackinsight/trackinsight.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 { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@@ -9,6 +10,7 @@ import { DataEnhancerService } from './data-enhancer.service';
@Module({ @Module({
exports: [ exports: [
DataEnhancerService, DataEnhancerService,
OpenFigiDataEnhancerService,
TrackinsightDataEnhancerService, TrackinsightDataEnhancerService,
YahooFinanceDataEnhancerService, YahooFinanceDataEnhancerService,
'DataEnhancers' 'DataEnhancers'
@@ -16,15 +18,21 @@ import { DataEnhancerService } from './data-enhancer.service';
imports: [ConfigurationModule, CryptocurrencyModule], imports: [ConfigurationModule, CryptocurrencyModule],
providers: [ providers: [
DataEnhancerService, DataEnhancerService,
OpenFigiDataEnhancerService,
TrackinsightDataEnhancerService, TrackinsightDataEnhancerService,
YahooFinanceDataEnhancerService, YahooFinanceDataEnhancerService,
{ {
inject: [ inject: [
OpenFigiDataEnhancerService,
TrackinsightDataEnhancerService, TrackinsightDataEnhancerService,
YahooFinanceDataEnhancerService YahooFinanceDataEnhancerService
], ],
provide: 'DataEnhancers', provide: 'DataEnhancers',
useFactory: (trackinsight, yahooFinance) => [trackinsight, yahooFinance] useFactory: (openfigi, trackinsight, yahooFinance) => [
openfigi,
trackinsight,
yahooFinance
]
} }
] ]
}) })

View File

@@ -0,0 +1,85 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
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 { Injectable } from '@nestjs/common';
import { SymbolProfile } from '@prisma/client';
import got, { Headers } from 'got';
@Injectable()
export class OpenFigiDataEnhancerService implements DataEnhancerInterface {
private static baseUrl = 'https://api.openfigi.com';
public constructor(
private readonly configurationService: ConfigurationService
) {}
public async enhance({
response,
symbol
}: {
response: Partial<SymbolProfile>;
symbol: string;
}): Promise<Partial<SymbolProfile>> {
if (
!(
response.assetClass === 'EQUITY' &&
(response.assetSubClass === 'ETF' || response.assetSubClass === 'STOCK')
)
) {
return response;
}
const headers: Headers = {};
const { exchange, ticker } = parseSymbol({
symbol,
dataSource: response.dataSource
});
if (this.configurationService.get('OPEN_FIGI_API_KEY')) {
headers['X-OPENFIGI-APIKEY'] =
this.configurationService.get('OPEN_FIGI_API_KEY');
}
let abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const mappings = await got
.post(`${OpenFigiDataEnhancerService.baseUrl}/v3/mapping`, {
headers,
json: [{ exchCode: exchange, idType: 'TICKER', idValue: ticker }],
// @ts-ignore
signal: abortController.signal
})
.json<any[]>();
if (mappings?.length === 1 && mappings[0].data?.length === 1) {
const { compositeFIGI, figi, shareClassFIGI } = mappings[0].data[0];
if (figi) {
response.figi = figi;
}
if (compositeFIGI) {
response.figiComposite = compositeFIGI;
}
if (shareClassFIGI) {
response.figiShareClass = shareClassFIGI;
}
}
return response;
}
public getName() {
return 'OPENFIGI';
}
public getTestSymbol() {
return undefined;
}
}

View File

@@ -10,6 +10,7 @@ import {
Prisma, Prisma,
SymbolProfile SymbolProfile
} from '@prisma/client'; } from '@prisma/client';
import { isISIN } from 'class-validator';
import { countries } from 'countries-list'; import { countries } from 'countries-list';
import yahooFinance from 'yahoo-finance2'; import yahooFinance from 'yahoo-finance2';
import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-iface'; import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-iface';
@@ -156,7 +157,20 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
const response: Partial<SymbolProfile> = {}; const response: Partial<SymbolProfile> = {};
try { try {
const symbol = this.convertToYahooFinanceSymbol(aSymbol); let symbol = aSymbol;
if (isISIN(symbol)) {
try {
const { quotes } = await yahooFinance.search(symbol);
if (quotes.length === 1) {
symbol = quotes[0].symbol;
}
} catch {}
} else {
symbol = this.convertToYahooFinanceSymbol(symbol);
}
const assetProfile = await yahooFinance.quoteSummary(symbol, { const assetProfile = await yahooFinance.quoteSummary(symbol, {
modules: ['price', 'summaryProfile', 'topHoldings'] modules: ['price', 'summaryProfile', 'topHoldings']
}); });
@@ -176,7 +190,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
shortName: assetProfile.price.shortName, shortName: assetProfile.price.shortName,
symbol: assetProfile.price.symbol symbol: assetProfile.price.symbol
}); });
response.symbol = aSymbol; response.symbol = assetProfile.price.symbol;
if (assetSubClass === AssetSubClass.MUTUALFUND) { if (assetSubClass === AssetSubClass.MUTUALFUND) {
response.sectors = []; response.sectors = [];

View File

@@ -311,7 +311,9 @@ export class DataProviderService {
i + maximumNumberOfSymbolsPerRequest i + maximumNumberOfSymbolsPerRequest
); );
const promise = Promise.resolve(dataProvider.getQuotes(symbolsChunk)); const promise = Promise.resolve(
dataProvider.getQuotes({ symbols: symbolsChunk })
);
promises.push( promises.push(
promise.then(async (result) => { promise.then(async (result) => {

View File

@@ -131,17 +131,21 @@ export class EodHistoricalDataService implements DataProviderInterface {
return DataSource.EOD_HISTORICAL_DATA; return DataSource.EOD_HISTORICAL_DATA;
} }
public async getQuotes( public async getQuotes({
aSymbols: string[] symbols
): Promise<{ [symbol: string]: IDataProviderResponse }> { }: {
const symbols = aSymbols.map((symbol) => { symbols: string[];
return this.convertToEodSymbol(symbol); }): Promise<{ [symbol: string]: IDataProviderResponse }> {
}); let response: { [symbol: string]: IDataProviderResponse } = {};
if (symbols.length <= 0) { if (symbols.length <= 0) {
return {}; return response;
} }
const eodHistoricalDataSymbols = symbols.map((symbol) => {
return this.convertToEodSymbol(symbol);
});
try { try {
const abortController = new AbortController(); const abortController = new AbortController();
@@ -150,9 +154,9 @@ export class EodHistoricalDataService implements DataProviderInterface {
}, DEFAULT_REQUEST_TIMEOUT); }, DEFAULT_REQUEST_TIMEOUT);
const realTimeResponse = await got( const realTimeResponse = await got(
`${this.URL}/real-time/${symbols[0]}?api_token=${ `${this.URL}/real-time/${eodHistoricalDataSymbols[0]}?api_token=${
this.apiKey this.apiKey
}&fmt=json&s=${symbols.join(',')}`, }&fmt=json&s=${eodHistoricalDataSymbols.join(',')}`,
{ {
// @ts-ignore // @ts-ignore
signal: abortController.signal signal: abortController.signal
@@ -160,10 +164,12 @@ export class EodHistoricalDataService implements DataProviderInterface {
).json<any>(); ).json<any>();
const quotes = const quotes =
symbols.length === 1 ? [realTimeResponse] : realTimeResponse; eodHistoricalDataSymbols.length === 1
? [realTimeResponse]
: realTimeResponse;
const searchResponse = await Promise.all( const searchResponse = await Promise.all(
symbols eodHistoricalDataSymbols
.filter((symbol) => { .filter((symbol) => {
return !symbol.endsWith('.FOREX'); return !symbol.endsWith('.FOREX');
}) })
@@ -176,7 +182,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
return items[0]; return items[0];
}); });
const response = quotes.reduce( response = quotes.reduce(
( (
result: { [symbol: string]: IDataProviderResponse }, result: { [symbol: string]: IDataProviderResponse },
{ close, code, timestamp } { close, code, timestamp }
@@ -283,7 +289,6 @@ export class EodHistoricalDataService implements DataProviderInterface {
if (symbol.endsWith('.FOREX')) { if (symbol.endsWith('.FOREX')) {
symbol = symbol.replace('GBX', 'GBp'); symbol = symbol.replace('GBX', 'GBp');
symbol = symbol.replace('.FOREX', ''); symbol = symbol.replace('.FOREX', '');
symbol = `${DEFAULT_CURRENCY}${symbol}`;
} }
return symbol; return symbol;
@@ -292,7 +297,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
/** /**
* Converts a symbol to a EOD symbol * Converts a symbol to a EOD symbol
* *
* Currency: USDCHF -> CHF.FOREX * Currency: USDCHF -> USDCHF.FOREX
*/ */
private convertToEodSymbol(aSymbol: string) { private convertToEodSymbol(aSymbol: string) {
if ( if (
@@ -304,9 +309,10 @@ export class EodHistoricalDataService implements DataProviderInterface {
aSymbol.substring(0, aSymbol.length - DEFAULT_CURRENCY.length) aSymbol.substring(0, aSymbol.length - DEFAULT_CURRENCY.length)
) )
) { ) {
return `${aSymbol let symbol = aSymbol;
.replace('GBp', 'GBX') symbol = symbol.replace('GBp', 'GBX');
.replace(DEFAULT_CURRENCY, '')}.FOREX`;
return `${symbol}.FOREX`;
} }
} }

View File

@@ -113,13 +113,15 @@ export class FinancialModelingPrepService implements DataProviderInterface {
return DataSource.FINANCIAL_MODELING_PREP; return DataSource.FINANCIAL_MODELING_PREP;
} }
public async getQuotes( public async getQuotes({
aSymbols: string[] symbols
): Promise<{ [symbol: string]: IDataProviderResponse }> { }: {
const results: { [symbol: string]: IDataProviderResponse } = {}; symbols: string[];
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
const response: { [symbol: string]: IDataProviderResponse } = {};
if (aSymbols.length <= 0) { if (symbols.length <= 0) {
return {}; return response;
} }
try { try {
@@ -130,7 +132,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
}, DEFAULT_REQUEST_TIMEOUT); }, DEFAULT_REQUEST_TIMEOUT);
const response = await got( const response = await got(
`${this.URL}/quote/${aSymbols.join(',')}?apikey=${this.apiKey}`, `${this.URL}/quote/${symbols.join(',')}?apikey=${this.apiKey}`,
{ {
// @ts-ignore // @ts-ignore
signal: abortController.signal signal: abortController.signal
@@ -138,7 +140,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
).json<any>(); ).json<any>();
for (const { price, symbol } of response) { for (const { price, symbol } of response) {
results[symbol] = { response[symbol] = {
currency: DEFAULT_CURRENCY, currency: DEFAULT_CURRENCY,
dataProviderInfo: this.getDataProviderInfo(), dataProviderInfo: this.getDataProviderInfo(),
dataSource: DataSource.FINANCIAL_MODELING_PREP, dataSource: DataSource.FINANCIAL_MODELING_PREP,
@@ -150,7 +152,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
Logger.error(error, 'FinancialModelingPrepService'); Logger.error(error, 'FinancialModelingPrepService');
} }
return results; return response;
} }
public getTestSymbol() { public getTestSymbol() {

View File

@@ -99,18 +99,20 @@ export class GoogleSheetsService implements DataProviderInterface {
return DataSource.GOOGLE_SHEETS; return DataSource.GOOGLE_SHEETS;
} }
public async getQuotes( public async getQuotes({
aSymbols: string[] symbols
): Promise<{ [symbol: string]: IDataProviderResponse }> { }: {
if (aSymbols.length <= 0) { symbols: string[];
return {}; }): Promise<{ [symbol: string]: IDataProviderResponse }> {
const response: { [symbol: string]: IDataProviderResponse } = {};
if (symbols.length <= 0) {
return response;
} }
try { try {
const response: { [symbol: string]: IDataProviderResponse } = {};
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles( const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
aSymbols.map((symbol) => { symbols.map((symbol) => {
return { return {
symbol, symbol,
dataSource: this.getName() dataSource: this.getName()
@@ -129,7 +131,7 @@ export class GoogleSheetsService implements DataProviderInterface {
const marketPrice = parseFloat(row['marketPrice']); const marketPrice = parseFloat(row['marketPrice']);
const symbol = row['symbol']; const symbol = row['symbol'];
if (aSymbols.includes(symbol)) { if (symbols.includes(symbol)) {
response[symbol] = { response[symbol] = {
marketPrice, marketPrice,
currency: symbolProfiles.find((symbolProfile) => { currency: symbolProfiles.find((symbolProfile) => {

View File

@@ -36,9 +36,11 @@ export interface DataProviderInterface {
getName(): DataSource; getName(): DataSource;
getQuotes( getQuotes({
aSymbols: string[] symbols
): Promise<{ [symbol: string]: IDataProviderResponse }>; }: {
symbols: string[];
}): Promise<{ [symbol: string]: IDataProviderResponse }>;
getTestSymbol(): string; getTestSymbol(): string;

View File

@@ -133,18 +133,20 @@ export class ManualService implements DataProviderInterface {
return DataSource.MANUAL; return DataSource.MANUAL;
} }
public async getQuotes( public async getQuotes({
aSymbols: string[] symbols
): Promise<{ [symbol: string]: IDataProviderResponse }> { }: {
symbols: string[];
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
const response: { [symbol: string]: IDataProviderResponse } = {}; const response: { [symbol: string]: IDataProviderResponse } = {};
if (aSymbols.length <= 0) { if (symbols.length <= 0) {
return response; return response;
} }
try { try {
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles( const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
aSymbols.map((symbol) => { symbols.map((symbol) => {
return { symbol, dataSource: this.getName() }; return { symbol, dataSource: this.getName() };
}) })
); );
@@ -154,10 +156,10 @@ export class ManualService implements DataProviderInterface {
orderBy: { orderBy: {
date: 'desc' date: 'desc'
}, },
take: aSymbols.length, take: symbols.length,
where: { where: {
symbol: { symbol: {
in: aSymbols in: symbols
} }
} }
}); });

View File

@@ -87,15 +87,17 @@ export class RapidApiService implements DataProviderInterface {
return DataSource.RAPID_API; return DataSource.RAPID_API;
} }
public async getQuotes( public async getQuotes({
aSymbols: string[] symbols
): Promise<{ [symbol: string]: IDataProviderResponse }> { }: {
if (aSymbols.length <= 0) { symbols: string[];
}): Promise<{ [symbol: string]: IDataProviderResponse }> {
if (symbols.length <= 0) {
return {}; return {};
} }
try { try {
const symbol = aSymbols[0]; const symbol = symbols[0];
if (symbol === ghostfolioFearAndGreedIndexSymbol) { if (symbol === ghostfolioFearAndGreedIndexSymbol) {
const fgi = await this.getFearAndGreedIndex(); const fgi = await this.getFearAndGreedIndex();

View File

@@ -30,7 +30,7 @@ export class YahooFinanceService implements DataProviderInterface {
public async getAssetProfile( public async getAssetProfile(
aSymbol: string aSymbol: string
): Promise<Partial<SymbolProfile>> { ): Promise<Partial<SymbolProfile>> {
const { assetClass, assetSubClass, currency, name } = const { assetClass, assetSubClass, currency, name, symbol } =
await this.yahooFinanceDataEnhancerService.getAssetProfile(aSymbol); await this.yahooFinanceDataEnhancerService.getAssetProfile(aSymbol);
return { return {
@@ -38,8 +38,8 @@ export class YahooFinanceService implements DataProviderInterface {
assetSubClass, assetSubClass,
currency, currency,
name, name,
dataSource: this.getName(), symbol,
symbol: aSymbol dataSource: this.getName()
}; };
} }
@@ -156,20 +156,22 @@ export class YahooFinanceService implements DataProviderInterface {
return DataSource.YAHOO; return DataSource.YAHOO;
} }
public async getQuotes( public async getQuotes({
aSymbols: string[] symbols
): Promise<{ [symbol: string]: IDataProviderResponse }> { }: {
if (aSymbols.length <= 0) { symbols: string[];
return {}; }): Promise<{ [symbol: string]: IDataProviderResponse }> {
const response: { [symbol: string]: IDataProviderResponse } = {};
if (symbols.length <= 0) {
return response;
} }
const yahooFinanceSymbols = aSymbols.map((symbol) => const yahooFinanceSymbols = symbols.map((symbol) =>
this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(symbol) this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(symbol)
); );
try { try {
const response: { [symbol: string]: IDataProviderResponse } = {};
let quotes: Pick< let quotes: Pick<
Quote, Quote,
'currency' | 'marketState' | 'regularMarketPrice' | 'symbol' 'currency' | 'marketState' | 'regularMarketPrice' | 'symbol'

View File

@@ -0,0 +1,67 @@
import { readFileSync, readdirSync } from 'fs';
import { join } from 'path';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { Logger } from '@nestjs/common';
import * as cheerio from 'cheerio';
export class I18nService {
private localesPath = join(__dirname, 'assets', 'locales');
private translations: { [locale: string]: cheerio.CheerioAPI } = {};
public constructor() {
this.loadFiles();
}
public getTranslation({
id,
languageCode
}: {
id: string;
languageCode: string;
}): string {
const $ = this.translations[languageCode];
if (!$) {
Logger.warn(`Translation not found for locale '${languageCode}'`);
}
const translatedText = $(
`trans-unit[id="${id}"] > ${
languageCode === DEFAULT_LANGUAGE_CODE ? 'source' : 'target'
}`
).text();
if (!translatedText) {
Logger.warn(
`Translation not found for id '${id}' in locale '${languageCode}'`
);
}
return translatedText.trim();
}
private loadFiles() {
try {
const files = readdirSync(this.localesPath, 'utf-8');
for (const file of files) {
const xmlData = readFileSync(join(this.localesPath, file), 'utf8');
this.translations[this.parseLanguageCode(file)] =
this.parseXml(xmlData);
}
} catch (error) {
Logger.error(error, 'I18nService');
}
}
private parseLanguageCode(aFileName: string) {
const match = aFileName.match(/\.([a-zA-Z]+)\.xlf$/);
return match ? match[1] : DEFAULT_LANGUAGE_CODE;
}
private parseXml(xmlData: string): cheerio.CheerioAPI {
return cheerio.load(xmlData, { xmlMode: true });
}
}

View File

@@ -26,6 +26,7 @@ export interface Environment extends CleanedEnvAccessors {
JWT_SECRET_KEY: string; JWT_SECRET_KEY: string;
MAX_ACTIVITIES_TO_IMPORT: number; MAX_ACTIVITIES_TO_IMPORT: number;
MAX_ITEM_IN_CACHE: number; MAX_ITEM_IN_CACHE: number;
OPEN_FIGI_API_KEY: string;
PORT: number; PORT: number;
RAPID_API_API_KEY: string; RAPID_API_API_KEY: string;
REDIS_HOST: string; REDIS_HOST: string;

View File

@@ -39,18 +39,22 @@ export class MarketDataService {
}); });
} }
public async getMax({ dataSource, symbol }: UniqueAsset): Promise<number> { public async getMax({ dataSource, symbol }: UniqueAsset) {
const aggregations = await this.prismaService.marketData.aggregate({ return this.prismaService.marketData.findFirst({
_max: { select: {
date: true,
marketPrice: true marketPrice: true
}, },
orderBy: [
{
marketPrice: 'desc'
}
],
where: { where: {
dataSource, dataSource,
symbol symbol
} }
}); });
return aggregations._max.marketPrice;
} }
public async getRange({ public async getRange({

View File

@@ -52,20 +52,12 @@ export class SymbolProfileService {
SymbolProfileOverrides: true SymbolProfileOverrides: true
}, },
where: { where: {
AND: [ OR: aUniqueAssets.map(({ dataSource, symbol }) => {
{ return {
dataSource: { dataSource,
in: aUniqueAssets.map(({ dataSource }) => { symbol
return dataSource; };
}) })
},
symbol: {
in: aUniqueAssets.map(({ symbol }) => {
return symbol;
})
}
}
]
} }
}) })
.then((symbolProfiles) => this.getSymbols(symbolProfiles)); .then((symbolProfiles) => this.getSymbols(symbolProfiles));
@@ -94,14 +86,24 @@ export class SymbolProfileService {
} }
public updateSymbolProfile({ public updateSymbolProfile({
assetClass,
assetSubClass,
comment, comment,
dataSource, dataSource,
name,
scraperConfiguration, scraperConfiguration,
symbol, symbol,
symbolMapping symbolMapping
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) { }: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
return this.prismaService.symbolProfile.update({ return this.prismaService.symbolProfile.update({
data: { comment, scraperConfiguration, symbolMapping }, data: {
assetClass,
assetSubClass,
comment,
name,
scraperConfiguration,
symbolMapping
},
where: { dataSource_symbol: { dataSource, symbol } } where: { dataSource_symbol: { dataSource, symbol } }
}); });
} }

View File

@@ -124,6 +124,9 @@
{ {
"command": "shx cp apps/client/src/assets/site.webmanifest dist/apps/client" "command": "shx cp apps/client/src/assets/site.webmanifest dist/apps/client"
}, },
{
"command": "shx cp -r apps/client/src/locales dist/apps/api/assets"
},
{ {
"command": "shx cp node_modules/ionicons/dist/index.js dist/apps/client" "command": "shx cp node_modules/ionicons/dist/index.js dist/apps/client"
}, },

View File

@@ -73,6 +73,11 @@ const routes: Routes = [
loadChildren: () => loadChildren: () =>
import('./pages/home/home-page.module').then((m) => m.HomePageModule) import('./pages/home/home-page.module').then((m) => m.HomePageModule)
}, },
{
path: 'i18n',
loadChildren: () =>
import('./pages/i18n/i18n-page.module').then((m) => m.I18nPageModule)
},
{ {
path: paths.markets, path: paths.markets,
loadChildren: () => loadChildren: () =>

View File

@@ -165,7 +165,6 @@
<div class="row text-center"> <div class="row text-center">
<div class="col"> <div class="col">
© 2021 - {{ currentYear }} <a href="https://ghostfol.io">Ghostfolio</a> © 2021 - {{ currentYear }} <a href="https://ghostfol.io">Ghostfolio</a>
{{ version }}
</div> </div>
</div> </div>

View File

@@ -17,7 +17,6 @@ import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { filter, takeUntil } from 'rxjs/operators'; import { filter, takeUntil } from 'rxjs/operators';
import { environment } from '../environments/environment';
import { DataService } from './services/data.service'; import { DataService } from './services/data.service';
import { TokenStorageService } from './services/token-storage.service'; import { TokenStorageService } from './services/token-storage.service';
import { UserService } from './services/user/user.service'; import { UserService } from './services/user/user.service';
@@ -60,7 +59,6 @@ export class AppComponent implements OnDestroy, OnInit {
public routerLinkResources = ['/' + $localize`resources`]; public routerLinkResources = ['/' + $localize`resources`];
public showFooter = false; public showFooter = false;
public user: User; public user: User;
public version = environment.version;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();

View File

@@ -1,15 +1,3 @@
<div *ngIf="hasPermissionToCreateAccess" class="d-flex justify-content-end">
<a
color="primary"
i18n
mat-flat-button
[queryParams]="{ createDialog: true }"
[routerLink]="[]"
>
Add Access
</a>
</div>
<table class="gf-table w-100" mat-table [dataSource]="dataSource"> <table class="gf-table w-100" mat-table [dataSource]="dataSource">
<ng-container matColumnDef="alias"> <ng-container matColumnDef="alias">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Alias</th> <th *matHeaderCellDef class="px-1" i18n mat-header-cell>Alias</th>

View File

@@ -19,7 +19,6 @@ import { Access } from '@ghostfolio/common/interfaces';
}) })
export class AccessTableComponent implements OnChanges, OnInit { export class AccessTableComponent implements OnChanges, OnInit {
@Input() accesses: Access[]; @Input() accesses: Access[];
@Input() hasPermissionToCreateAccess = false;
@Input() showActions: boolean; @Input() showActions: boolean;
@Output() accessDeleted = new EventEmitter<string>(); @Output() accessDeleted = new EventEmitter<string>();

View File

@@ -3,5 +3,9 @@
.mat-mdc-dialog-content { .mat-mdc-dialog-content {
max-height: unset; max-height: unset;
.chart-container {
aspect-ratio: 16 / 9;
}
} }
} }

View File

@@ -8,11 +8,11 @@ import {
} 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 { 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 { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { downloadAsFile } from '@ghostfolio/common/helper'; import { downloadAsFile } from '@ghostfolio/common/helper';
import { User } from '@ghostfolio/common/interfaces'; import { HistoricalDataItem, User } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n';
import Big from 'big.js'; import Big from 'big.js';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
@@ -32,6 +32,9 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
public balance: number; public balance: number;
public currency: string; public currency: string;
public equity: number; public equity: number;
public hasImpersonationId: boolean;
public historicalDataItems: HistoricalDataItem[];
public isLoadingChart: boolean;
public name: string; public name: string;
public orders: OrderWithAccount[]; public orders: OrderWithAccount[];
public platformName: string; public platformName: string;
@@ -46,6 +49,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
@Inject(MAT_DIALOG_DATA) public data: AccountDetailDialogParams, @Inject(MAT_DIALOG_DATA) public data: AccountDetailDialogParams,
private dataService: DataService, private dataService: DataService,
public dialogRef: MatDialogRef<AccountDetailDialog>, public dialogRef: MatDialogRef<AccountDetailDialog>,
private impersonationStorageService: ImpersonationStorageService,
private userService: UserService private userService: UserService
) { ) {
this.userService.stateChanged this.userService.stateChanged
@@ -59,7 +63,9 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
}); });
} }
public ngOnInit(): void { public ngOnInit() {
this.isLoadingChart = true;
this.dataService this.dataService
.fetchAccount(this.data.accountId) .fetchAccount(this.data.accountId)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
@@ -101,9 +107,46 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
this.dataService
.fetchPortfolioPerformance({
filters: [
{
id: this.data.accountId,
type: 'ACCOUNT'
}
],
range: 'max',
withExcludedAccounts: true
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ chart }) => {
this.historicalDataItems = chart.map(
({ date, value, valueInPercentage }) => {
return {
date,
value:
this.hasImpersonationId || this.user.settings.isRestrictedView
? valueInPercentage
: value
};
}
);
this.isLoadingChart = false;
this.changeDetectorRef.markForCheck();
});
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
});
} }
public onClose(): void { public onClose() {
this.dialogRef.close(); this.dialogRef.close();
} }

View File

@@ -20,6 +20,17 @@
</div> </div>
</div> </div>
<div class="chart-container mb-3">
<gf-investment-chart
class="h-100"
[currency]="user?.settings?.baseCurrency"
[historicalDataItems]="historicalDataItems"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[isLoading]="isLoadingChart"
[locale]="user?.settings?.locale"
></gf-investment-chart>
</div>
<div class="row"> <div class="row">
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value <gf-value

View File

@@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
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 { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.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';
@@ -17,6 +18,7 @@ import { AccountDetailDialog } from './account-detail-dialog.component';
GfActivitiesTableModule, GfActivitiesTableModule,
GfDialogFooterModule, GfDialogFooterModule,
GfDialogHeaderModule, GfDialogHeaderModule,
GfInvestmentChartModule,
GfValueModule, GfValueModule,
MatButtonModule, MatButtonModule,
MatDialogModule, MatDialogModule,

View File

@@ -1,7 +1,8 @@
<div *ngIf="false" class="d-flex justify-content-end"> <div *ngIf="showActions" class="d-flex justify-content-end">
<button <button
class="align-items-center d-flex" class="align-items-center d-flex"
mat-stroked-button mat-stroked-button
[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"></ion-icon>
@@ -253,16 +254,20 @@
</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">
<ion-icon class="mr-2" name="create-outline"></ion-icon> <ion-icon class="mr-2" name="create-outline"></ion-icon>
<span i18n>Edit</span> <span i18n>Edit</span>
</span>
</button> </button>
<button <button
mat-menu-item mat-menu-item
[disabled]="element.isDefault || element.transactionCount > 0" [disabled]="element.isDefault || element.transactionCount > 0"
(click)="onDeleteAccount(element.id)" (click)="onDeleteAccount(element.id)"
> >
<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"></ion-icon>
<span i18n>Delete</span> <span i18n>Delete</span>
</span>
</button> </button>
</mat-menu> </mat-menu>
</td> </td>

View File

@@ -6,6 +6,7 @@ import {
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms'; import { FormBuilder, FormGroup } from '@angular/forms';
import { MatTableDataSource } from '@angular/material/table';
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { QUEUE_JOB_STATUS_LIST } from '@ghostfolio/common/config'; import { QUEUE_JOB_STATUS_LIST } from '@ghostfolio/common/config';
@@ -24,7 +25,19 @@ import { takeUntil } from 'rxjs/operators';
export class AdminJobsComponent implements OnDestroy, OnInit { export class AdminJobsComponent implements OnDestroy, OnInit {
public defaultDateTimeFormat: string; public defaultDateTimeFormat: string;
public filterForm: FormGroup; public filterForm: FormGroup;
public jobs: AdminJobs['jobs'] = []; public dataSource: MatTableDataSource<AdminJobs['jobs'][0]> =
new MatTableDataSource();
public displayedColumns = [
'index',
'type',
'symbol',
'dataSource',
'attempts',
'created',
'finished',
'status',
'actions'
];
public statusFilterOptions = QUEUE_JOB_STATUS_LIST; public statusFilterOptions = QUEUE_JOB_STATUS_LIST;
public user: User; public user: User;
@@ -102,7 +115,7 @@ export class AdminJobsComponent implements OnDestroy, OnInit {
.fetchJobs({ status: aStatus }) .fetchJobs({ status: aStatus })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ jobs }) => { .subscribe(({ jobs }) => {
this.jobs = jobs; this.dataSource = new MatTableDataSource(jobs);
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });

View File

@@ -13,18 +13,115 @@
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</form> </form>
<table class="gf-table w-100"> <table class="gf-table w-100" mat-table [dataSource]="dataSource">
<thead> <ng-container matColumnDef="index">
<tr class="mat-header-row"> <th *matHeaderCellDef class="px-1 py-2 text-right" mat-header-cell>
<th class="mat-header-cell px-1 py-2 text-right">#</th> #
<th class="mat-header-cell px-1 py-2" i18n>Type</th> </th>
<th class="mat-header-cell px-1 py-2" i18n>Symbol</th> <td *matCellDef="let element" class="px-1 py-2 text-right" mat-cell>
<th class="mat-header-cell px-1 py-2" i18n>Data Source</th> {{ element.id }}
<th class="mat-header-cell px-1 py-2 text-right" i18n>Attempts</th> </td>
<th class="mat-header-cell px-1 py-2" i18n>Created</th> </ng-container>
<th class="mat-header-cell px-1 py-2" i18n>Finished</th>
<th class="mat-header-cell px-1 py-2" i18n>Status</th> <ng-container matColumnDef="type">
<th class="mat-header-cell px-1 py-2"> <th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
<ng-container i18n>Type</ng-container>
</th>
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
<ng-container *ngIf="element.name === 'GATHER_ASSET_PROFILE'" i18n>
Asset Profile
</ng-container>
<ng-container
*ngIf="element.name === 'GATHER_HISTORICAL_MARKET_DATA'"
i18n
>
Historical Market Data
</ng-container>
</td>
</ng-container>
<ng-container matColumnDef="symbol">
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
<ng-container i18n>Symbol</ng-container>
</th>
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
{{ element.data?.symbol }}
</td>
</ng-container>
<ng-container matColumnDef="dataSource">
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
<ng-container i18n>Data Source</ng-container>
</th>
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
{{ element.data?.dataSource }}
</td>
</ng-container>
<ng-container matColumnDef="attempts">
<th *matHeaderCellDef class="px-1 py-2 text-right" mat-header-cell>
<ng-container i18n>Attempts</ng-container>
</th>
<td *matCellDef="let element" class="px-1 py-2 text-right" mat-cell>
{{ element.attemptsMade }}
</td>
</ng-container>
<ng-container matColumnDef="created">
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
<ng-container i18n>Created</ng-container>
</th>
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
{{ element.timestamp | date: defaultDateTimeFormat }}
</td>
</ng-container>
<ng-container matColumnDef="finished">
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
<ng-container i18n>Finished</ng-container>
</th>
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
{{ element.finishedOn | date: defaultDateTimeFormat }}
</td>
</ng-container>
<ng-container matColumnDef="status">
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
<ng-container i18n>Status</ng-container>
</th>
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
<ion-icon
*ngIf="element.state === 'active'"
name="play-outline"
></ion-icon>
<ion-icon
*ngIf="element.state === 'completed'"
class="text-success"
name="checkmark-circle-outline"
></ion-icon>
<ion-icon
*ngIf="element.state === 'delayed'"
name="time-outline"
[ngClass]="{ 'text-danger': element.stacktrace?.length > 0 }"
></ion-icon>
<ion-icon
*ngIf="element.state === 'failed'"
class="text-danger"
name="alert-circle-outline"
></ion-icon>
<ion-icon
*ngIf="element.state === 'paused'"
name="pause-outline"
></ion-icon>
<ion-icon
*ngIf="element.state === 'waiting'"
name="cafe-outline"
></ion-icon>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
<button <button
class="mx-1 no-min-width px-2" class="mx-1 no-min-width px-2"
mat-button mat-button
@@ -39,69 +136,7 @@
</button> </button>
</mat-menu> </mat-menu>
</th> </th>
</tr> <td *matCellDef="let element" class="px-1 py-2" mat-cell>
</thead>
<tbody>
<ng-container *ngFor="let job of jobs">
<tr class="mat-row">
<td class="mat-cell px-1 py-2 text-right">{{ job.id }}</td>
<td class="mat-cell px-1 py-2">
<span class="align-items-center d-flex">
<ion-icon
class="mr-1"
name="arrow-down-circle-outline"
></ion-icon>
<ng-container *ngIf="job.name === 'GATHER_ASSET_PROFILE'">
<span i18n>Asset Profile</span>
</ng-container>
<ng-container
*ngIf="job.name === 'GATHER_HISTORICAL_MARKET_DATA'"
>
<span i18n>Historical Market Data</span>
</ng-container>
</span>
</td>
<td class="mat-cell px-1 py-2">{{ job.data?.symbol }}</td>
<td class="mat-cell px-1 py-2">{{ job.data?.dataSource }}</td>
<td class="mat-cell px-1 py-2 text-right">
{{ job.attemptsMade }}
</td>
<td class="mat-cell px-1 py-2">
{{ job.timestamp | date: defaultDateTimeFormat }}
</td>
<td class="mat-cell px-1 py-2">
{{ job.finishedOn | date: defaultDateTimeFormat }}
</td>
<td class="mat-cell px-1 py-2">
<ion-icon
*ngIf="job.state === 'active'"
name="play-outline"
></ion-icon>
<ion-icon
*ngIf="job.state === 'completed'"
class="text-success"
name="checkmark-circle-outline"
></ion-icon>
<ion-icon
*ngIf="job.state === 'delayed'"
name="time-outline"
[ngClass]="{ 'text-danger': job.stacktrace?.length > 0 }"
></ion-icon>
<ion-icon
*ngIf="job.state === 'failed'"
class="text-danger"
name="alert-circle-outline"
></ion-icon>
<ion-icon
*ngIf="job.state === 'paused'"
name="pause-outline"
></ion-icon>
<ion-icon
*ngIf="job.state === 'waiting'"
name="cafe-outline"
></ion-icon>
</td>
<td class="mat-cell px-1 py-2">
<button <button
class="mx-1 no-min-width px-2" class="mx-1 no-min-width px-2"
mat-button mat-button
@@ -111,24 +146,25 @@
<ion-icon name="ellipsis-horizontal"></ion-icon> <ion-icon name="ellipsis-horizontal"></ion-icon>
</button> </button>
<mat-menu #jobActionsMenu="matMenu" xPosition="before"> <mat-menu #jobActionsMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onViewData(job.data)"> <button mat-menu-item (click)="onViewData(element.data)">
<ng-container i18n>View Data</ng-container> <ng-container i18n>View Data</ng-container>
</button> </button>
<button <button
mat-menu-item mat-menu-item
[disabled]="job.stacktrace?.length <= 0" [disabled]="element.stacktrace?.length <= 0"
(click)="onViewStacktrace(job.stacktrace)" (click)="onViewStacktrace(element.stacktrace)"
> >
<ng-container i18n>View Stacktrace</ng-container> <ng-container i18n>View Stacktrace</ng-container>
</button> </button>
<button mat-menu-item (click)="onDeleteJob(job.id)"> <button mat-menu-item (click)="onDeleteJob(element.id)">
<ng-container i18n>Delete Job</ng-container> <ng-container i18n>Delete Job</ng-container>
</button> </button>
</mat-menu> </mat-menu>
</td> </td>
</tr>
</ng-container> </ng-container>
</tbody>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table> </table>
</div> </div>
</div> </div>

View File

@@ -4,6 +4,7 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { MatTableModule } from '@angular/material/table';
import { AdminJobsComponent } from './admin-jobs.component'; import { AdminJobsComponent } from './admin-jobs.component';
@@ -15,6 +16,7 @@ import { AdminJobsComponent } from './admin-jobs.component';
MatButtonModule, MatButtonModule,
MatMenuModule, MatMenuModule,
MatSelectModule, MatSelectModule,
MatTableModule,
ReactiveFormsModule ReactiveFormsModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]

View File

@@ -9,7 +9,11 @@
[showYAxis]="true" [showYAxis]="true"
[symbol]="symbol" [symbol]="symbol"
></gf-line-chart> ></gf-line-chart>
<div *ngFor="let itemByMonth of marketDataByMonth | keyvalue" class="d-flex"> <div
*ngFor="let itemByMonth of marketDataByMonth | keyvalue"
class="d-flex"
[hidden]="!marketData.length > 0"
>
<div class="date px-1 text-nowrap">{{ itemByMonth.key }}</div> <div class="date px-1 text-nowrap">{{ itemByMonth.key }}</div>
<div class="align-items-center d-flex flex-grow-1 px-1"> <div class="align-items-center d-flex flex-grow-1 px-1">
<div <div

View File

@@ -28,7 +28,6 @@
&.today { &.today {
background-color: rgba(var(--palette-accent-500), 1); background-color: rgba(var(--palette-accent-500), 1);
cursor: default;
} }
} }
} }

View File

@@ -83,10 +83,10 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
public ngOnChanges() { public ngOnChanges() {
this.defaultDateFormat = getDateFormatString(this.locale); this.defaultDateFormat = getDateFormatString(this.locale);
this.historicalDataItems = this.marketData.map((marketDataItem) => { this.historicalDataItems = this.marketData.map(({ date, marketPrice }) => {
return { return {
date: format(marketDataItem.date, DATE_FORMAT), date: format(date, DATE_FORMAT),
value: marketDataItem.marketPrice value: marketPrice
}; };
}); });
@@ -157,10 +157,6 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
const date = parseISO(`${yearMonth}-${day}`); const date = parseISO(`${yearMonth}-${day}`);
const marketPrice = this.marketDataByMonth[yearMonth]?.[day]?.marketPrice; const marketPrice = this.marketDataByMonth[yearMonth]?.[day]?.marketPrice;
if (isSameDay(date, new Date())) {
return;
}
const dialogRef = this.dialog.open(MarketDataDetailDialog, { const dialogRef = this.dialog.open(MarketDataDetailDialog, {
data: <MarketDataDetailDialogParams>{ data: <MarketDataDetailDialogParams>{
date, date,
@@ -177,7 +173,7 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ withRefresh }) => { .subscribe(({ withRefresh } = { withRefresh: false }) => {
this.marketDataChanged.next(withRefresh); this.marketDataChanged.next(withRefresh);
}); });
} }

View File

@@ -57,10 +57,16 @@ export class MarketDataDetailDialog implements OnDestroy {
public onUpdate() { public onUpdate() {
this.adminService this.adminService
.putMarketData({ .postMarketData({
dataSource: this.data.dataSource, dataSource: this.data.dataSource,
date: this.data.date, marketData: {
marketData: { marketPrice: this.data.marketPrice }, marketData: [
{
date: this.data.date.toISOString(),
marketPrice: this.data.marketPrice
}
]
},
symbol: this.data.symbol symbol: this.data.symbol
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))

View File

@@ -178,10 +178,20 @@ export class AdminMarketDataComponent
} }
public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) { public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) {
const confirmation = confirm(
$localize`Do you really want to delete this asset profile?`
);
if (confirmation) {
this.adminService this.adminService
.deleteProfileData({ dataSource, symbol }) .deleteProfileData({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {}); .subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
}
} }
public onGather7Days() { public onGather7Days() {
@@ -342,7 +352,7 @@ export class AdminMarketDataComponent
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ dataSource, symbol }) => { .subscribe(({ dataSource, symbol } = {}) => {
if (dataSource && symbol) { if (dataSource && symbol) {
this.adminService this.adminService
.addAssetProfile({ dataSource, symbol }) .addAssetProfile({ dataSource, symbol })

View File

@@ -143,12 +143,24 @@
<ion-icon name="ellipsis-horizontal"></ion-icon> <ion-icon name="ellipsis-horizontal"></ion-icon>
</button> </button>
<mat-menu #assetProfileActionsMenu="matMenu" xPosition="before"> <mat-menu #assetProfileActionsMenu="matMenu" xPosition="before">
<button
mat-menu-item
(click)="onOpenAssetProfileDialog({ dataSource: element.dataSource, symbol: element.symbol })"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline"></ion-icon>
<span i18n>Edit</span>
</span>
</button>
<button <button
mat-menu-item mat-menu-item
[disabled]="element.activitiesCount !== 0" [disabled]="element.activitiesCount !== 0"
(click)="onDeleteProfileData({dataSource: element.dataSource, symbol: element.symbol})" (click)="onDeleteProfileData({dataSource: element.dataSource, symbol: element.symbol})"
> >
<ng-container i18n>Delete</ng-container> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete</span>
</span>
</button> </button>
</mat-menu> </mat-menu>
</td> </td>

View File

@@ -2,11 +2,4 @@
:host { :host {
display: block; display: block;
.fab-container {
bottom: 2rem;
position: fixed;
right: 2rem;
z-index: 999;
}
} }

View File

@@ -6,17 +6,25 @@ import {
OnDestroy, OnDestroy,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { FormBuilder } from '@angular/forms'; import { FormBuilder, FormControl, Validators } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto'; import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { import {
AdminMarketDataDetails, AdminMarketDataDetails,
UniqueAsset UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { MarketData, SymbolProfile } from '@prisma/client'; import {
AssetClass,
AssetSubClass,
MarketData,
SymbolProfile
} from '@prisma/client';
import { format } from 'date-fns';
import { parse as csvToJson } from 'papaparse';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@@ -30,24 +38,38 @@ import { AssetProfileDialogParams } from './interfaces/interfaces';
styleUrls: ['./asset-profile-dialog.component.scss'] styleUrls: ['./asset-profile-dialog.component.scss']
}) })
export class AssetProfileDialog implements OnDestroy, OnInit { export class AssetProfileDialog implements OnDestroy, OnInit {
public assetClass: string; public assetProfileClass: string;
public assetClasses = Object.keys(AssetClass).map((assetClass) => {
return { id: assetClass, label: translate(assetClass) };
});
public assetSubClasses = Object.keys(AssetSubClass).map((assetSubClass) => {
return { id: assetSubClass, label: translate(assetSubClass) };
});
public assetProfile: AdminMarketDataDetails['assetProfile']; public assetProfile: AdminMarketDataDetails['assetProfile'];
public assetProfileForm = this.formBuilder.group({ public assetProfileForm = this.formBuilder.group({
assetClass: new FormControl<AssetClass>(undefined),
assetSubClass: new FormControl<AssetSubClass>(undefined),
comment: '', comment: '',
name: ['', Validators.required],
scraperConfiguration: '', scraperConfiguration: '',
symbolMapping: '' symbolMapping: ''
}); });
public assetSubClass: string; public assetProfileSubClass: string;
public benchmarks: Partial<SymbolProfile>[]; public benchmarks: Partial<SymbolProfile>[];
public countries: { public countries: {
[code: string]: { name: string; value: number }; [code: string]: { name: string; value: number };
}; };
public historicalDataAsCsvString: string;
public isBenchmark = false; public isBenchmark = false;
public marketDataDetails: MarketData[] = []; public marketDataDetails: MarketData[] = [];
public sectors: { public sectors: {
[name: string]: { name: string; value: number }; [name: string]: { name: string; value: number };
}; };
private static readonly HISTORICAL_DATA_TEMPLATE = `date;marketPrice\n${format(
new Date(),
DATE_FORMAT
)};123.45`;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
@@ -66,6 +88,9 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
} }
public initialize() { public initialize() {
this.historicalDataAsCsvString =
AssetProfileDialog.HISTORICAL_DATA_TEMPLATE;
this.adminService this.adminService
.fetchAdminMarketDataBySymbol({ .fetchAdminMarketDataBySymbol({
dataSource: this.data.dataSource, dataSource: this.data.dataSource,
@@ -75,8 +100,8 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
.subscribe(({ assetProfile, marketData }) => { .subscribe(({ assetProfile, marketData }) => {
this.assetProfile = assetProfile; this.assetProfile = assetProfile;
this.assetClass = translate(this.assetProfile?.assetClass); this.assetProfileClass = translate(this.assetProfile?.assetClass);
this.assetSubClass = translate(this.assetProfile?.assetSubClass); this.assetProfileSubClass = translate(this.assetProfile?.assetSubClass);
this.countries = {}; this.countries = {};
this.isBenchmark = this.benchmarks.some(({ id }) => { this.isBenchmark = this.benchmarks.some(({ id }) => {
return id === this.assetProfile.id; return id === this.assetProfile.id;
@@ -103,7 +128,10 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
} }
this.assetProfileForm.setValue({ this.assetProfileForm.setValue({
assetClass: this.assetProfile.assetClass ?? null,
assetSubClass: this.assetProfile.assetSubClass ?? null,
comment: this.assetProfile?.comment ?? '', comment: this.assetProfile?.comment ?? '',
name: this.assetProfile.name ?? this.assetProfile.symbol,
scraperConfiguration: JSON.stringify( scraperConfiguration: JSON.stringify(
this.assetProfile?.scraperConfiguration ?? {} this.assetProfile?.scraperConfiguration ?? {}
), ),
@@ -134,6 +162,29 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
.subscribe(() => {}); .subscribe(() => {});
} }
public onImportHistoricalData() {
const marketData = csvToJson(this.historicalDataAsCsvString, {
dynamicTyping: true,
header: true,
skipEmptyLines: true
}).data;
this.adminService
.postMarketData({
dataSource: this.data.dataSource,
marketData: {
marketData: marketData.map(({ date, marketPrice }) => {
return { marketPrice, date: parseDate(date).toISOString() };
})
},
symbol: this.data.symbol
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.initialize();
});
}
public onMarketDataChanged(withRefresh: boolean = false) { public onMarketDataChanged(withRefresh: boolean = false) {
if (withRefresh) { if (withRefresh) {
this.initialize(); this.initialize();
@@ -170,9 +221,12 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
} catch {} } catch {}
const assetProfileData: UpdateAssetProfileDto = { const assetProfileData: UpdateAssetProfileDto = {
assetClass: this.assetProfileForm.controls['assetClass'].value,
assetSubClass: this.assetProfileForm.controls['assetSubClass'].value,
comment: this.assetProfileForm.controls['comment'].value ?? null,
name: this.assetProfileForm.controls['name'].value,
scraperConfiguration, scraperConfiguration,
symbolMapping, symbolMapping
comment: this.assetProfileForm.controls['comment'].value ?? null
}; };
this.adminService this.adminService

View File

@@ -51,6 +51,36 @@
[symbol]="data.symbol" [symbol]="data.symbol"
(marketDataChanged)="onMarketDataChanged($event)" (marketDataChanged)="onMarketDataChanged($event)"
></gf-admin-market-data-detail> ></gf-admin-market-data-detail>
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label>
<ng-container i18n>Historical Data</ng-container> (CSV)
</mat-label>
<textarea
cdkAutosizeMaxRows="5"
cdkTextareaAutosize
matInput
placeholder="e.g. 20230601;1.61"
type="text"
[ngModelOptions]="{standalone: true}"
[(ngModel)]="historicalDataAsCsvString"
(keyup.enter)="$event.stopPropagation()"
></textarea>
</mat-form-field>
</div>
<div class="d-flex justify-content-end mt-2">
<button
color="accent"
mat-flat-button
type="button"
(click)="onImportHistoricalData()"
>
<ng-container i18n>Import</ng-container>
</button>
</div>
<div class="row"> <div class="row">
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value i18n size="medium" [value]="assetProfile?.symbol" <gf-value i18n size="medium" [value]="assetProfile?.symbol"
@@ -82,7 +112,11 @@
> >
</div> </div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value i18n size="medium" [hidden]="!assetClass" [value]="assetClass" <gf-value
i18n
size="medium"
[hidden]="!assetProfileClass"
[value]="assetProfileClass"
>Asset Class</gf-value >Asset Class</gf-value
> >
</div> </div>
@@ -90,8 +124,8 @@
<gf-value <gf-value
i18n i18n
size="medium" size="medium"
[hidden]="!assetSubClass" [hidden]="!assetProfileSubClass"
[value]="assetSubClass" [value]="assetProfileSubClass"
>Asset Sub Class</gf-value >Asset Sub Class</gf-value
> >
</div> </div>
@@ -144,6 +178,38 @@
</ng-template> </ng-template>
</ng-container> </ng-container>
</div> </div>
<div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Name</mat-label>
<input formControlName="name" matInput type="text" />
</mat-form-field>
</div>
<div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Asset Class</mat-label>
<mat-select formControlName="assetClass">
<mat-option [value]="null"></mat-option>
<mat-option
*ngFor="let assetClass of assetClasses"
[value]="assetClass.id"
>{{ assetClass.label }}</mat-option
>
</mat-select>
</mat-form-field>
</div>
<div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Asset Sub Class</mat-label>
<mat-select formControlName="assetSubClass">
<mat-option [value]="null"></mat-option>
<mat-option
*ngFor="let assetSubClass of assetSubClasses"
[value]="assetSubClass.id"
>{{ assetSubClass.label }}</mat-option
>
</mat-select>
</mat-form-field>
</div>
<div class="d-flex my-3"> <div class="d-flex my-3">
<div class="w-50"> <div class="w-50">
<mat-checkbox <mat-checkbox

View File

@@ -7,6 +7,7 @@ import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatSelectModule } from '@angular/material/select';
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 { 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';
@@ -26,6 +27,7 @@ import { AssetProfileDialog } from './asset-profile-dialog.component';
MatDialogModule, MatDialogModule,
MatInputModule, MatInputModule,
MatMenuModule, MatMenuModule,
MatSelectModule,
ReactiveFormsModule, ReactiveFormsModule,
TextFieldModule TextFieldModule
], ],

View File

@@ -1,15 +1,15 @@
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef,
Component, Component,
Inject,
OnDestroy, OnDestroy,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { import {
AbstractControl,
FormBuilder, FormBuilder,
FormControl, FormControl,
FormGroup, FormGroup,
ValidationErrors,
Validators Validators
} from '@angular/forms'; } from '@angular/forms';
import { MatDialogRef } from '@angular/material/dialog'; import { MatDialogRef } from '@angular/material/dialog';
@@ -19,35 +19,75 @@ import { AdminService } from '@ghostfolio/client/services/admin.service';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'h-100' }, host: { class: 'h-100' },
selector: 'gf-create-asset-profile-dialog', selector: 'gf-create-asset-profile-dialog',
styleUrls: ['./create-asset-profile-dialog.component.scss'],
templateUrl: 'create-asset-profile-dialog.html' templateUrl: 'create-asset-profile-dialog.html'
}) })
export class CreateAssetProfileDialog implements OnInit, OnDestroy { export class CreateAssetProfileDialog implements OnInit, OnDestroy {
public createAssetProfileForm: FormGroup; public createAssetProfileForm: FormGroup;
public mode: 'auto' | 'manual';
public constructor( public constructor(
public readonly adminService: AdminService, public readonly adminService: AdminService,
public readonly changeDetectorRef: ChangeDetectorRef,
public readonly dialogRef: MatDialogRef<CreateAssetProfileDialog>, public readonly dialogRef: MatDialogRef<CreateAssetProfileDialog>,
public readonly formBuilder: FormBuilder public readonly formBuilder: FormBuilder
) {} ) {}
public ngOnInit() { public ngOnInit() {
this.createAssetProfileForm = this.formBuilder.group({ this.createAssetProfileForm = this.formBuilder.group(
{
addSymbol: new FormControl(null, [Validators.required]),
searchSymbol: new FormControl(null, [Validators.required]) searchSymbol: new FormControl(null, [Validators.required])
}); },
{
validators: this.atLeastOneValid
}
);
this.mode = 'auto';
} }
public onCancel() { public onCancel() {
this.dialogRef.close(); this.dialogRef.close();
} }
public onRadioChange(mode: 'auto' | 'manual') {
this.mode = mode;
}
public onSubmit() { public onSubmit() {
this.dialogRef.close({ this.mode === 'auto'
? this.dialogRef.close({
dataSource: dataSource:
this.createAssetProfileForm.controls['searchSymbol'].value.dataSource, this.createAssetProfileForm.controls['searchSymbol'].value
symbol: this.createAssetProfileForm.controls['searchSymbol'].value.symbol .dataSource,
symbol:
this.createAssetProfileForm.controls['searchSymbol'].value.symbol
})
: this.dialogRef.close({
dataSource: 'MANUAL',
symbol: this.createAssetProfileForm.controls['addSymbol'].value
}); });
} }
public ngOnDestroy() {} public ngOnDestroy() {}
private atLeastOneValid(control: AbstractControl): ValidationErrors {
const addSymbolControl = control.get('addSymbol');
const searchSymbolControl = control.get('searchSymbol');
if (addSymbolControl.valid && searchSymbolControl.valid) {
return { atLeastOneValid: true };
}
if (
addSymbolControl.valid ||
!addSymbolControl ||
searchSymbolControl.valid ||
!searchSymbolControl
) {
return { atLeastOneValid: false };
}
return { atLeastOneValid: true };
}
} }

View File

@@ -6,6 +6,21 @@
> >
<h1 i18n mat-dialog-title>Add Asset Profile</h1> <h1 i18n mat-dialog-title>Add Asset Profile</h1>
<div class="flex-grow-1 py-3" mat-dialog-content> <div class="flex-grow-1 py-3" mat-dialog-content>
<div class="mb-3">
<mat-radio-group
color="primary"
[value]="mode"
(change)="onRadioChange($event.value)"
>
<mat-radio-button name="auto" value="auto"></mat-radio-button>
<label class="m-0" for="auto" i18n>Search</label>
<mat-radio-button class="ml-3" name="manual" value="manual">
</mat-radio-button>
<label class="m-0" for="manual" i18n>Add Manually</label>
</mat-radio-group>
</div>
<div *ngIf="mode === 'auto'">
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Name, symbol or ISIN</mat-label> <mat-label i18n>Name, symbol or ISIN</mat-label>
<gf-symbol-autocomplete <gf-symbol-autocomplete
@@ -14,13 +29,20 @@
/> />
</mat-form-field> </mat-form-field>
</div> </div>
<div *ngIf="mode === 'manual'">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Symbol</mat-label>
<input formControlName="addSymbol" matInput />
</mat-form-field>
</div>
</div>
<div class="d-flex justify-content-end" mat-dialog-actions> <div class="d-flex justify-content-end" mat-dialog-actions>
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button> <button i18n mat-button type="button" (click)="onCancel()">Cancel</button>
<button <button
color="primary" color="primary"
mat-flat-button mat-flat-button
type="submit" type="submit"
[disabled]="!createAssetProfileForm.valid" [disabled]="createAssetProfileForm.hasError('atLeastOneValid')"
> >
<ng-container i18n>Save</ng-container> <ng-container i18n>Save</ng-container>
</button> </button>

View File

@@ -4,6 +4,8 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatRadioModule } from '@angular/material/radio';
import { GfSymbolAutocompleteModule } from '@ghostfolio/ui/symbol-autocomplete'; import { GfSymbolAutocompleteModule } from '@ghostfolio/ui/symbol-autocomplete';
import { CreateAssetProfileDialog } from './create-asset-profile-dialog.component'; import { CreateAssetProfileDialog } from './create-asset-profile-dialog.component';
@@ -17,6 +19,8 @@ import { CreateAssetProfileDialog } from './create-asset-profile-dialog.componen
MatDialogModule, MatDialogModule,
MatButtonModule, MatButtonModule,
MatFormFieldModule, MatFormFieldModule,
MatInputModule,
MatRadioModule,
ReactiveFormsModule ReactiveFormsModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]

View File

@@ -1,6 +1,5 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatCheckboxChange } from '@angular/material/checkbox'; import { MatSlideToggleChange } from '@angular/material/slide-toggle';
import { environment } from '@ghostfolio/client/../environments/environment';
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
import { CacheService } from '@ghostfolio/client/services/cache.service'; import { CacheService } from '@ghostfolio/client/services/cache.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
@@ -170,20 +169,20 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
} }
} }
public onReadOnlyModeChange(aEvent: MatCheckboxChange) { public onEnableUserSignupModeChange(aEvent: MatSlideToggleChange) {
this.putAdminSetting({
key: PROPERTY_IS_READ_ONLY_MODE,
value: aEvent.checked ? true : undefined
});
}
public onEnableUserSignupModeChange(aEvent: MatCheckboxChange) {
this.putAdminSetting({ this.putAdminSetting({
key: PROPERTY_IS_USER_SIGNUP_ENABLED, key: PROPERTY_IS_USER_SIGNUP_ENABLED,
value: aEvent.checked ? undefined : false value: aEvent.checked ? undefined : false
}); });
} }
public onReadOnlyModeChange(aEvent: MatSlideToggleChange) {
this.putAdminSetting({
key: PROPERTY_IS_READ_ONLY_MODE,
value: aEvent.checked ? true : undefined
});
}
public onSetSystemMessage() { public onSetSystemMessage() {
const systemMessage = prompt($localize`Please set your system message:`); const systemMessage = prompt($localize`Please set your system message:`);

View File

@@ -55,6 +55,18 @@
</td> </td>
<td class="pl-1">{{ exchangeRate.label2 }}</td> <td class="pl-1">{{ exchangeRate.label2 }}</td>
<td> <td>
<a
class="h-100 mx-1 no-min-width px-2"
mat-button
[queryParams]="{
assetProfileDialog: true,
dataSource: exchangeRate.dataSource,
symbol: exchangeRate.symbol
}"
[routerLink]="['/admin', 'market-data']"
>
<ion-icon name="create-outline"></ion-icon>
</a>
<button <button
*ngIf="customCurrencies.includes(exchangeRate.label2)" *ngIf="customCurrencies.includes(exchangeRate.label2)"
class="h-100 mx-1 no-min-width px-2" class="h-100 mx-1 no-min-width px-2"
@@ -81,21 +93,23 @@
<div class="d-flex my-3"> <div class="d-flex my-3">
<div class="w-50" i18n>User Signup</div> <div class="w-50" i18n>User Signup</div>
<div class="w-50"> <div class="w-50">
<mat-checkbox <mat-slide-toggle
color="primary" color="primary"
hideIcon="true"
[checked]="info.globalPermissions.includes(permissions.createUserAccount)" [checked]="info.globalPermissions.includes(permissions.createUserAccount)"
(change)="onEnableUserSignupModeChange($event)" (change)="onEnableUserSignupModeChange($event)"
></mat-checkbox> ></mat-slide-toggle>
</div> </div>
</div> </div>
<div *ngIf="hasPermissionToToggleReadOnlyMode" class="d-flex my-3"> <div *ngIf="hasPermissionToToggleReadOnlyMode" class="d-flex my-3">
<div class="w-50" i18n>Read-only Mode</div> <div class="w-50" i18n>Read-only Mode</div>
<div class="w-50"> <div class="w-50">
<mat-checkbox <mat-slide-toggle
color="primary" color="primary"
hideIcon="true"
[checked]="info?.isReadOnlyMode" [checked]="info?.isReadOnlyMode"
(change)="onReadOnlyModeChange($event)" (change)="onReadOnlyModeChange($event)"
></mat-checkbox> ></mat-slide-toggle>
</div> </div>
</div> </div>
<div *ngIf="hasPermissionForSystemMessage" class="d-flex my-3"> <div *ngIf="hasPermissionForSystemMessage" class="d-flex my-3">

View File

@@ -3,8 +3,9 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { RouterModule } from '@angular/router';
import { CacheService } from '@ghostfolio/client/services/cache.service'; import { CacheService } from '@ghostfolio/client/services/cache.service';
import { GfValueModule } from '@ghostfolio/ui/value'; import { GfValueModule } from '@ghostfolio/ui/value';
@@ -18,10 +19,11 @@ import { AdminOverviewComponent } from './admin-overview.component';
FormsModule, FormsModule,
GfValueModule, GfValueModule,
MatButtonModule, MatButtonModule,
MatCheckboxModule,
MatCardModule, MatCardModule,
MatSelectModule, MatSelectModule,
ReactiveFormsModule MatSlideToggleModule,
ReactiveFormsModule,
RouterModule
], ],
providers: [CacheService], providers: [CacheService],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]

View File

@@ -86,12 +86,16 @@
</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">
<ion-icon class="mr-2" name="create-outline"></ion-icon> <ion-icon class="mr-2" name="create-outline"></ion-icon>
<span i18n>Edit</span> <span i18n>Edit</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">
<ion-icon class="mr-2" name="trash-outline"></ion-icon> <ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete</span> <span i18n>Delete</span>
</span>
</button> </button>
</mat-menu> </mat-menu>
</td> </td>

View File

@@ -66,12 +66,16 @@
</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">
<ion-icon class="mr-2" name="create-outline"></ion-icon> <ion-icon class="mr-2" name="create-outline"></ion-icon>
<span i18n>Edit</span> <span i18n>Edit</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">
<ion-icon class="mr-2" name="trash-outline"></ion-icon> <ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete</span> <span i18n>Delete</span>
</span>
</button> </button>
</mat-menu> </mat-menu>
</td> </td>

View File

@@ -1,4 +1,5 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatTableDataSource } from '@angular/material/table';
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
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';
@@ -20,13 +21,15 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './admin-users.html' templateUrl: './admin-users.html'
}) })
export class AdminUsersComponent implements OnDestroy, OnInit { export class AdminUsersComponent implements OnDestroy, OnInit {
public dataSource: MatTableDataSource<AdminData['users'][0]> =
new MatTableDataSource();
public defaultDateFormat: string; public defaultDateFormat: string;
public displayedColumns: string[] = [];
public getEmojiFlag = getEmojiFlag; public getEmojiFlag = getEmojiFlag;
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
public hasPermissionToImpersonateAllUsers: boolean; public hasPermissionToImpersonateAllUsers: boolean;
public info: InfoItem; public info: InfoItem;
public user: User; public user: User;
public users: AdminData['users'];
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@@ -44,6 +47,29 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
permissions.enableSubscription permissions.enableSubscription
); );
if (this.hasPermissionForSubscription) {
this.displayedColumns = [
'index',
'user',
'country',
'registration',
'accounts',
'activities',
'engagementPerDay',
'lastRequest',
'actions'
];
} else {
this.displayedColumns = [
'index',
'user',
'registration',
'accounts',
'activities',
'actions'
];
}
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => { .subscribe((state) => {
@@ -118,7 +144,7 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
.fetchAdminData() .fetchAdminData()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ users }) => { .subscribe(({ users }) => {
this.users = users; this.dataSource = new MatTableDataSource(users);
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });

View File

@@ -2,107 +2,193 @@
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<div class="users"> <div class="users">
<table class="gf-table"> <table class="gf-table" mat-table [dataSource]="dataSource">
<thead> <ng-container matColumnDef="index">
<tr class="mat-mdc-header-row">
<th class="mat-mdc-header-cell px-1 py-2 text-right">#</th>
<th class="mat-mdc-header-cell px-1 py-2" i18n>User</th>
<th <th
*ngIf="hasPermissionForSubscription" *matHeaderCellDef
class="mat-mdc-header-cell px-1 py-2"
>
<ng-container i18n>Country</ng-container>
</th>
<th class="mat-mdc-header-cell px-1 py-2">
<ng-container i18n>Registration</ng-container>
</th>
<th class="mat-mdc-header-cell px-1 py-2 text-right">
<ng-container i18n>Accounts</ng-container>
</th>
<th class="mat-mdc-header-cell px-1 py-2 text-right">
<ng-container i18n>Activities</ng-container>
</th>
<th
*ngIf="hasPermissionForSubscription"
class="mat-mdc-header-cell px-1 py-2 text-right" class="mat-mdc-header-cell px-1 py-2 text-right"
mat-header-cell
> >
<ng-container i18n>Engagement per Day</ng-container> #
</th> </th>
<td
*matCellDef="let element; let i=index"
class="mat-mdc-cell px-1 py-2 text-right"
mat-cell
>
{{ i + 1 }}
</td>
</ng-container>
<ng-container matColumnDef="user">
<th <th
*ngIf="hasPermissionForSubscription" *matHeaderCellDef
class="mat-mdc-header-cell px-1 py-2" class="mat-mdc-header-cell px-1 py-2"
i18n i18n
mat-header-cell
> >
Last Request User
</th> </th>
<th class="mat-mdc-header-cell px-1 py-2"></th> <td
</tr> *matCellDef="let element"
</thead> class="mat-mdc-cell px-1 py-2"
<tbody> mat-cell
<tr
*ngFor="let userItem of users; let i = index"
class="mat-mdc-row"
> >
<td class="mat-mdc-cell px-1 py-2 text-right">{{ i + 1 }}</td>
<td class="mat-mdc-cell px-1 py-2">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<span class="d-none d-sm-inline-block text-monospace" <span class="d-none d-sm-inline-block text-monospace"
>{{ userItem.id }}</span >{{ element.id }}</span
> >
<span class="d-inline-block d-sm-none text-monospace" <span class="d-inline-block d-sm-none text-monospace"
>{{ (userItem.id | slice:0:5) + '...' }}</span >{{ (element.id | slice:0:5) + '...' }}</span
> >
<gf-premium-indicator <gf-premium-indicator
*ngIf="userItem?.subscription?.type === 'Premium'" *ngIf="element?.subscription?.type === 'Premium'"
class="ml-1" class="ml-1"
[enableLink]="false" [enableLink]="false"
[title]="'Expires ' + formatDistanceToNow(userItem.subscription.expiresAt) + ' (' + (userItem.subscription.expiresAt | date: defaultDateFormat) + ')'" [title]="'Expires ' + formatDistanceToNow(element.subscription.expiresAt) + ' (' + (element.subscription.expiresAt | date: defaultDateFormat) + ')'"
></gf-premium-indicator> ></gf-premium-indicator>
</div> </div>
</td> </td>
<td </ng-container>
<ng-container
*ngIf="hasPermissionForSubscription" *ngIf="hasPermissionForSubscription"
matColumnDef="country"
>
<th
*matHeaderCellDef
class="mat-mdc-header-cell px-1 py-2"
mat-header-cell
>
<ng-container i18n>Country</ng-container>
</th>
<td
*matCellDef="let element"
class="mat-mdc-cell px-1 py-2" class="mat-mdc-cell px-1 py-2"
mat-cell
> >
<span class="h5" [title]="userItem.country" <span class="h5" [title]="element.country"
>{{ getEmojiFlag(userItem.country) }}</span >{{ getEmojiFlag(element.country) }}</span
> >
</td> </td>
<td class="mat-mdc-cell px-1 py-2"> </ng-container>
{{ formatDistanceToNow(userItem.createdAt) }}
</td> <ng-container matColumnDef="registration">
<td class="mat-mdc-cell px-1 py-2 text-right"> <th
<gf-value *matHeaderCellDef
class="d-inline-block justify-content-end" class="mat-mdc-header-cell px-1 py-2"
[locale]="user?.settings?.locale" mat-header-cell
[value]="userItem.accountCount" >
></gf-value> <ng-container i18n>Registration</ng-container>
</td> </th>
<td class="mat-mdc-cell px-1 py-2 text-right">
<gf-value
class="d-inline-block justify-content-end"
[locale]="user?.settings?.locale"
[value]="userItem.transactionCount"
></gf-value>
</td>
<td <td
*ngIf="hasPermissionForSubscription" *matCellDef="let element"
class="mat-mdc-cell px-1 py-2"
mat-cell
>
{{ formatDistanceToNow(element.createdAt) }}
</td>
</ng-container>
<ng-container matColumnDef="accounts">
<th
*matHeaderCellDef
class="mat-mdc-header-cell px-1 py-2 text-right"
mat-header-cell
>
<ng-container i18n>Accounts</ng-container>
</th>
<td
*matCellDef="let element"
class="mat-mdc-cell px-1 py-2 text-right" class="mat-mdc-cell px-1 py-2 text-right"
mat-cell
>
<gf-value
class="d-inline-block justify-content-end"
[locale]="user?.settings?.locale"
[value]="element.accountCount"
></gf-value>
</td>
</ng-container>
<ng-container matColumnDef="activities">
<th
*matHeaderCellDef
class="mat-mdc-header-cell px-1 py-2 text-right"
mat-header-cell
>
<ng-container i18n>Activities</ng-container>
</th>
<td
*matCellDef="let element"
class="mat-mdc-cell px-1 py-2 text-right"
mat-cell
>
<gf-value
class="d-inline-block justify-content-end"
[locale]="user?.settings?.locale"
[value]="element.transactionCount"
></gf-value>
</td>
</ng-container>
<ng-container
*ngIf="hasPermissionForSubscription"
matColumnDef="engagementPerDay"
>
<th
*matHeaderCellDef
class="mat-mdc-header-cell px-1 py-2 text-right"
mat-header-cell
>
<ng-container i18n>Engagement per Day</ng-container>
</th>
<td
*matCellDef="let element"
class="mat-mdc-cell px-1 py-2 text-right"
mat-cell
> >
<gf-value <gf-value
class="d-inline-block justify-content-end" class="d-inline-block justify-content-end"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[precision]="0" [precision]="0"
[value]="userItem.engagement" [value]="element.engagement"
></gf-value> ></gf-value>
</td> </td>
<td </ng-container>
<ng-container
*ngIf="hasPermissionForSubscription" *ngIf="hasPermissionForSubscription"
class="mat-mdc-cell px-1 py-2" matColumnDef="lastRequest"
> >
{{ formatDistanceToNow(userItem.lastActivity) }} <th
*matHeaderCellDef
class="mat-mdc-header-cell px-1 py-2"
i18n
mat-header-cell
>
Last Request
</th>
<td
*matCellDef="let element"
class="mat-mdc-cell px-1 py-2"
mat-cell
>
{{ formatDistanceToNow(element.lastActivity) }}
</td> </td>
<td class="mat-mdc-cell px-1 py-2"> </ng-container>
<ng-container matColumnDef="actions">
<th
*matHeaderCellDef
class="mat-mdc-header-cell px-1 py-2"
mat-header-cell
></th>
<td
*matCellDef="let element"
class="mat-mdc-cell px-1 py-2"
mat-cell
>
<button <button
class="mx-1 no-min-width px-2" class="mx-1 no-min-width px-2"
mat-button mat-button
@@ -115,23 +201,37 @@
<button <button
*ngIf="hasPermissionToImpersonateAllUsers" *ngIf="hasPermissionToImpersonateAllUsers"
mat-menu-item mat-menu-item
(click)="onImpersonateUser(userItem.id)" (click)="onImpersonateUser(element.id)"
> >
<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"></ion-icon>
<span i18n>Impersonate User</span> <span i18n>Impersonate User</span>
</span>
</button> </button>
<button <button
mat-menu-item mat-menu-item
[disabled]="userItem.id === user?.id" [disabled]="element.id === user?.id"
(click)="onDeleteUser(userItem.id)" (click)="onDeleteUser(element.id)"
> >
<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"></ion-icon>
<span i18n>Delete User</span> <span i18n>Delete User</span>
</span>
</button> </button>
</mat-menu> </mat-menu>
</td> </td>
</tr> </ng-container>
</tbody>
<tr
*matHeaderRowDef="displayedColumns"
class="mat-mdc-header-row"
mat-header-row
></tr>
<tr
*matRowDef="let row; columns: displayedColumns"
class="mat-mdc-row"
mat-row
></tr>
</table> </table>
</div> </div>
</div> </div>

View File

@@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatTableModule } from '@angular/material/table';
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator'; import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
import { GfValueModule } from '@ghostfolio/ui/value'; import { GfValueModule } from '@ghostfolio/ui/value';
@@ -15,7 +16,8 @@ import { AdminUsersComponent } from './admin-users.component';
GfPremiumIndicatorModule, GfPremiumIndicatorModule,
GfValueModule, GfValueModule,
MatButtonModule, MatButtonModule,
MatMenuModule MatMenuModule,
MatTableModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })

View File

@@ -1,6 +1,5 @@
<button <button
*ngIf="deviceType === 'mobile'" *ngIf="deviceType === 'mobile'"
class="mt-2"
mat-button mat-button
(click)="onClickCloseButton()" (click)="onClickCloseButton()"
> >

View File

@@ -1,7 +1,9 @@
:host { :host {
display: flex; display: flex;
flex: 0 0 auto; flex: 0 0 auto;
margin-bottom: 0;
min-height: 0; min-height: 0;
@media (min-width: 576px) {
padding: 0 !important; padding: 0 !important;
}
} }

View File

@@ -131,6 +131,9 @@
<gf-assistant <gf-assistant
#assistant #assistant
[deviceType]="deviceType" [deviceType]="deviceType"
[hasPermissionToAccessAdminControl]="
hasPermissionToAccessAdminControl
"
(closed)="closeAssistant()" (closed)="closeAssistant()"
/> />
</mat-menu> </mat-menu>

View File

@@ -22,7 +22,7 @@
} }
.mdc-button { .mdc-button {
height: unset; height: 100%;
&:not(.mat-primary) { &:not(.mat-primary) {
background-color: transparent; background-color: transparent;

View File

@@ -81,8 +81,6 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
.subscribe((impersonationId) => { .subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId; this.hasImpersonationId = !!impersonationId;
}); });
this.update();
} }
public onChangeDateRange(dateRange: DateRange) { public onChangeDateRange(dateRange: DateRange) {

View File

@@ -1,5 +1,5 @@
<div class="container justify-content-center p-3"> <div class="container justify-content-center p-3">
<div *ngIf="user.settings.viewMode !== 'ZEN'" class="mb-3 text-center"> <div 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

@@ -155,16 +155,18 @@
[isDate]="true" [isDate]="true"
[locale]="data.locale" [locale]="data.locale"
[value]="firstBuyDate" [value]="firstBuyDate"
>First Buy Date</gf-value >First Activity</gf-value
> >
</div> </div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value <gf-value
i18n
size="medium" size="medium"
[locale]="data.locale" [locale]="data.locale"
[value]="transactionCount" [value]="transactionCount"
>Transactions</gf-value ><ng-container *ngIf="transactionCount === 1">Activity</ng-container
><ng-container *ngIf="transactionCount !== 1"
>Activities</ng-container
></gf-value
> >
</div> </div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">

View File

@@ -4,7 +4,9 @@ import {
Inject, Inject,
OnDestroy OnDestroy
} from '@angular/core'; } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces'; import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
@@ -17,19 +19,36 @@ import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
templateUrl: 'create-or-update-access-dialog.html' templateUrl: 'create-or-update-access-dialog.html'
}) })
export class CreateOrUpdateAccessDialog implements OnDestroy { export class CreateOrUpdateAccessDialog implements OnDestroy {
public accessForm: FormGroup;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateAccessDialogParams,
public dialogRef: MatDialogRef<CreateOrUpdateAccessDialog>, public dialogRef: MatDialogRef<CreateOrUpdateAccessDialog>,
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateAccessDialogParams private formBuilder: FormBuilder
) {} ) {}
ngOnInit() {} ngOnInit() {
this.accessForm = this.formBuilder.group({
alias: [this.data.access.alias],
type: [this.data.access.type, Validators.required]
});
}
public onCancel() { public onCancel() {
this.dialogRef.close(); this.dialogRef.close();
} }
public onSubmit() {
const access: CreateAccessDto = {
alias: this.accessForm.controls['alias'].value,
type: this.accessForm.controls['type'].value
};
this.dialogRef.close({ access });
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();

View File

@@ -1,33 +1,38 @@
<form #addAccessForm="ngForm" class="d-flex flex-column h-100"> <form
class="d-flex flex-column h-100"
[formGroup]="accessForm"
(keyup.enter)="accessForm.valid && onSubmit()"
(ngSubmit)="onSubmit()"
>
<h1 i18n mat-dialog-title>Grant access</h1> <h1 i18n mat-dialog-title>Grant access</h1>
<div class="flex-grow-1 py-3" mat-dialog-content> <div class="flex-grow-1 py-3" mat-dialog-content>
<div> <div>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Alias</mat-label> <mat-label i18n>Alias</mat-label>
<input <input
formControlName="alias"
matInput matInput
name="alias"
type="text" type="text"
[(ngModel)]="data.access.alias" (keydown.enter)="$event.stopPropagation()"
/> />
</mat-form-field> </mat-form-field>
</div> </div>
<div> <div>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Type</mat-label> <mat-label i18n>Type</mat-label>
<mat-select name="type" required [(value)]="data.access.type"> <mat-select formControlName="type">
<mat-option i18n value="PUBLIC">Public</mat-option> <mat-option i18n value="PUBLIC">Public</mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
</div> </div>
<div class="justify-content-end" mat-dialog-actions> <div class="justify-content-end" mat-dialog-actions>
<button i18n mat-button (click)="onCancel()">Cancel</button> <button i18n mat-button type="button" (click)="onCancel()">Cancel</button>
<button <button
color="primary" color="primary"
mat-flat-button mat-flat-button
[disabled]="!addAccessForm.form.valid" type="submit"
[mat-dialog-close]="data" [disabled]="!accessForm.valid"
> >
<ng-container i18n>Save</ng-container> <ng-container i18n>Save</ng-container>
</button> </button>

View File

@@ -10,8 +10,18 @@
</h1> </h1>
<gf-access-table <gf-access-table
[accesses]="accesses" [accesses]="accesses"
[hasPermissionToCreateAccess]="hasPermissionToCreateAccess"
[showActions]="hasPermissionToDeleteAccess" [showActions]="hasPermissionToDeleteAccess"
(accessDeleted)="onDeleteAccess($event)" (accessDeleted)="onDeleteAccess($event)"
></gf-access-table> ></gf-access-table>
<div *ngIf="hasPermissionToCreateAccess" class="fab-container">
<a
class="align-items-center d-flex justify-content-center"
color="primary"
mat-fab
[queryParams]="{ createDialog: true }"
[routerLink]="[]"
>
<ion-icon name="add-outline" size="large"></ion-icon>
</a>
</div>
</div> </div>

View File

@@ -1,5 +1,6 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfPortfolioAccessTableModule } from '@ghostfolio/client/components/access-table/access-table.module'; import { GfPortfolioAccessTableModule } from '@ghostfolio/client/components/access-table/access-table.module';
@@ -16,6 +17,7 @@ import { UserAccountAccessComponent } from './user-account-access.component';
GfCreateOrUpdateAccessDialogModule, GfCreateOrUpdateAccessDialogModule,
GfPortfolioAccessTableModule, GfPortfolioAccessTableModule,
GfPremiumIndicatorModule, GfPremiumIndicatorModule,
MatButtonModule,
MatDialogModule, MatDialogModule,
RouterModule RouterModule
] ]

View File

@@ -1,23 +1,15 @@
<div class="container"> <div class="align-items-center container d-flex h-100 justify-content-center">
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Membership</h1> <div class="row w-100">
<div class="row">
<div class="col"> <div class="col">
<div class="d-flex"> <div class="align-items-center d-flex flex-column">
<div class="mx-auto"> <gf-membership-card
<div class="align-items-center d-flex mb-1"> [expiresAt]="user?.subscription?.expiresAt | date: defaultDateFormat"
<a [routerLink]="routerLinkPricing" [name]="user?.subscription?.type"
>{{ user?.subscription?.type }}</a ></gf-membership-card>
<div
*ngIf="user?.subscription?.type === 'Basic'"
class="d-flex flex-column mt-5"
> >
<gf-premium-indicator
*ngIf="user?.subscription?.type === 'Premium'"
class="ml-1"
></gf-premium-indicator>
</div>
<div *ngIf="user?.subscription?.type === 'Premium'">
<ng-container i18n>Valid until</ng-container> {{
user?.subscription?.expiresAt | date: defaultDateFormat }}
</div>
<div *ngIf="user?.subscription?.type === 'Basic'">
<ng-container <ng-container
*ngIf="hasPermissionForSubscription && hasPermissionToUpdateUserSettings" *ngIf="hasPermissionForSubscription && hasPermissionToUpdateUserSettings"
> >
@@ -29,7 +21,7 @@
>Renew</ng-container >Renew</ng-container
> >
</button> </button>
<div *ngIf="price" class="mt-1"> <div *ngIf="price" class="mt-1 text-center">
<ng-container *ngIf="coupon" <ng-container *ngIf="coupon"
><del class="text-muted" ><del class="text-muted"
>{{ baseCurrency }}&nbsp;{{ price }}</del >{{ baseCurrency }}&nbsp;{{ price }}</del
@@ -41,9 +33,10 @@
>&nbsp;<span i18n>per year</span> >&nbsp;<span i18n>per year</span>
</div> </div>
</ng-container> </ng-container>
<div class="align-items-center d-flex justify-content-center mt-4">
<a <a
*ngIf="!user?.subscription?.expiresAt" *ngIf="!user?.subscription?.expiresAt"
class="mr-2 my-2" class="mx-1"
mat-stroked-button mat-stroked-button
[href]="trySubscriptionMail" [href]="trySubscriptionMail"
><span i18n>Try Premium</span> ><span i18n>Try Premium</span>
@@ -54,7 +47,7 @@
></a> ></a>
<a <a
*ngIf="hasPermissionToUpdateUserSettings" *ngIf="hasPermissionToUpdateUserSettings"
class="mr-2 my-2" class="mx-1"
i18n i18n
mat-stroked-button mat-stroked-button
[routerLink]="" [routerLink]=""

View File

@@ -3,6 +3,7 @@ import { NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfMembershipCardModule } from '@ghostfolio/ui/membership-card';
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator'; import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
import { GfValueModule } from '@ghostfolio/ui/value'; import { GfValueModule } from '@ghostfolio/ui/value';
@@ -13,6 +14,7 @@ import { UserAccountMembershipComponent } from './user-account-membership.compon
exports: [UserAccountMembershipComponent], exports: [UserAccountMembershipComponent],
imports: [ imports: [
CommonModule, CommonModule,
GfMembershipCardModule,
GfPremiumIndicatorModule, GfPremiumIndicatorModule,
GfValueModule, GfValueModule,
MatButtonModule, MatButtonModule,

View File

@@ -1,6 +1,7 @@
:host { :host {
color: rgb(var(--dark-primary-text)); color: rgb(var(--dark-primary-text));
display: block; display: block;
height: 100%;
} }
:host-context(.is-dark-theme) { :host-context(.is-dark-theme) {

View File

@@ -3,10 +3,9 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
OnDestroy, OnDestroy,
OnInit, OnInit
ViewChild
} from '@angular/core'; } from '@angular/core';
import { MatCheckbox, MatCheckboxChange } from '@angular/material/checkbox'; import { MatSlideToggleChange } from '@angular/material/slide-toggle';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { import {
STAY_SIGNED_IN, STAY_SIGNED_IN,
@@ -29,14 +28,12 @@ import { catchError, takeUntil } from 'rxjs/operators';
templateUrl: './user-account-settings.html' templateUrl: './user-account-settings.html'
}) })
export class UserAccountSettingsComponent implements OnDestroy, OnInit { export class UserAccountSettingsComponent implements OnDestroy, OnInit {
@ViewChild('toggleSignInWithFingerprintEnabledElement')
signInWithFingerprintElement: MatCheckbox;
public appearancePlaceholder = $localize`Auto`; public appearancePlaceholder = $localize`Auto`;
public baseCurrency: string; public baseCurrency: string;
public currencies: string[] = []; public currencies: string[] = [];
public hasPermissionToUpdateViewMode: boolean; public hasPermissionToUpdateViewMode: boolean;
public hasPermissionToUpdateUserSettings: boolean; public hasPermissionToUpdateUserSettings: boolean;
public isWebAuthnEnabled: boolean;
public language = document.documentElement.lang; public language = document.documentElement.lang;
public locales = [ public locales = [
'de', 'de',
@@ -120,7 +117,7 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit {
}); });
} }
public onExperimentalFeaturesChange(aEvent: MatCheckboxChange) { public onExperimentalFeaturesChange(aEvent: MatSlideToggleChange) {
this.dataService this.dataService
.putUserSetting({ isExperimentalFeatures: aEvent.checked }) .putUserSetting({ isExperimentalFeatures: aEvent.checked })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
@@ -158,7 +155,7 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit {
}); });
} }
public onRestrictedViewChange(aEvent: MatCheckboxChange) { public onRestrictedViewChange(aEvent: MatSlideToggleChange) {
this.dataService this.dataService
.putUserSetting({ isRestrictedView: aEvent.checked }) .putUserSetting({ isRestrictedView: aEvent.checked })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
@@ -176,7 +173,7 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit {
}); });
} }
public onSignInWithFingerprintChange(aEvent: MatCheckboxChange) { public onSignInWithFingerprintChange(aEvent: MatSlideToggleChange) {
if (aEvent.checked) { if (aEvent.checked) {
this.registerDevice(); this.registerDevice();
} else { } else {
@@ -192,7 +189,7 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit {
} }
} }
public onViewModeChange(aEvent: MatCheckboxChange) { public onViewModeChange(aEvent: MatSlideToggleChange) {
this.dataService this.dataService
.putUserSetting({ viewMode: aEvent.checked === true ? 'ZEN' : 'DEFAULT' }) .putUserSetting({ viewMode: aEvent.checked === true ? 'ZEN' : 'DEFAULT' })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
@@ -250,9 +247,8 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit {
} }
private update() { private update() {
if (this.signInWithFingerprintElement) { this.isWebAuthnEnabled = this.webAuthnService.isEnabled() ?? false;
this.signInWithFingerprintElement.checked =
this.webAuthnService.isEnabled() ?? false; this.changeDetectorRef.markForCheck();
}
} }
} }

View File

@@ -11,12 +11,13 @@
</div> </div>
</div> </div>
<div class="pl-1 w-50"> <div class="pl-1 w-50">
<mat-checkbox <mat-slide-toggle
color="primary" color="primary"
hideIcon="true"
[checked]="user.settings.isRestrictedView" [checked]="user.settings.isRestrictedView"
[disabled]="!hasPermissionToUpdateUserSettings" [disabled]="!hasPermissionToUpdateUserSettings"
(change)="onRestrictedViewChange($event)" (change)="onRestrictedViewChange($event)"
></mat-checkbox> ></mat-slide-toggle>
</div> </div>
</div> </div>
<div class="d-flex mt-4 py-1"> <div class="d-flex mt-4 py-1">
@@ -139,12 +140,13 @@
</div> </div>
</div> </div>
<div class="pl-1 w-50"> <div class="pl-1 w-50">
<mat-checkbox <mat-slide-toggle
color="primary" color="primary"
hideIcon="true"
[checked]="user.settings.viewMode === 'ZEN'" [checked]="user.settings.viewMode === 'ZEN'"
[disabled]="!hasPermissionToUpdateViewMode" [disabled]="!hasPermissionToUpdateViewMode"
(change)="onViewModeChange($event)" (change)="onViewModeChange($event)"
></mat-checkbox> ></mat-slide-toggle>
</div> </div>
</div> </div>
<div class="align-items-center d-flex mt-4 py-1"> <div class="align-items-center d-flex mt-4 py-1">
@@ -153,12 +155,13 @@
<div class="hint-text text-muted" i18n>Sign in with fingerprint</div> <div class="hint-text text-muted" i18n>Sign in with fingerprint</div>
</div> </div>
<div class="pl-1 w-50"> <div class="pl-1 w-50">
<mat-checkbox <mat-slide-toggle
#toggleSignInWithFingerprintEnabledElement
color="primary" color="primary"
hideIcon="true"
[checked]="isWebAuthnEnabled === true"
[disabled]="!hasPermissionToUpdateUserSettings" [disabled]="!hasPermissionToUpdateUserSettings"
(change)="onSignInWithFingerprintChange($event)" (change)="onSignInWithFingerprintChange($event)"
></mat-checkbox> ></mat-slide-toggle>
</div> </div>
</div> </div>
<div <div
@@ -172,12 +175,13 @@
</div> </div>
</div> </div>
<div class="pl-1 w-50"> <div class="pl-1 w-50">
<mat-checkbox <mat-slide-toggle
color="primary" color="primary"
hideIcon="true"
[checked]="user.settings.isExperimentalFeatures" [checked]="user.settings.isExperimentalFeatures"
[disabled]="!hasPermissionToUpdateUserSettings" [disabled]="!hasPermissionToUpdateUserSettings"
(change)="onExperimentalFeaturesChange($event)" (change)="onExperimentalFeaturesChange($event)"
></mat-checkbox> ></mat-slide-toggle>
</div> </div>
</div> </div>
<div class="align-items-center d-flex mt-4 py-1"> <div class="align-items-center d-flex mt-4 py-1">

View File

@@ -3,9 +3,9 @@ import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfValueModule } from '@ghostfolio/ui/value'; import { GfValueModule } from '@ghostfolio/ui/value';
@@ -20,9 +20,9 @@ import { UserAccountSettingsComponent } from './user-account-settings.component'
GfValueModule, GfValueModule,
MatButtonModule, MatButtonModule,
MatCardModule, MatCardModule,
MatCheckboxModule,
MatFormFieldModule, MatFormFieldModule,
MatSelectModule, MatSelectModule,
MatSlideToggleModule,
ReactiveFormsModule, ReactiveFormsModule,
RouterModule RouterModule
] ]

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