Compare commits
143 Commits
Author | SHA1 | Date | |
---|---|---|---|
7c8530483c | |||
539d3ff754 | |||
9d28b63da6 | |||
24abbd85e6 | |||
b6f395fd3b | |||
04d894cf88 | |||
b4d2c4109e | |||
823093f4d7 | |||
56bf422407 | |||
df0e9ad03b | |||
0e3702c2be | |||
11136ae4f8 | |||
2e6a7d5a91 | |||
83845c256a | |||
34c9703716 | |||
48903238c5 | |||
57a14bd945 | |||
4fd0622114 | |||
52f0fb5ab8 | |||
20195b2b1a | |||
7fa4e6ebd2 | |||
d8531ddfcb | |||
70d670b711 | |||
27b0663a80 | |||
874dfb0235 | |||
072db0d558 | |||
12e692429a | |||
e22b8b78b8 | |||
dc5052f7dc | |||
335553e891 | |||
d480ad1023 | |||
7320751056 | |||
108c0c13c4 | |||
053a5cc5b5 | |||
c456a8bcfe | |||
6fcecb5bc6 | |||
e4e0a7d9f0 | |||
c7173761a3 | |||
185e130d9f | |||
81245635af | |||
55182ac1af | |||
0b446a30ae | |||
c5e6602102 | |||
573038f407 | |||
dbc38e705e | |||
f127e7c61a | |||
4ccabde251 | |||
86ae88f90f | |||
69bc1d67e1 | |||
03942aecda | |||
7ec9170c0d | |||
51431a7fb2 | |||
4adda6783d | |||
d5cd4c0dea | |||
34be10d755 | |||
51f586e160 | |||
ff64a00196 | |||
148f6f8762 | |||
bf2c4d1e9e | |||
eee1f1c722 | |||
9f2a49a1c7 | |||
44058b2d7a | |||
23634f3404 | |||
f93dab6086 | |||
207859cc22 | |||
77181aaaff | |||
412039badf | |||
7619442895 | |||
61ecd66e0f | |||
81217b35ef | |||
678f1f0051 | |||
71c7e37b5a | |||
80459371f3 | |||
35f1f348a8 | |||
0bb0b12991 | |||
d887de50d2 | |||
2571e5b8c0 | |||
e444d717e5 | |||
1866e26c1d | |||
9923074e04 | |||
c367e61b85 | |||
364f1ad9b9 | |||
2394cbd6fe | |||
a74d5cce20 | |||
95bcc3f32d | |||
e9dbd4a55d | |||
d440b09dc9 | |||
cc16ba5dc8 | |||
d10227bc39 | |||
4e214c32e8 | |||
49e2862e03 | |||
34e33a2400 | |||
ec9bc984af | |||
2388c494df | |||
d71ab10eed | |||
0e0592180f | |||
60e2aff488 | |||
7b5454e7de | |||
30835ced88 | |||
8897f32bc5 | |||
abaa6b5f27 | |||
2060fcaf0b | |||
fd2408dd62 | |||
31cca024f1 | |||
b535122945 | |||
5113e4e3ad | |||
35e039748f | |||
c6b9e0aa5b | |||
b250491ca5 | |||
61e501c659 | |||
c0f19d56ec | |||
8e2b235b1f | |||
c3407e9b34 | |||
74193e4ee2 | |||
3fe8f9c882 | |||
d130efad47 | |||
109f0ebd70 | |||
069ddcc6b2 | |||
f7bf6e652b | |||
eb059a024a | |||
ad88acff1c | |||
1ff736537c | |||
1fa65e1efd | |||
df6bb489c2 | |||
928a13310d | |||
2384861953 | |||
fe90bda6fb | |||
d4b29ff11c | |||
a0a26cfa58 | |||
1610150427 | |||
cff8acd7b1 | |||
0f36d6cbdb | |||
046e28b521 | |||
aba562cb35 | |||
03f2f33344 | |||
a996dd7ed5 | |||
002b883668 | |||
0b06823893 | |||
2dfd779444 | |||
1824413379 | |||
3332ade3d3 | |||
8d2e110e3d | |||
a8fcf09380 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -24,15 +24,16 @@
|
|||||||
|
|
||||||
# misc
|
# misc
|
||||||
/.angular/cache
|
/.angular/cache
|
||||||
|
.env.prod
|
||||||
/.sass-cache
|
/.sass-cache
|
||||||
/connect.lock
|
/connect.lock
|
||||||
/coverage
|
/coverage
|
||||||
/dist
|
/dist
|
||||||
/libpeerconnection.log
|
/libpeerconnection.log
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
yarn-error.log
|
|
||||||
testem.log
|
testem.log
|
||||||
/typings
|
/typings
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
# System Files
|
# System Files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
@ -2,7 +2,7 @@ language: node_js
|
|||||||
git:
|
git:
|
||||||
depth: false
|
depth: false
|
||||||
node_js:
|
node_js:
|
||||||
- 14
|
- 16
|
||||||
|
|
||||||
services:
|
services:
|
||||||
- docker
|
- docker
|
||||||
|
314
CHANGELOG.md
314
CHANGELOG.md
@ -5,6 +5,320 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## 1.183.0 - 24.08.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a filter by asset sub class for the asset profiles in the admin control
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
|
||||||
|
## 1.182.0 - 23.08.2022
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the language localization for German (`de`)
|
||||||
|
- Extended and made the columns of the asset profiles sortable in the admin control
|
||||||
|
- Moved the asset profile details in the admin control panel to a dialog
|
||||||
|
|
||||||
|
## 1.181.2 - 21.08.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a language selector to the account page
|
||||||
|
- Added support for translated labels in the value component
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Integrated the commands `database:setup` and `database:migrate` into the container start
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed a division by zero error in the benchmarks calculation
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply manual data migration (`yarn database:migrate`) is not needed anymore
|
||||||
|
|
||||||
|
## 1.180.1 - 18.08.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Set up `ng-extract-i18n-merge` to improve the i18n extraction and merge workflow
|
||||||
|
- Set up language localization for German (`de`)
|
||||||
|
- Resolved the feature graphic of the blog post
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Tagged template literal strings in components for localization with `$localize`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the license component in the about page
|
||||||
|
- Fixed the links to the blog posts
|
||||||
|
|
||||||
|
## 1.179.5 - 15.08.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Set up i18n support
|
||||||
|
- Added a blog post: _500 Stars on GitHub_
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Reduced the maximum width of the performance chart on the home page
|
||||||
|
|
||||||
|
## 1.178.0 - 09.08.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added `url` to the symbol profile overrides model for manual adjustments
|
||||||
|
- Added default values for `countries` and `sectors` of the symbol profile overrides model
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Simplified the initialization of the exchange rate service
|
||||||
|
- Improved the orders query for `assetClass` with symbol profile overrides
|
||||||
|
- Improved the styling of the benchmarks in the markets overview
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
|
## 1.177.0 - 04.08.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added `GHOSTFOLIO` as a default to `DATA_SOURCES`
|
||||||
|
- Added the `AGPLv3` logo to the landing page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Refactored the initialization of the exchange rate service
|
||||||
|
- Upgraded `angular` from version `14.0.2` to `14.1.0`
|
||||||
|
- Upgraded `nestjs` from version `8.4.7` to `9.0.7`
|
||||||
|
- Upgraded `Nx` from version `14.3.5` to `14.5.1`
|
||||||
|
- Upgraded `prisma` from version `3.15.2` to `4.1.1`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Handled database connection errors (do not exit process)
|
||||||
|
|
||||||
|
## 1.176.2 - 31.07.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added page titles
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the performance of data provider requests by introducing a maximum number of symbols per request (chunk size)
|
||||||
|
- Changed the log level settings
|
||||||
|
- Refactored the access of the environment variables in the bootstrap function (api)
|
||||||
|
- Upgraded `Node.js` from version `14` to `16` (`Dockerfile`)
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Upgrade to `Node.js` 16+
|
||||||
|
|
||||||
|
## 1.175.0 - 29.07.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Set up a Frequently Asked Questions (FAQ) page
|
||||||
|
- Added the savings rate to the investment timeline grouped by month
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Added the symbols to the activities in the account detail dialog
|
||||||
|
|
||||||
|
## 1.174.0 - 27.07.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Support a note for activities
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
|
## 1.173.0 - 23.07.2022
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with the currency inconsistency in the _Yahoo Finance_ service (convert from `USX` to `USD`)
|
||||||
|
|
||||||
|
## 1.172.0 - 23.07.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a blog post: _Ghostfolio meets Internet Identity_
|
||||||
|
|
||||||
|
## 1.171.0 - 22.07.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added _Internet Identity_ as a new social login provider
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the empty state of the
|
||||||
|
- _Analysis_ section
|
||||||
|
- _Holdings_ section
|
||||||
|
- performance chart on the home page
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the distorted tooltip in the performance chart on the home page
|
||||||
|
- Fixed a calculation issue of the current month in the investment timeline grouped by month
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
|
## 1.170.0 - 19.07.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for the tags in the create or edit transaction dialog
|
||||||
|
- Added support for the cryptocurrency _TerraUSD_ (`UST-USD`)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Removed the alias from the user interface as a preparation to remove it from the `User` database schema
|
||||||
|
- Removed the activities import limit for users with a subscription
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Rename the environment variable from `MAX_ORDERS_TO_IMPORT` to `MAX_ACTIVITIES_TO_IMPORT`
|
||||||
|
|
||||||
|
## 1.169.0 - 14.07.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for the cryptocurrency _Songbird_ (`SGB1-USD`)
|
||||||
|
- Added support for the cryptocurrency _Terra 2.0_ (`LUNA2-USD`)
|
||||||
|
- Added a blog post
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Refreshed the cryptocurrencies list to support more coins by default
|
||||||
|
- Upgraded `date-fns` from version `2.22.1` to `2.28.0`
|
||||||
|
|
||||||
|
## 1.168.0 - 10.07.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the investment timeline grouped by month
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Handled an occasional currency pair inconsistency in the _Yahoo Finance_ service (`GBP=X` instead of `USDGBP=X`)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the content height of the account detail dialog
|
||||||
|
|
||||||
|
## 1.167.0 - 07.07.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added _Markets_ to the public pages
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the _Create Account_ link in the _Live Demo_
|
||||||
|
- Upgraded `ngx-markdown` from version `13.0.0` to `14.0.1`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue in the _Holdings_ section for users without a subscription
|
||||||
|
|
||||||
|
## 1.166.0 - 30.06.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added an account detail dialog
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the label of the (symbol) search
|
||||||
|
- Refactored the demo account as a route (`/demo`)
|
||||||
|
- Upgraded `nestjs` from version `8.2.3` to `8.4.7`
|
||||||
|
- Upgraded `prisma` from version `3.14.0` to `3.15.2`
|
||||||
|
- Upgraded `yahoo-finance2` from version `2.3.2` to `2.3.3`
|
||||||
|
- Upgraded `zone.js` from version `0.11.4` to `0.11.6`
|
||||||
|
|
||||||
|
## 1.165.0 - 25.06.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added an icon and name column to the positions table
|
||||||
|
- Added a reusable premium indicator component
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Moved the positions table to a dedicated section (_Holdings_)
|
||||||
|
- Changed the data gathering by symbol endpoint to delete data first
|
||||||
|
|
||||||
|
## 1.164.0 - 23.06.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the positions table including performance to the public page
|
||||||
|
|
||||||
|
## 1.163.0 - 22.06.2022
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the onboarding for iOS
|
||||||
|
|
||||||
|
## 1.162.0 - 18.06.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a _Privacy Policy_ page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Simplified the header
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with the currency inconsistency in the _Yahoo Finance_ service (convert from `ILA` to `ILS`)
|
||||||
|
|
||||||
|
## 1.161.1 - 16.06.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the vertical hover line to inspect data points in the performance chart on the home page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the landing page
|
||||||
|
- Upgraded `angular` from version `13.3.6` to `14.0.2`
|
||||||
|
- Upgraded `Nx` from version `14.1.4` to `14.3.5`
|
||||||
|
- Upgraded `storybook` from version `6.4.22` to `6.5.9`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Improved the error handling of missing market prices
|
||||||
|
|
||||||
|
## 1.160.0 - 15.06.2022
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the `No data provider has been found` error in the search (regression after `envalid` upgrade to `7.3.1` in Ghostfolio `1.157.0`)
|
||||||
|
|
||||||
|
## 1.159.0 - 15.06.2022
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Changed the default `HOST` to `0.0.0.0`
|
||||||
|
- Refactored the endpoint of the public page (filter by equity)
|
||||||
|
|
||||||
## 1.158.1 - 12.06.2022
|
## 1.158.1 - 12.06.2022
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
10
Dockerfile
10
Dockerfile
@ -1,4 +1,4 @@
|
|||||||
FROM node:14-alpine as builder
|
FROM node:16-alpine as builder
|
||||||
|
|
||||||
# Build application and add additional files
|
# Build application and add additional files
|
||||||
|
|
||||||
@ -12,7 +12,7 @@ COPY ./package.json package.json
|
|||||||
COPY ./yarn.lock yarn.lock
|
COPY ./yarn.lock yarn.lock
|
||||||
COPY ./prisma/schema.prisma prisma/schema.prisma
|
COPY ./prisma/schema.prisma prisma/schema.prisma
|
||||||
|
|
||||||
RUN apk add --no-cache python3 g++ make openssl
|
RUN apk add --no-cache python3 g++ make openssl git
|
||||||
RUN yarn install
|
RUN yarn install
|
||||||
|
|
||||||
# See https://github.com/nrwl/nx/issues/6586 for further details
|
# See https://github.com/nrwl/nx/issues/6586 for further details
|
||||||
@ -22,7 +22,7 @@ RUN node decorate-angular-cli.js
|
|||||||
COPY ./angular.json angular.json
|
COPY ./angular.json angular.json
|
||||||
COPY ./nx.json nx.json
|
COPY ./nx.json nx.json
|
||||||
COPY ./replace.build.js replace.build.js
|
COPY ./replace.build.js replace.build.js
|
||||||
COPY ./jest.preset.ts jest.preset.ts
|
COPY ./jest.preset.js jest.preset.js
|
||||||
COPY ./jest.config.ts jest.config.ts
|
COPY ./jest.config.ts jest.config.ts
|
||||||
COPY ./tsconfig.base.json tsconfig.base.json
|
COPY ./tsconfig.base.json tsconfig.base.json
|
||||||
COPY ./libs libs
|
COPY ./libs libs
|
||||||
@ -45,8 +45,8 @@ COPY package.json /ghostfolio/dist/apps/api
|
|||||||
RUN yarn database:generate-typings
|
RUN yarn database:generate-typings
|
||||||
|
|
||||||
# Image to run, copy everything needed from builder
|
# Image to run, copy everything needed from builder
|
||||||
FROM node:14-alpine
|
FROM node:16-alpine
|
||||||
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
||||||
WORKDIR /ghostfolio/apps/api
|
WORKDIR /ghostfolio/apps/api
|
||||||
EXPOSE 3333
|
EXPOSE 3333
|
||||||
CMD [ "node", "main" ]
|
CMD [ "yarn", "start:prod" ]
|
||||||
|
45
README.md
45
README.md
@ -12,7 +12,7 @@
|
|||||||
<strong>Open Source Wealth Management Software</strong>
|
<strong>Open Source Wealth Management Software</strong>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/blog"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
|
<a href="https://ghostfol.io"><strong>Ghostfol.io</strong></a> | <a href="https://ghostfol.io/demo"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/faq"><strong>FAQ</strong></a> | <a href="https://ghostfol.io/blog"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a href="#contributing">
|
<a href="#contributing">
|
||||||
@ -35,7 +35,7 @@
|
|||||||
|
|
||||||
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
|
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
|
||||||
|
|
||||||
If you prefer to run Ghostfolio on your own infrastructure (self-hosting), please find further instructions in the section [Run with Docker](#run-with-docker-self-hosting).
|
If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the [Self-hosting](#self-hosting) section.
|
||||||
|
|
||||||
## Why Ghostfolio?
|
## Why Ghostfolio?
|
||||||
|
|
||||||
@ -81,6 +81,23 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
|
|||||||
|
|
||||||
## Self-hosting
|
## Self-hosting
|
||||||
|
|
||||||
|
### Supported Environment Variables
|
||||||
|
|
||||||
|
| Name | Default Value | Description |
|
||||||
|
| ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
|
||||||
|
| `BASE_CURRENCY` | `USD` | The base currency of the Ghostfolio application. Caution: This cannot be changed later! |
|
||||||
|
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
||||||
|
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
||||||
|
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
|
||||||
|
| `PORT` | `3333` | The port where the Ghostfolio application will run on |
|
||||||
|
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
|
||||||
|
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
|
||||||
|
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
|
||||||
|
| `REDIS_HOST` | `localhost` | The host where _Redis_ is running |
|
||||||
|
| `REDIS_PASSWORD` | | The password of _Redis_ |
|
||||||
|
| `REDIS_PORT` | `6379` | The port where _Redis_ is running |
|
||||||
|
|
||||||
### Run with Docker Compose
|
### Run with Docker Compose
|
||||||
|
|
||||||
#### Prerequisites
|
#### Prerequisites
|
||||||
@ -97,14 +114,6 @@ Run the following command to start the Docker images from [Docker Hub](https://h
|
|||||||
docker-compose --env-file ./.env -f docker/docker-compose.yml up -d
|
docker-compose --env-file ./.env -f docker/docker-compose.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
##### Setup Database
|
|
||||||
|
|
||||||
Run the following command to setup the database once Ghostfolio is running:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker-compose --env-file ./.env -f docker/docker-compose.yml exec ghostfolio yarn database:setup
|
|
||||||
```
|
|
||||||
|
|
||||||
#### b. Build and run environment
|
#### b. Build and run environment
|
||||||
|
|
||||||
Run the following commands to build and start the Docker images:
|
Run the following commands to build and start the Docker images:
|
||||||
@ -114,14 +123,6 @@ docker-compose --env-file ./.env -f docker/docker-compose.build.yml build
|
|||||||
docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
##### Setup Database
|
|
||||||
|
|
||||||
Run the following command to setup the database once Ghostfolio is running:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker-compose --env-file ./.env -f docker/docker-compose.build.yml exec ghostfolio yarn database:setup
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Fetch Historical Data
|
#### Fetch Historical Data
|
||||||
|
|
||||||
Open http://localhost:3333 in your browser and accomplish these steps:
|
Open http://localhost:3333 in your browser and accomplish these steps:
|
||||||
@ -134,9 +135,9 @@ Open http://localhost:3333 in your browser and accomplish these steps:
|
|||||||
|
|
||||||
1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
|
1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
|
||||||
1. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d`
|
1. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d`
|
||||||
1. Then, run the following command to keep your database schema in sync: `docker-compose --env-file ./.env -f docker/docker-compose.yml exec ghostfolio yarn database:migrate`
|
At each start, the container will automatically apply the database schema migrations if needed.
|
||||||
|
|
||||||
### Run with _Unraid_ (unofficial)
|
### Run with _Unraid_ (Community)
|
||||||
|
|
||||||
Please follow the instructions of the Ghostfolio [Unraid Community App](https://unraid.net/community/apps?q=ghostfolio).
|
Please follow the instructions of the Ghostfolio [Unraid Community App](https://unraid.net/community/apps?q=ghostfolio).
|
||||||
|
|
||||||
@ -145,7 +146,7 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
|
|||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||||
- [Node.js](https://nodejs.org/en/download) (version 14+)
|
- [Node.js](https://nodejs.org/en/download) (version 16+)
|
||||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||||
- A local copy of this Git repository (clone)
|
- A local copy of this Git repository (clone)
|
||||||
|
|
||||||
@ -186,7 +187,7 @@ yarn database:push
|
|||||||
|
|
||||||
Run `yarn test`
|
Run `yarn test`
|
||||||
|
|
||||||
## Public API (experimental)
|
## Public API
|
||||||
|
|
||||||
### Import Activities
|
### Import Activities
|
||||||
|
|
||||||
|
80
angular.json
80
angular.json
@ -2,6 +2,7 @@
|
|||||||
"version": 1,
|
"version": 1,
|
||||||
"projects": {
|
"projects": {
|
||||||
"api": {
|
"api": {
|
||||||
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
"root": "apps/api",
|
"root": "apps/api",
|
||||||
"sourceRoot": "apps/api/src",
|
"sourceRoot": "apps/api/src",
|
||||||
"projectType": "application",
|
"projectType": "application",
|
||||||
@ -56,6 +57,7 @@
|
|||||||
"tags": []
|
"tags": []
|
||||||
},
|
},
|
||||||
"client": {
|
"client": {
|
||||||
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
"projectType": "application",
|
"projectType": "application",
|
||||||
"schematics": {
|
"schematics": {
|
||||||
"@schematics/angular:component": {
|
"@schematics/angular:component": {
|
||||||
@ -75,45 +77,49 @@
|
|||||||
"polyfills": "apps/client/src/polyfills.ts",
|
"polyfills": "apps/client/src/polyfills.ts",
|
||||||
"tsConfig": "apps/client/tsconfig.app.json",
|
"tsConfig": "apps/client/tsconfig.app.json",
|
||||||
"assets": [
|
"assets": [
|
||||||
"apps/client/src/assets",
|
|
||||||
{
|
{
|
||||||
"glob": "assetlinks.json",
|
"glob": "assetlinks.json",
|
||||||
"input": "apps/client/src/assets",
|
"input": "apps/client/src/assets",
|
||||||
"output": "./.well-known"
|
"output": "./../.well-known"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"glob": "CHANGELOG.md",
|
"glob": "CHANGELOG.md",
|
||||||
"input": "",
|
"input": "",
|
||||||
"output": "./assets"
|
"output": "./../assets"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"glob": "LICENSE",
|
"glob": "LICENSE",
|
||||||
"input": "",
|
"input": "",
|
||||||
"output": "./assets"
|
"output": "./../assets"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"glob": "robots.txt",
|
"glob": "robots.txt",
|
||||||
"input": "apps/client/src/assets",
|
"input": "apps/client/src/assets",
|
||||||
"output": "./"
|
"output": "./../"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"glob": "sitemap.xml",
|
"glob": "sitemap.xml",
|
||||||
"input": "apps/client/src/assets",
|
"input": "apps/client/src/assets",
|
||||||
"output": "./"
|
"output": "./../"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"glob": "**/*",
|
"glob": "**/*",
|
||||||
"input": "node_modules/ionicons/dist/ionicons",
|
"input": "node_modules/ionicons/dist/ionicons",
|
||||||
"output": "./ionicons"
|
"output": "./../ionicons"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"glob": "**/*.js",
|
"glob": "**/*.js",
|
||||||
"input": "node_modules/ionicons/dist/",
|
"input": "node_modules/ionicons/dist/",
|
||||||
"output": "./"
|
"output": "./../"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "apps/client/src/assets",
|
||||||
|
"output": "./../assets/"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": ["apps/client/src/styles.scss"],
|
"styles": ["apps/client/src/styles.scss"],
|
||||||
"scripts": ["node_modules/marked/lib/marked.js"],
|
"scripts": ["node_modules/marked/marked.min.js"],
|
||||||
"vendorChunk": true,
|
"vendorChunk": true,
|
||||||
"extractLicenses": false,
|
"extractLicenses": false,
|
||||||
"buildOptimizer": false,
|
"buildOptimizer": false,
|
||||||
@ -122,6 +128,14 @@
|
|||||||
"namedChunks": true
|
"namedChunks": true
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
|
"development-de": {
|
||||||
|
"baseHref": "/de/",
|
||||||
|
"localize": ["de"]
|
||||||
|
},
|
||||||
|
"development-en": {
|
||||||
|
"baseHref": "/en/",
|
||||||
|
"localize": ["en"]
|
||||||
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"fileReplacements": [
|
"fileReplacements": [
|
||||||
{
|
{
|
||||||
@ -160,15 +174,24 @@
|
|||||||
"proxyConfig": "apps/client/proxy.conf.json"
|
"proxyConfig": "apps/client/proxy.conf.json"
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
|
"development-de": {
|
||||||
|
"browserTarget": "client:build:development-de"
|
||||||
|
},
|
||||||
|
"development-en": {
|
||||||
|
"browserTarget": "client:build:development-en"
|
||||||
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"browserTarget": "client:build:production"
|
"browserTarget": "client:build:production"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"extract-i18n": {
|
"extract-i18n": {
|
||||||
"builder": "@angular-devkit/build-angular:extract-i18n",
|
"builder": "ng-extract-i18n-merge:ng-extract-i18n-merge",
|
||||||
"options": {
|
"options": {
|
||||||
"browserTarget": "client:build"
|
"browserTarget": "client:build",
|
||||||
|
"includeContext": true,
|
||||||
|
"outputPath": "src/locales",
|
||||||
|
"targetFiles": ["messages.de.xlf"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"lint": {
|
"lint": {
|
||||||
@ -186,9 +209,19 @@
|
|||||||
"outputs": ["coverage/apps/client"]
|
"outputs": ["coverage/apps/client"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"i18n": {
|
||||||
|
"locales": {
|
||||||
|
"de": {
|
||||||
|
"baseHref": "/de/",
|
||||||
|
"translation": "apps/client/src/locales/messages.de.xlf"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sourceLocale": "en"
|
||||||
|
},
|
||||||
"tags": []
|
"tags": []
|
||||||
},
|
},
|
||||||
"client-e2e": {
|
"client-e2e": {
|
||||||
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
"root": "apps/client-e2e",
|
"root": "apps/client-e2e",
|
||||||
"sourceRoot": "apps/client-e2e/src",
|
"sourceRoot": "apps/client-e2e/src",
|
||||||
"projectType": "application",
|
"projectType": "application",
|
||||||
@ -211,6 +244,7 @@
|
|||||||
"implicitDependencies": ["client"]
|
"implicitDependencies": ["client"]
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
"root": "libs/common",
|
"root": "libs/common",
|
||||||
"sourceRoot": "libs/common/src",
|
"sourceRoot": "libs/common/src",
|
||||||
"projectType": "library",
|
"projectType": "library",
|
||||||
@ -233,6 +267,7 @@
|
|||||||
"tags": []
|
"tags": []
|
||||||
},
|
},
|
||||||
"ui": {
|
"ui": {
|
||||||
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
"projectType": "library",
|
"projectType": "library",
|
||||||
"schematics": {
|
"schematics": {
|
||||||
"@schematics/angular:component": {
|
"@schematics/angular:component": {
|
||||||
@ -258,14 +293,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"storybook": {
|
"storybook": {
|
||||||
"builder": "@nrwl/storybook:storybook",
|
"builder": "@storybook/angular:start-storybook",
|
||||||
"options": {
|
"options": {
|
||||||
"uiFramework": "@storybook/angular",
|
|
||||||
"port": 4400,
|
"port": 4400,
|
||||||
"config": {
|
"configDir": "libs/ui/.storybook",
|
||||||
"configFolder": "libs/ui/.storybook"
|
"browserTarget": "ui:build-storybook",
|
||||||
},
|
"compodoc": false
|
||||||
"projectBuildConfig": "ui:build-storybook"
|
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"ci": {
|
"ci": {
|
||||||
@ -274,15 +307,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"build-storybook": {
|
"build-storybook": {
|
||||||
"builder": "@nrwl/storybook:build",
|
"builder": "@storybook/angular:build-storybook",
|
||||||
"outputs": ["{options.outputPath}"],
|
"outputs": ["{options.outputPath}"],
|
||||||
"options": {
|
"options": {
|
||||||
"uiFramework": "@storybook/angular",
|
"outputDir": "dist/storybook/ui",
|
||||||
"outputPath": "dist/storybook/ui",
|
"configDir": "libs/ui/.storybook",
|
||||||
"config": {
|
"browserTarget": "ui:build-storybook",
|
||||||
"configFolder": "libs/ui/.storybook"
|
"compodoc": false
|
||||||
},
|
|
||||||
"projectBuildConfig": "ui:build-storybook"
|
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"ci": {
|
"ci": {
|
||||||
@ -294,6 +325,7 @@
|
|||||||
"tags": []
|
"tags": []
|
||||||
},
|
},
|
||||||
"ui-e2e": {
|
"ui-e2e": {
|
||||||
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
"root": "apps/ui-e2e",
|
"root": "apps/ui-e2e",
|
||||||
"sourceRoot": "apps/ui-e2e/src",
|
"sourceRoot": "apps/ui-e2e/src",
|
||||||
"projectType": "application",
|
"projectType": "application",
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
export default {
|
||||||
displayName: 'api',
|
displayName: 'api',
|
||||||
|
|
||||||
globals: {
|
globals: {
|
||||||
@ -13,5 +13,5 @@ module.exports = {
|
|||||||
coverageDirectory: '../../coverage/apps/api',
|
coverageDirectory: '../../coverage/apps/api',
|
||||||
testTimeout: 10000,
|
testTimeout: 10000,
|
||||||
testEnvironment: 'node',
|
testEnvironment: 'node',
|
||||||
preset: '../../jest.preset.ts'
|
preset: '../../jest.preset.js'
|
||||||
};
|
};
|
||||||
|
@ -7,7 +7,10 @@ import {
|
|||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||||
import { Accounts } from '@ghostfolio/common/interfaces';
|
import { Accounts } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type {
|
||||||
|
AccountWithValue,
|
||||||
|
RequestWithUser
|
||||||
|
} from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@ -123,13 +126,45 @@ export class AccountController {
|
|||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getAccountById(@Param('id') id: string): Promise<AccountModel> {
|
public async getAccountById(
|
||||||
return this.accountService.account({
|
@Headers('impersonation-id') impersonationId,
|
||||||
id_userId: {
|
@Param('id') id: string
|
||||||
id,
|
): Promise<AccountWithValue> {
|
||||||
userId: this.request.user.id
|
const impersonationUserId =
|
||||||
|
await this.impersonationService.validateImpersonationId(
|
||||||
|
impersonationId,
|
||||||
|
this.request.user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
let accountsWithAggregations =
|
||||||
|
await this.portfolioService.getAccountsWithAggregations(
|
||||||
|
impersonationUserId || this.request.user.id,
|
||||||
|
[{ id, type: 'ACCOUNT' }]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
impersonationUserId ||
|
||||||
|
this.userService.isRestrictedView(this.request.user)
|
||||||
|
) {
|
||||||
|
accountsWithAggregations = {
|
||||||
|
...nullifyValuesInObject(accountsWithAggregations, [
|
||||||
|
'totalBalanceInBaseCurrency',
|
||||||
|
'totalValueInBaseCurrency'
|
||||||
|
]),
|
||||||
|
accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [
|
||||||
|
'balance',
|
||||||
|
'balanceInBaseCurrency',
|
||||||
|
'convertedBalance',
|
||||||
|
'fee',
|
||||||
|
'quantity',
|
||||||
|
'unitPrice',
|
||||||
|
'value',
|
||||||
|
'valueInBaseCurrency'
|
||||||
|
])
|
||||||
|
};
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
return accountsWithAggregations.accounts[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@ -8,7 +8,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
AdminData,
|
AdminData,
|
||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
AdminMarketDataDetails
|
AdminMarketDataDetails,
|
||||||
|
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 { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
@ -22,6 +23,7 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
|
Query,
|
||||||
UseGuards
|
UseGuards
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
@ -226,7 +228,9 @@ export class AdminController {
|
|||||||
|
|
||||||
@Get('market-data')
|
@Get('market-data')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getMarketData(): Promise<AdminMarketData> {
|
public async getMarketData(
|
||||||
|
@Query('assetSubClasses') filterByAssetSubClasses?: string
|
||||||
|
): Promise<AdminMarketData> {
|
||||||
if (
|
if (
|
||||||
!hasPermission(
|
!hasPermission(
|
||||||
this.request.user.permissions,
|
this.request.user.permissions,
|
||||||
@ -239,7 +243,18 @@ export class AdminController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.adminService.getMarketData();
|
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
|
||||||
|
|
||||||
|
const filters: Filter[] = [
|
||||||
|
...assetSubClasses.map((assetSubClass) => {
|
||||||
|
return <Filter>{
|
||||||
|
id: assetSubClass,
|
||||||
|
type: 'ASSET_SUB_CLASS'
|
||||||
|
};
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
return this.adminService.getMarketData(filters);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('market-data/:dataSource/:symbol')
|
@Get('market-data/:dataSource/:symbol')
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
@ -12,11 +11,13 @@ import {
|
|||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
AdminMarketDataDetails,
|
AdminMarketDataDetails,
|
||||||
AdminMarketDataItem,
|
AdminMarketDataItem,
|
||||||
|
Filter,
|
||||||
UniqueAsset
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Property } from '@prisma/client';
|
import { AssetSubClass, Prisma, Property } from '@prisma/client';
|
||||||
import { differenceInDays } from 'date-fns';
|
import { differenceInDays } from 'date-fns';
|
||||||
|
import { groupBy } from 'lodash';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminService {
|
export class AdminService {
|
||||||
@ -24,7 +25,6 @@ export class AdminService {
|
|||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly marketDataService: MarketDataService,
|
private readonly marketDataService: MarketDataService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
@ -65,14 +65,27 @@ export class AdminService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getMarketData(): Promise<AdminMarketData> {
|
public async getMarketData(filters?: Filter[]): Promise<AdminMarketData> {
|
||||||
|
const where: Prisma.SymbolProfileWhereInput = {};
|
||||||
|
|
||||||
|
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
|
||||||
|
filters,
|
||||||
|
(filter) => {
|
||||||
|
return filter.type;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const marketData = await this.prismaService.marketData.groupBy({
|
const marketData = await this.prismaService.marketData.groupBy({
|
||||||
_count: true,
|
_count: true,
|
||||||
by: ['dataSource', 'symbol']
|
by: ['dataSource', 'symbol']
|
||||||
});
|
});
|
||||||
|
|
||||||
const currencyPairsToGather: AdminMarketDataItem[] =
|
let currencyPairsToGather: AdminMarketDataItem[] = [];
|
||||||
this.exchangeRateDataService
|
|
||||||
|
if (filtersByAssetSubClass) {
|
||||||
|
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
|
||||||
|
} else {
|
||||||
|
currencyPairsToGather = this.exchangeRateDataService
|
||||||
.getCurrencyPairs()
|
.getCurrencyPairs()
|
||||||
.map(({ dataSource, symbol }) => {
|
.map(({ dataSource, symbol }) => {
|
||||||
const marketDataItemCount =
|
const marketDataItemCount =
|
||||||
@ -86,17 +99,24 @@ export class AdminService {
|
|||||||
return {
|
return {
|
||||||
dataSource,
|
dataSource,
|
||||||
marketDataItemCount,
|
marketDataItemCount,
|
||||||
symbol
|
symbol,
|
||||||
|
countriesCount: 0,
|
||||||
|
sectorsCount: 0
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const symbolProfilesToGather: AdminMarketDataItem[] = (
|
const symbolProfilesToGather: AdminMarketDataItem[] = (
|
||||||
await this.prismaService.symbolProfile.findMany({
|
await this.prismaService.symbolProfile.findMany({
|
||||||
|
where,
|
||||||
orderBy: [{ symbol: 'asc' }],
|
orderBy: [{ symbol: 'asc' }],
|
||||||
select: {
|
select: {
|
||||||
_count: {
|
_count: {
|
||||||
select: { Order: true }
|
select: { Order: true }
|
||||||
},
|
},
|
||||||
|
assetClass: true,
|
||||||
|
assetSubClass: true,
|
||||||
|
countries: true,
|
||||||
dataSource: true,
|
dataSource: true,
|
||||||
Order: {
|
Order: {
|
||||||
orderBy: [{ date: 'asc' }],
|
orderBy: [{ date: 'asc' }],
|
||||||
@ -104,10 +124,14 @@ export class AdminService {
|
|||||||
take: 1
|
take: 1
|
||||||
},
|
},
|
||||||
scraperConfiguration: true,
|
scraperConfiguration: true,
|
||||||
|
sectors: true,
|
||||||
symbol: true
|
symbol: true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
).map((symbolProfile) => {
|
).map((symbolProfile) => {
|
||||||
|
const countriesCount = symbolProfile.countries
|
||||||
|
? Object.keys(symbolProfile.countries).length
|
||||||
|
: 0;
|
||||||
const marketDataItemCount =
|
const marketDataItemCount =
|
||||||
marketData.find((marketDataItem) => {
|
marketData.find((marketDataItem) => {
|
||||||
return (
|
return (
|
||||||
@ -115,10 +139,17 @@ export class AdminService {
|
|||||||
marketDataItem.symbol === symbolProfile.symbol
|
marketDataItem.symbol === symbolProfile.symbol
|
||||||
);
|
);
|
||||||
})?._count ?? 0;
|
})?._count ?? 0;
|
||||||
|
const sectorsCount = symbolProfile.sectors
|
||||||
|
? Object.keys(symbolProfile.sectors).length
|
||||||
|
: 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
countriesCount,
|
||||||
marketDataItemCount,
|
marketDataItemCount,
|
||||||
|
sectorsCount,
|
||||||
activityCount: symbolProfile._count.Order,
|
activityCount: symbolProfile._count.Order,
|
||||||
|
assetClass: symbolProfile.assetClass,
|
||||||
|
assetSubClass: symbolProfile.assetSubClass,
|
||||||
dataSource: symbolProfile.dataSource,
|
dataSource: symbolProfile.dataSource,
|
||||||
date: symbolProfile.Order?.[0]?.date,
|
date: symbolProfile.Order?.[0]?.date,
|
||||||
symbol: symbolProfile.symbol
|
symbol: symbolProfile.symbol
|
||||||
@ -174,7 +205,6 @@ export class AdminService {
|
|||||||
_count: {
|
_count: {
|
||||||
select: { Account: true, Order: true }
|
select: { Account: true, Order: true }
|
||||||
},
|
},
|
||||||
alias: true,
|
|
||||||
Analytics: {
|
Analytics: {
|
||||||
select: {
|
select: {
|
||||||
activityCount: true,
|
activityCount: true,
|
||||||
@ -194,7 +224,7 @@ export class AdminService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return usersWithAnalytics.map(
|
return usersWithAnalytics.map(
|
||||||
({ _count, alias, Analytics, createdAt, id, Subscription }) => {
|
({ _count, Analytics, createdAt, id, Subscription }) => {
|
||||||
const daysSinceRegistration =
|
const daysSinceRegistration =
|
||||||
differenceInDays(new Date(), createdAt) + 1;
|
differenceInDays(new Date(), createdAt) + 1;
|
||||||
const engagement = Analytics.activityCount / daysSinceRegistration;
|
const engagement = Analytics.activityCount / daysSinceRegistration;
|
||||||
@ -206,7 +236,6 @@ export class AdminService {
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
alias,
|
|
||||||
createdAt,
|
createdAt,
|
||||||
engagement,
|
engagement,
|
||||||
id,
|
id,
|
||||||
|
@ -1,6 +1,17 @@
|
|||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { Controller } from '@nestjs/common';
|
import { Controller } from '@nestjs/common';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AppController {
|
export class AppController {
|
||||||
public constructor() {}
|
public constructor(
|
||||||
|
private readonly exchangeRateDataService: ExchangeRateDataService
|
||||||
|
) {
|
||||||
|
this.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initialize() {
|
||||||
|
try {
|
||||||
|
await this.exchangeRateDataService.initialize();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -10,7 +10,7 @@ import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-d
|
|||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
|
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
|
||||||
import { BullModule } from '@nestjs/bull';
|
import { BullModule } from '@nestjs/bull';
|
||||||
import { Module } from '@nestjs/common';
|
import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||||
@ -23,6 +23,7 @@ import { AuthModule } from './auth/auth.module';
|
|||||||
import { BenchmarkModule } from './benchmark/benchmark.module';
|
import { BenchmarkModule } from './benchmark/benchmark.module';
|
||||||
import { CacheModule } from './cache/cache.module';
|
import { CacheModule } from './cache/cache.module';
|
||||||
import { ExportModule } from './export/export.module';
|
import { ExportModule } from './export/export.module';
|
||||||
|
import { FrontendMiddleware } from './frontend.middleware';
|
||||||
import { ImportModule } from './import/import.module';
|
import { ImportModule } from './import/import.module';
|
||||||
import { InfoModule } from './info/info.module';
|
import { InfoModule } from './info/info.module';
|
||||||
import { OrderModule } from './order/order.module';
|
import { OrderModule } from './order/order.module';
|
||||||
@ -82,4 +83,10 @@ import { UserModule } from './user/user.module';
|
|||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [CronService]
|
providers: [CronService]
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {
|
||||||
|
configure(consumer: MiddlewareConsumer) {
|
||||||
|
consumer
|
||||||
|
.apply(FrontendMiddleware)
|
||||||
|
.forRoutes({ path: '*', method: RequestMethod.ALL });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
|
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||||
|
import { OAuthResponse } from '@ghostfolio/common/interfaces';
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@ -31,7 +33,9 @@ export class AuthController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('anonymous/:accessToken')
|
@Get('anonymous/:accessToken')
|
||||||
public async accessTokenLogin(@Param('accessToken') accessToken: string) {
|
public async accessTokenLogin(
|
||||||
|
@Param('accessToken') accessToken: string
|
||||||
|
): Promise<OAuthResponse> {
|
||||||
try {
|
try {
|
||||||
const authToken = await this.authService.validateAnonymousLogin(
|
const authToken = await this.authService.validateAnonymousLogin(
|
||||||
accessToken
|
accessToken
|
||||||
@ -59,9 +63,34 @@ export class AuthController {
|
|||||||
const jwt: string = req.user.jwt;
|
const jwt: string = req.user.jwt;
|
||||||
|
|
||||||
if (jwt) {
|
if (jwt) {
|
||||||
res.redirect(`${this.configurationService.get('ROOT_URL')}/auth/${jwt}`);
|
res.redirect(
|
||||||
|
`${this.configurationService.get(
|
||||||
|
'ROOT_URL'
|
||||||
|
)}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}`
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
res.redirect(`${this.configurationService.get('ROOT_URL')}/auth`);
|
res.redirect(
|
||||||
|
`${this.configurationService.get(
|
||||||
|
'ROOT_URL'
|
||||||
|
)}/${DEFAULT_LANGUAGE_CODE}/auth`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('internet-identity/:principalId')
|
||||||
|
public async internetIdentityLogin(
|
||||||
|
@Param('principalId') principalId: string
|
||||||
|
): Promise<OAuthResponse> {
|
||||||
|
try {
|
||||||
|
const authToken = await this.authService.validateInternetIdentityLogin(
|
||||||
|
principalId
|
||||||
|
);
|
||||||
|
return { authToken };
|
||||||
|
} catch {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,6 +2,7 @@ import { UserService } from '@ghostfolio/api/app/user/user.service';
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { Provider } from '@prisma/client';
|
||||||
|
|
||||||
import { ValidateOAuthLoginParams } from './interfaces/interfaces';
|
import { ValidateOAuthLoginParams } from './interfaces/interfaces';
|
||||||
|
|
||||||
@ -13,7 +14,7 @@ export class AuthService {
|
|||||||
private readonly userService: UserService
|
private readonly userService: UserService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async validateAnonymousLogin(accessToken: string) {
|
public async validateAnonymousLogin(accessToken: string): Promise<string> {
|
||||||
return new Promise(async (resolve, reject) => {
|
return new Promise(async (resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
const hashedAccessToken = this.userService.createAccessToken(
|
const hashedAccessToken = this.userService.createAccessToken(
|
||||||
@ -26,7 +27,7 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
const jwt: string = this.jwtService.sign({
|
const jwt = this.jwtService.sign({
|
||||||
id: user.id
|
id: user.id
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -40,6 +41,33 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async validateInternetIdentityLogin(principalId: string) {
|
||||||
|
try {
|
||||||
|
const provider: Provider = 'INTERNET_IDENTITY';
|
||||||
|
|
||||||
|
let [user] = await this.userService.users({
|
||||||
|
where: { provider, thirdPartyId: principalId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
// Create new user if not found
|
||||||
|
user = await this.userService.createUser({
|
||||||
|
provider,
|
||||||
|
thirdPartyId: principalId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.jwtService.sign({
|
||||||
|
id: user.id
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new InternalServerErrorException(
|
||||||
|
'validateInternetIdentityLogin',
|
||||||
|
error.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async validateOAuthLogin({
|
public async validateOAuthLogin({
|
||||||
provider,
|
provider,
|
||||||
thirdPartyId
|
thirdPartyId
|
||||||
@ -57,13 +85,14 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const jwt: string = this.jwtService.sign({
|
return this.jwtService.sign({
|
||||||
id: user.id
|
id: user.id
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
return jwt;
|
throw new InternalServerErrorException(
|
||||||
} catch (err) {
|
'validateOAuthLogin',
|
||||||
throw new InternalServerErrorException('validateOAuthLogin', err.message);
|
error.message
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,8 +3,7 @@ import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interc
|
|||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import { PROPERTY_BENCHMARKS } from '@ghostfolio/common/config';
|
import { PROPERTY_BENCHMARKS } from '@ghostfolio/common/config';
|
||||||
import { BenchmarkResponse, UniqueAsset } from '@ghostfolio/common/interfaces';
|
import { BenchmarkResponse, UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
import { Controller, Get, UseGuards, UseInterceptors } from '@nestjs/common';
|
import { Controller, Get, UseInterceptors } from '@nestjs/common';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
|
||||||
|
|
||||||
import { BenchmarkService } from './benchmark.service';
|
import { BenchmarkService } from './benchmark.service';
|
||||||
|
|
||||||
@ -16,7 +15,6 @@ export class BenchmarkController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getBenchmark(): Promise<BenchmarkResponse> {
|
public async getBenchmark(): Promise<BenchmarkResponse> {
|
||||||
|
@ -48,9 +48,13 @@ export class BenchmarkService {
|
|||||||
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
|
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
|
||||||
const { marketPrice } = quotes[benchmarkAssets[index].symbol];
|
const { marketPrice } = quotes[benchmarkAssets[index].symbol];
|
||||||
|
|
||||||
const performancePercentFromAllTimeHigh = new Big(marketPrice)
|
let performancePercentFromAllTimeHigh = new Big(0);
|
||||||
|
|
||||||
|
if (allTimeHigh) {
|
||||||
|
performancePercentFromAllTimeHigh = new Big(marketPrice)
|
||||||
.div(allTimeHigh)
|
.div(allTimeHigh)
|
||||||
.minus(1);
|
.minus(1);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
marketCondition: this.getMarketCondition(
|
marketCondition: this.getMarketCondition(
|
||||||
|
@ -18,6 +18,7 @@ export class ExportService {
|
|||||||
orderBy: { date: 'desc' },
|
orderBy: { date: 'desc' },
|
||||||
select: {
|
select: {
|
||||||
accountId: true,
|
accountId: true,
|
||||||
|
comment: true,
|
||||||
date: true,
|
date: true,
|
||||||
fee: true,
|
fee: true,
|
||||||
id: true,
|
id: true,
|
||||||
@ -40,6 +41,7 @@ export class ExportService {
|
|||||||
activities: activities.map(
|
activities: activities.map(
|
||||||
({
|
({
|
||||||
accountId,
|
accountId,
|
||||||
|
comment,
|
||||||
date,
|
date,
|
||||||
fee,
|
fee,
|
||||||
id,
|
id,
|
||||||
@ -50,6 +52,7 @@ export class ExportService {
|
|||||||
}) => {
|
}) => {
|
||||||
return {
|
return {
|
||||||
accountId,
|
accountId,
|
||||||
|
comment,
|
||||||
fee,
|
fee,
|
||||||
id,
|
id,
|
||||||
quantity,
|
quantity,
|
||||||
|
81
apps/api/src/app/frontend.middleware.ts
Normal file
81
apps/api/src/app/frontend.middleware.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
|
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||||
|
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||||
|
import { NextFunction, Request, Response } from 'express';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FrontendMiddleware implements NestMiddleware {
|
||||||
|
public indexHtmlDe = fs.readFileSync(
|
||||||
|
this.getPathOfIndexHtmlFile('de'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
public indexHtmlEn = fs.readFileSync(
|
||||||
|
this.getPathOfIndexHtmlFile(DEFAULT_LANGUAGE_CODE),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly configurationService: ConfigurationService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public use(req: Request, res: Response, next: NextFunction) {
|
||||||
|
let featureGraphicPath = 'assets/cover.png';
|
||||||
|
|
||||||
|
if (
|
||||||
|
req.path === '/en/blog/2022/08/500-stars-on-github' ||
|
||||||
|
req.path === '/en/blog/2022/08/500-stars-on-github/'
|
||||||
|
) {
|
||||||
|
featureGraphicPath = 'assets/images/blog/500-stars-on-github.jpg';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.path.startsWith('/api/') || this.isFileRequest(req.url)) {
|
||||||
|
// Skip
|
||||||
|
next();
|
||||||
|
} else if (req.path === '/de' || req.path.startsWith('/de/')) {
|
||||||
|
res.send(
|
||||||
|
this.interpolate(this.indexHtmlDe, {
|
||||||
|
featureGraphicPath,
|
||||||
|
languageCode: 'de',
|
||||||
|
path: req.path,
|
||||||
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
res.send(
|
||||||
|
this.interpolate(this.indexHtmlEn, {
|
||||||
|
featureGraphicPath,
|
||||||
|
languageCode: DEFAULT_LANGUAGE_CODE,
|
||||||
|
path: req.path,
|
||||||
|
rootUrl: this.configurationService.get('ROOT_URL')
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPathOfIndexHtmlFile(aLocale: string) {
|
||||||
|
return path.join(__dirname, '..', 'client', aLocale, 'index.html');
|
||||||
|
}
|
||||||
|
|
||||||
|
private interpolate(template: string, context: any) {
|
||||||
|
return template.replace(/[$]{([^}]+)}/g, (_, objectPath) => {
|
||||||
|
const properties = objectPath.split('.');
|
||||||
|
return properties.reduce(
|
||||||
|
(previous, current) => previous?.[current],
|
||||||
|
context
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private isFileRequest(filename: string) {
|
||||||
|
if (filename === '/assets/LICENSE') {
|
||||||
|
return true;
|
||||||
|
} else if (filename.includes('auth/ey')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filename.split('.').pop() !== filename;
|
||||||
|
}
|
||||||
|
}
|
@ -34,8 +34,20 @@ export class ImportController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let maxActivitiesToImport = this.configurationService.get(
|
||||||
|
'MAX_ACTIVITIES_TO_IMPORT'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
|
this.request.user.subscription.type === 'Premium'
|
||||||
|
) {
|
||||||
|
maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await this.importService.import({
|
return await this.importService.import({
|
||||||
|
maxActivitiesToImport,
|
||||||
activities: importData.activities,
|
activities: importData.activities,
|
||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
|
@ -17,9 +17,11 @@ export class ImportService {
|
|||||||
|
|
||||||
public async import({
|
public async import({
|
||||||
activities,
|
activities,
|
||||||
|
maxActivitiesToImport,
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
activities: Partial<CreateOrderDto>[];
|
activities: Partial<CreateOrderDto>[];
|
||||||
|
maxActivitiesToImport: number;
|
||||||
userId: string;
|
userId: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
for (const activity of activities) {
|
for (const activity of activities) {
|
||||||
@ -32,7 +34,11 @@ export class ImportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.validateActivities({ activities, userId });
|
await this.validateActivities({
|
||||||
|
activities,
|
||||||
|
maxActivitiesToImport,
|
||||||
|
userId
|
||||||
|
});
|
||||||
|
|
||||||
const accountIds = (await this.accountService.getAccounts(userId)).map(
|
const accountIds = (await this.accountService.getAccounts(userId)).map(
|
||||||
(account) => {
|
(account) => {
|
||||||
@ -42,6 +48,7 @@ export class ImportService {
|
|||||||
|
|
||||||
for (const {
|
for (const {
|
||||||
accountId,
|
accountId,
|
||||||
|
comment,
|
||||||
currency,
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
date,
|
date,
|
||||||
@ -52,6 +59,7 @@ export class ImportService {
|
|||||||
unitPrice
|
unitPrice
|
||||||
} of activities) {
|
} of activities) {
|
||||||
await this.orderService.createOrder({
|
await this.orderService.createOrder({
|
||||||
|
comment,
|
||||||
fee,
|
fee,
|
||||||
quantity,
|
quantity,
|
||||||
type,
|
type,
|
||||||
@ -81,19 +89,15 @@ export class ImportService {
|
|||||||
|
|
||||||
private async validateActivities({
|
private async validateActivities({
|
||||||
activities,
|
activities,
|
||||||
|
maxActivitiesToImport,
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
activities: Partial<CreateOrderDto>[];
|
activities: Partial<CreateOrderDto>[];
|
||||||
|
maxActivitiesToImport: number;
|
||||||
userId: string;
|
userId: string;
|
||||||
}) {
|
}) {
|
||||||
if (
|
if (activities?.length > maxActivitiesToImport) {
|
||||||
activities?.length > this.configurationService.get('MAX_ORDERS_TO_IMPORT')
|
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
`Too many activities (${this.configurationService.get(
|
|
||||||
'MAX_ORDERS_TO_IMPORT'
|
|
||||||
)} at most)`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingActivities = await this.orderService.orders({
|
const existingActivities = await this.orderService.orders({
|
||||||
|
@ -63,6 +63,8 @@ export class InfoService {
|
|||||||
} else {
|
} else {
|
||||||
info.fearAndGreedDataSource = ghostfolioFearAndGreedIndexDataSource;
|
info.fearAndGreedDataSource = ghostfolioFearAndGreedIndexDataSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
globalPermissions.push(permissions.enableFearAndGreedIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
|
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
|
||||||
|
@ -1,30 +1,46 @@
|
|||||||
import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client';
|
|
||||||
import {
|
import {
|
||||||
|
AssetClass,
|
||||||
|
AssetSubClass,
|
||||||
|
DataSource,
|
||||||
|
Tag,
|
||||||
|
Type
|
||||||
|
} from '@prisma/client';
|
||||||
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsISO8601,
|
IsISO8601,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString
|
IsString
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
import { isString } from 'lodash';
|
||||||
|
|
||||||
export class CreateOrderDto {
|
export class CreateOrderDto {
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
|
|
||||||
@IsEnum(AssetClass, { each: true })
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
@IsEnum(AssetClass, { each: true })
|
||||||
assetClass?: AssetClass;
|
assetClass?: AssetClass;
|
||||||
|
|
||||||
@IsEnum(AssetSubClass, { each: true })
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
@IsEnum(AssetSubClass, { each: true })
|
||||||
assetSubClass?: AssetSubClass;
|
assetSubClass?: AssetSubClass;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Transform(({ value }: TransformFnParams) =>
|
||||||
|
isString(value) ? value.trim() : value
|
||||||
|
)
|
||||||
|
comment?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
@IsEnum(DataSource, { each: true })
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
@IsEnum(DataSource, { each: true })
|
||||||
dataSource?: DataSource;
|
dataSource?: DataSource;
|
||||||
|
|
||||||
@IsISO8601()
|
@IsISO8601()
|
||||||
@ -39,6 +55,10 @@ export class CreateOrderDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
symbol: string;
|
symbol: string;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsOptional()
|
||||||
|
tags?: Tag[];
|
||||||
|
|
||||||
@IsEnum(Type, { each: true })
|
@IsEnum(Type, { each: true })
|
||||||
type: Type;
|
type: Type;
|
||||||
|
|
||||||
|
@ -4,6 +4,7 @@ import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/
|
|||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||||
|
import { Filter } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
@ -17,6 +18,7 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors
|
UseInterceptors
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@ -66,8 +68,36 @@ export class OrderController {
|
|||||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getAllOrders(
|
public async getAllOrders(
|
||||||
@Headers('impersonation-id') impersonationId
|
@Headers('impersonation-id') impersonationId,
|
||||||
|
@Query('accounts') filterByAccounts?: string,
|
||||||
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
|
@Query('tags') filterByTags?: string
|
||||||
): Promise<Activities> {
|
): Promise<Activities> {
|
||||||
|
const accountIds = filterByAccounts?.split(',') ?? [];
|
||||||
|
const assetClasses = filterByAssetClasses?.split(',') ?? [];
|
||||||
|
const tagIds = filterByTags?.split(',') ?? [];
|
||||||
|
|
||||||
|
const filters: Filter[] = [
|
||||||
|
...accountIds.map((accountId) => {
|
||||||
|
return <Filter>{
|
||||||
|
id: accountId,
|
||||||
|
type: 'ACCOUNT'
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
...assetClasses.map((assetClass) => {
|
||||||
|
return <Filter>{
|
||||||
|
id: assetClass,
|
||||||
|
type: 'ASSET_CLASS'
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
...tagIds.map((tagId) => {
|
||||||
|
return <Filter>{
|
||||||
|
id: tagId,
|
||||||
|
type: 'TAG'
|
||||||
|
};
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
const impersonationUserId =
|
const impersonationUserId =
|
||||||
await this.impersonationService.validateImpersonationId(
|
await this.impersonationService.validateImpersonationId(
|
||||||
impersonationId,
|
impersonationId,
|
||||||
@ -76,6 +106,7 @@ export class OrderController {
|
|||||||
const userCurrency = this.request.user.Settings.currency;
|
const userCurrency = this.request.user.Settings.currency;
|
||||||
|
|
||||||
let activities = await this.orderService.getOrders({
|
let activities = await this.orderService.getOrders({
|
||||||
|
filters,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
includeDrafts: true,
|
includeDrafts: true,
|
||||||
userId: impersonationUserId || this.request.user.id
|
userId: impersonationUserId || this.request.user.id
|
||||||
|
@ -16,6 +16,7 @@ import {
|
|||||||
DataSource,
|
DataSource,
|
||||||
Order,
|
Order,
|
||||||
Prisma,
|
Prisma,
|
||||||
|
Tag,
|
||||||
Type as TypeOfOrder
|
Type as TypeOfOrder
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
@ -71,6 +72,7 @@ export class OrderService {
|
|||||||
currency?: string;
|
currency?: string;
|
||||||
dataSource?: DataSource;
|
dataSource?: DataSource;
|
||||||
symbol?: string;
|
symbol?: string;
|
||||||
|
tags?: Tag[];
|
||||||
userId: string;
|
userId: string;
|
||||||
}
|
}
|
||||||
): Promise<Order> {
|
): Promise<Order> {
|
||||||
@ -80,6 +82,8 @@ export class OrderService {
|
|||||||
return account.isDefault === true;
|
return account.isDefault === true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const tags = data.tags ?? [];
|
||||||
|
|
||||||
let Account = {
|
let Account = {
|
||||||
connect: {
|
connect: {
|
||||||
id_userId: {
|
id_userId: {
|
||||||
@ -139,9 +143,15 @@ export class OrderService {
|
|||||||
delete data.accountId;
|
delete data.accountId;
|
||||||
delete data.assetClass;
|
delete data.assetClass;
|
||||||
delete data.assetSubClass;
|
delete data.assetSubClass;
|
||||||
|
|
||||||
|
if (!data.comment) {
|
||||||
|
delete data.comment;
|
||||||
|
}
|
||||||
|
|
||||||
delete data.currency;
|
delete data.currency;
|
||||||
delete data.dataSource;
|
delete data.dataSource;
|
||||||
delete data.symbol;
|
delete data.symbol;
|
||||||
|
delete data.tags;
|
||||||
delete data.userId;
|
delete data.userId;
|
||||||
|
|
||||||
const orderData: Prisma.OrderCreateInput = data;
|
const orderData: Prisma.OrderCreateInput = data;
|
||||||
@ -150,7 +160,12 @@ export class OrderService {
|
|||||||
data: {
|
data: {
|
||||||
...orderData,
|
...orderData,
|
||||||
Account,
|
Account,
|
||||||
isDraft
|
isDraft,
|
||||||
|
tags: {
|
||||||
|
connect: tags.map(({ id }) => {
|
||||||
|
return { id };
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -215,9 +230,10 @@ export class OrderService {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
SymbolProfileOverrides: {
|
OR: [
|
||||||
is: null
|
{ SymbolProfileOverrides: { is: null } },
|
||||||
}
|
{ SymbolProfileOverrides: { assetClass: null } }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@ -298,6 +314,7 @@ export class OrderService {
|
|||||||
currency?: string;
|
currency?: string;
|
||||||
dataSource?: DataSource;
|
dataSource?: DataSource;
|
||||||
symbol?: string;
|
symbol?: string;
|
||||||
|
tags?: Tag[];
|
||||||
};
|
};
|
||||||
where: Prisma.OrderWhereUniqueInput;
|
where: Prisma.OrderWhereUniqueInput;
|
||||||
}): Promise<Order> {
|
}): Promise<Order> {
|
||||||
@ -305,6 +322,12 @@ export class OrderService {
|
|||||||
delete data.Account;
|
delete data.Account;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!data.comment) {
|
||||||
|
data.comment = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = data.tags ?? [];
|
||||||
|
|
||||||
let isDraft = false;
|
let isDraft = false;
|
||||||
|
|
||||||
if (data.type === 'ITEM') {
|
if (data.type === 'ITEM') {
|
||||||
@ -331,11 +354,17 @@ export class OrderService {
|
|||||||
delete data.currency;
|
delete data.currency;
|
||||||
delete data.dataSource;
|
delete data.dataSource;
|
||||||
delete data.symbol;
|
delete data.symbol;
|
||||||
|
delete data.tags;
|
||||||
|
|
||||||
return this.prismaService.order.update({
|
return this.prismaService.order.update({
|
||||||
data: {
|
data: {
|
||||||
...data,
|
...data,
|
||||||
isDraft
|
isDraft,
|
||||||
|
tags: {
|
||||||
|
connect: tags.map(({ id }) => {
|
||||||
|
return { id };
|
||||||
|
})
|
||||||
|
}
|
||||||
},
|
},
|
||||||
where
|
where
|
||||||
});
|
});
|
||||||
|
@ -1,11 +1,20 @@
|
|||||||
import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client';
|
|
||||||
import {
|
import {
|
||||||
|
AssetClass,
|
||||||
|
AssetSubClass,
|
||||||
|
DataSource,
|
||||||
|
Tag,
|
||||||
|
Type
|
||||||
|
} from '@prisma/client';
|
||||||
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsISO8601,
|
IsISO8601,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString
|
IsString
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
import { isString } from 'lodash';
|
||||||
|
|
||||||
export class UpdateOrderDto {
|
export class UpdateOrderDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ -20,6 +29,13 @@ export class UpdateOrderDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
assetSubClass?: AssetSubClass;
|
assetSubClass?: AssetSubClass;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Transform(({ value }: TransformFnParams) =>
|
||||||
|
isString(value) ? value.trim() : value
|
||||||
|
)
|
||||||
|
comment?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
@ -41,6 +57,10 @@ export class UpdateOrderDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
symbol: string;
|
symbol: string;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsOptional()
|
||||||
|
tags?: Tag[];
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
type: Type;
|
type: Type;
|
||||||
|
|
||||||
|
@ -62,6 +62,10 @@ describe('PortfolioCalculator', () => {
|
|||||||
parseDate('2021-11-22')
|
parseDate('2021-11-22')
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
|
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
expect(currentPositions).toEqual({
|
expect(currentPositions).toEqual({
|
||||||
@ -91,6 +95,15 @@ describe('PortfolioCalculator', () => {
|
|||||||
],
|
],
|
||||||
totalInvestment: new Big('0')
|
totalInvestment: new Big('0')
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(investments).toEqual([
|
||||||
|
{ date: '2021-11-22', investment: new Big('285.8') },
|
||||||
|
{ date: '2021-11-30', investment: new Big('0') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(investmentsByMonth).toEqual([
|
||||||
|
{ date: '2021-11-01', investment: new Big('12.6') }
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -51,6 +51,10 @@ describe('PortfolioCalculator', () => {
|
|||||||
parseDate('2021-11-30')
|
parseDate('2021-11-30')
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
|
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
expect(currentPositions).toEqual({
|
expect(currentPositions).toEqual({
|
||||||
@ -80,6 +84,14 @@ describe('PortfolioCalculator', () => {
|
|||||||
],
|
],
|
||||||
totalInvestment: new Big('273.2')
|
totalInvestment: new Big('273.2')
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(investments).toEqual([
|
||||||
|
{ date: '2021-11-30', investment: new Big('273.2') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(investmentsByMonth).toEqual([
|
||||||
|
{ date: '2021-11-01', investment: new Big('273.2') }
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -39,6 +39,10 @@ describe('PortfolioCalculator', () => {
|
|||||||
new Date()
|
new Date()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
|
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
expect(currentPositions).toEqual({
|
expect(currentPositions).toEqual({
|
||||||
@ -51,6 +55,10 @@ describe('PortfolioCalculator', () => {
|
|||||||
positions: [],
|
positions: [],
|
||||||
totalInvestment: new Big(0)
|
totalInvestment: new Big(0)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(investments).toEqual([]);
|
||||||
|
|
||||||
|
expect(investmentsByMonth).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -62,6 +62,10 @@ describe('PortfolioCalculator', () => {
|
|||||||
parseDate('2022-03-07')
|
parseDate('2022-03-07')
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
|
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
|
||||||
|
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
expect(currentPositions).toEqual({
|
expect(currentPositions).toEqual({
|
||||||
@ -91,6 +95,16 @@ describe('PortfolioCalculator', () => {
|
|||||||
],
|
],
|
||||||
totalInvestment: new Big('75.80')
|
totalInvestment: new Big('75.80')
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(investments).toEqual([
|
||||||
|
{ date: '2022-03-07', investment: new Big('151.6') },
|
||||||
|
{ date: '2022-04-08', investment: new Big('75.8') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(investmentsByMonth).toEqual([
|
||||||
|
{ date: '2022-03-01', investment: new Big('151.6') },
|
||||||
|
{ date: '2022-04-01', investment: new Big('-85.73') }
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -14,8 +14,11 @@ import {
|
|||||||
format,
|
format,
|
||||||
isAfter,
|
isAfter,
|
||||||
isBefore,
|
isBefore,
|
||||||
|
isSameMonth,
|
||||||
|
isSameYear,
|
||||||
max,
|
max,
|
||||||
min
|
min,
|
||||||
|
set
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import { first, flatten, isNumber, sortBy } from 'lodash';
|
import { first, flatten, isNumber, sortBy } from 'lodash';
|
||||||
|
|
||||||
@ -323,6 +326,53 @@ export class PortfolioCalculator {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getInvestmentsByMonth(): { date: string; investment: Big }[] {
|
||||||
|
if (this.orders.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const investments = [];
|
||||||
|
let currentDate: Date;
|
||||||
|
let investmentByMonth = new Big(0);
|
||||||
|
|
||||||
|
for (const [index, order] of this.orders.entries()) {
|
||||||
|
if (
|
||||||
|
isSameMonth(parseDate(order.date), currentDate) &&
|
||||||
|
isSameYear(parseDate(order.date), currentDate)
|
||||||
|
) {
|
||||||
|
// Same month: Add up investments
|
||||||
|
|
||||||
|
investmentByMonth = investmentByMonth.plus(
|
||||||
|
order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// New month: Store previous month and reset
|
||||||
|
|
||||||
|
if (currentDate) {
|
||||||
|
investments.push({
|
||||||
|
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
|
||||||
|
investment: investmentByMonth
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
currentDate = parseDate(order.date);
|
||||||
|
investmentByMonth = order.quantity
|
||||||
|
.mul(order.unitPrice)
|
||||||
|
.mul(this.getFactor(order.type));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index === this.orders.length - 1) {
|
||||||
|
// Store current month (latest order)
|
||||||
|
investments.push({
|
||||||
|
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
|
||||||
|
investment: investmentByMonth
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return investments;
|
||||||
|
}
|
||||||
|
|
||||||
public async calculateTimeline(
|
public async calculateTimeline(
|
||||||
timelineSpecification: TimelineSpecification[],
|
timelineSpecification: TimelineSpecification[],
|
||||||
endDate: string
|
endDate: string
|
||||||
|
@ -20,7 +20,12 @@ import {
|
|||||||
PortfolioReport,
|
PortfolioReport,
|
||||||
PortfolioSummary
|
PortfolioSummary
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
|
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||||
|
import type {
|
||||||
|
DateRange,
|
||||||
|
GroupBy,
|
||||||
|
RequestWithUser
|
||||||
|
} from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
@ -190,21 +195,35 @@ export class PortfolioController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isBasicUser =
|
let hasDetails = true;
|
||||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
this.request.user.subscription.type === 'Basic';
|
hasDetails = this.request.user.subscription.type === 'Premium';
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||||
|
holdings[symbol] = {
|
||||||
|
...portfolioPosition,
|
||||||
|
assetClass: hasDetails ? portfolioPosition.assetClass : undefined,
|
||||||
|
assetSubClass: hasDetails ? portfolioPosition.assetSubClass : undefined,
|
||||||
|
countries: hasDetails ? portfolioPosition.countries : [],
|
||||||
|
currency: hasDetails ? portfolioPosition.currency : undefined,
|
||||||
|
markets: hasDetails ? portfolioPosition.markets : undefined,
|
||||||
|
sectors: hasDetails ? portfolioPosition.sectors : []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accounts,
|
accounts,
|
||||||
hasError,
|
hasError,
|
||||||
holdings: isBasicUser ? {} : holdings
|
holdings
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('investments')
|
@Get('investments')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getInvestments(
|
public async getInvestments(
|
||||||
@Headers('impersonation-id') impersonationId: string
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
|
@Query('groupBy') groupBy?: GroupBy
|
||||||
): Promise<PortfolioInvestments> {
|
): Promise<PortfolioInvestments> {
|
||||||
if (
|
if (
|
||||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
@ -216,9 +235,16 @@ export class PortfolioController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let investments = await this.portfolioService.getInvestments(
|
let investments: InvestmentItem[];
|
||||||
impersonationId
|
|
||||||
|
if (groupBy === 'month') {
|
||||||
|
investments = await this.portfolioService.getInvestments(
|
||||||
|
impersonationId,
|
||||||
|
'month'
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
investments = await this.portfolioService.getInvestments(impersonationId);
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationId ||
|
impersonationId ||
|
||||||
@ -316,7 +342,9 @@ export class PortfolioController {
|
|||||||
|
|
||||||
const { holdings } = await this.portfolioService.getDetails(
|
const { holdings } = await this.portfolioService.getDetails(
|
||||||
access.userId,
|
access.userId,
|
||||||
access.userId
|
access.userId,
|
||||||
|
'max',
|
||||||
|
[{ id: 'EQUITY', type: 'ASSET_CLASS' }]
|
||||||
);
|
);
|
||||||
|
|
||||||
const portfolioPublicDetails: PortfolioPublicDetails = {
|
const portfolioPublicDetails: PortfolioPublicDetails = {
|
||||||
@ -325,9 +353,6 @@ export class PortfolioController {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const totalValue = Object.values(holdings)
|
const totalValue = Object.values(holdings)
|
||||||
.filter((holding) => {
|
|
||||||
return holding.assetClass === 'EQUITY';
|
|
||||||
})
|
|
||||||
.map((portfolioPosition) => {
|
.map((portfolioPosition) => {
|
||||||
return this.exchangeRateDataService.toCurrency(
|
return this.exchangeRateDataService.toCurrency(
|
||||||
portfolioPosition.quantity * portfolioPosition.marketPrice,
|
portfolioPosition.quantity * portfolioPosition.marketPrice,
|
||||||
@ -338,18 +363,19 @@ export class PortfolioController {
|
|||||||
.reduce((a, b) => a + b, 0);
|
.reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||||
if (portfolioPosition.assetClass === 'EQUITY') {
|
|
||||||
portfolioPublicDetails.holdings[symbol] = {
|
portfolioPublicDetails.holdings[symbol] = {
|
||||||
allocationCurrent: portfolioPosition.allocationCurrent,
|
allocationCurrent: portfolioPosition.value / totalValue,
|
||||||
countries: hasDetails ? portfolioPosition.countries : [],
|
countries: hasDetails ? portfolioPosition.countries : [],
|
||||||
currency: portfolioPosition.currency,
|
currency: hasDetails ? portfolioPosition.currency : undefined,
|
||||||
markets: portfolioPosition.markets,
|
markets: hasDetails ? portfolioPosition.markets : undefined,
|
||||||
name: portfolioPosition.name,
|
name: portfolioPosition.name,
|
||||||
|
netPerformancePercent: portfolioPosition.netPerformancePercent,
|
||||||
sectors: hasDetails ? portfolioPosition.sectors : [],
|
sectors: hasDetails ? portfolioPosition.sectors : [],
|
||||||
|
symbol: portfolioPosition.symbol,
|
||||||
|
url: portfolioPosition.url,
|
||||||
value: portfolioPosition.value / totalValue
|
value: portfolioPosition.value / totalValue
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return portfolioPublicDetails;
|
return portfolioPublicDetails;
|
||||||
}
|
}
|
||||||
|
@ -41,6 +41,7 @@ import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.in
|
|||||||
import type {
|
import type {
|
||||||
AccountWithValue,
|
AccountWithValue,
|
||||||
DateRange,
|
DateRange,
|
||||||
|
GroupBy,
|
||||||
Market,
|
Market,
|
||||||
OrderWithAccount,
|
OrderWithAccount,
|
||||||
RequestWithUser
|
RequestWithUser
|
||||||
@ -50,6 +51,7 @@ import { REQUEST } from '@nestjs/core';
|
|||||||
import {
|
import {
|
||||||
AssetClass,
|
AssetClass,
|
||||||
DataSource,
|
DataSource,
|
||||||
|
Prisma,
|
||||||
Tag,
|
Tag,
|
||||||
Type as TypeOfOrder
|
Type as TypeOfOrder
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
@ -63,6 +65,7 @@ import {
|
|||||||
max,
|
max,
|
||||||
parse,
|
parse,
|
||||||
parseISO,
|
parseISO,
|
||||||
|
set,
|
||||||
setDayOfYear,
|
setDayOfYear,
|
||||||
startOfDay,
|
startOfDay,
|
||||||
subDays,
|
subDays,
|
||||||
@ -100,14 +103,23 @@ export class PortfolioService {
|
|||||||
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAccounts(aUserId: string): Promise<AccountWithValue[]> {
|
public async getAccounts(
|
||||||
|
aUserId: string,
|
||||||
|
aFilters?: Filter[]
|
||||||
|
): Promise<AccountWithValue[]> {
|
||||||
|
const where: Prisma.AccountWhereInput = { userId: aUserId };
|
||||||
|
|
||||||
|
if (aFilters?.[0].id && aFilters?.[0].type === 'ACCOUNT') {
|
||||||
|
where.id = aFilters[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
const [accounts, details] = await Promise.all([
|
const [accounts, details] = await Promise.all([
|
||||||
this.accountService.accounts({
|
this.accountService.accounts({
|
||||||
|
where,
|
||||||
include: { Order: true, Platform: true },
|
include: { Order: true, Platform: true },
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' }
|
||||||
where: { userId: aUserId }
|
|
||||||
}),
|
}),
|
||||||
this.getDetails(aUserId, aUserId)
|
this.getDetails(aUserId, aUserId, undefined, aFilters)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const userCurrency = this.request.user.Settings.currency;
|
const userCurrency = this.request.user.Settings.currency;
|
||||||
@ -145,8 +157,11 @@ export class PortfolioService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAccountsWithAggregations(aUserId: string): Promise<Accounts> {
|
public async getAccountsWithAggregations(
|
||||||
const accounts = await this.getAccounts(aUserId);
|
aUserId: string,
|
||||||
|
aFilters?: Filter[]
|
||||||
|
): Promise<Accounts> {
|
||||||
|
const accounts = await this.getAccounts(aUserId, aFilters);
|
||||||
let totalBalanceInBaseCurrency = new Big(0);
|
let totalBalanceInBaseCurrency = new Big(0);
|
||||||
let totalValueInBaseCurrency = new Big(0);
|
let totalValueInBaseCurrency = new Big(0);
|
||||||
let transactionCount = 0;
|
let transactionCount = 0;
|
||||||
@ -170,7 +185,8 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getInvestments(
|
public async getInvestments(
|
||||||
aImpersonationId: string
|
aImpersonationId: string,
|
||||||
|
groupBy?: GroupBy
|
||||||
): Promise<InvestmentItem[]> {
|
): Promise<InvestmentItem[]> {
|
||||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||||
|
|
||||||
@ -191,21 +207,49 @@ export class PortfolioService {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const investments = portfolioCalculator.getInvestments().map((item) => {
|
let investments: InvestmentItem[];
|
||||||
|
|
||||||
|
if (groupBy === 'month') {
|
||||||
|
investments = portfolioCalculator.getInvestmentsByMonth().map((item) => {
|
||||||
return {
|
return {
|
||||||
date: item.date,
|
date: item.date,
|
||||||
investment: item.investment.toNumber()
|
investment: item.investment.toNumber()
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add investment of current month
|
||||||
|
const dateOfCurrentMonth = format(
|
||||||
|
set(new Date(), { date: 1 }),
|
||||||
|
DATE_FORMAT
|
||||||
|
);
|
||||||
|
const investmentOfCurrentMonth = investments.filter(({ date }) => {
|
||||||
|
return date === dateOfCurrentMonth;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (investmentOfCurrentMonth.length <= 0) {
|
||||||
|
investments.push({
|
||||||
|
date: dateOfCurrentMonth,
|
||||||
|
investment: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
investments = portfolioCalculator
|
||||||
|
.getInvestments()
|
||||||
|
.map(({ date, investment }) => {
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
investment: investment.toNumber()
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Add investment of today
|
// Add investment of today
|
||||||
const investmentOfToday = investments.filter((investment) => {
|
const investmentOfToday = investments.filter(({ date }) => {
|
||||||
return investment.date === format(new Date(), DATE_FORMAT);
|
return date === format(new Date(), DATE_FORMAT);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (investmentOfToday.length <= 0) {
|
if (investmentOfToday.length <= 0) {
|
||||||
const pastInvestments = investments.filter((investment) => {
|
const pastInvestments = investments.filter(({ date }) => {
|
||||||
return isBefore(parseDate(investment.date), new Date());
|
return isBefore(parseDate(date), new Date());
|
||||||
});
|
});
|
||||||
const lastInvestment = pastInvestments[pastInvestments.length - 1];
|
const lastInvestment = pastInvestments[pastInvestments.length - 1];
|
||||||
|
|
||||||
@ -214,6 +258,7 @@ export class PortfolioService {
|
|||||||
investment: lastInvestment?.investment ?? 0
|
investment: lastInvestment?.investment ?? 0
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return sortBy(investments, (investment) => {
|
return sortBy(investments, (investment) => {
|
||||||
return investment.date;
|
return investment.date;
|
||||||
@ -273,7 +318,6 @@ export class PortfolioService {
|
|||||||
.filter((timelineItem) => timelineItem !== null)
|
.filter((timelineItem) => timelineItem !== null)
|
||||||
.map((timelineItem) => ({
|
.map((timelineItem) => ({
|
||||||
date: timelineItem.date,
|
date: timelineItem.date,
|
||||||
marketPrice: timelineItem.value,
|
|
||||||
value: timelineItem.netPerformance.toNumber()
|
value: timelineItem.netPerformance.toNumber()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -394,7 +438,7 @@ export class PortfolioService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const value = item.quantity.mul(item.marketPrice);
|
const value = item.quantity.mul(item.marketPrice ?? 0);
|
||||||
const symbolProfile = symbolProfileMap[item.symbol];
|
const symbolProfile = symbolProfileMap[item.symbol];
|
||||||
const dataProviderResponse = dataProviderResponses[item.symbol];
|
const dataProviderResponse = dataProviderResponses[item.symbol];
|
||||||
|
|
||||||
@ -442,6 +486,7 @@ export class PortfolioService {
|
|||||||
sectors: symbolProfile.sectors,
|
sectors: symbolProfile.sectors,
|
||||||
symbol: item.symbol,
|
symbol: item.symbol,
|
||||||
transactionCount: item.transactionCount,
|
transactionCount: item.transactionCount,
|
||||||
|
url: symbolProfile.url,
|
||||||
value: value.toNumber()
|
value: value.toNumber()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -658,7 +703,7 @@ export class PortfolioService {
|
|||||||
netPerformancePercent: position.netPerformancePercentage?.toNumber(),
|
netPerformancePercent: position.netPerformancePercentage?.toNumber(),
|
||||||
quantity: quantity.toNumber(),
|
quantity: quantity.toNumber(),
|
||||||
value: this.exchangeRateDataService.toCurrency(
|
value: this.exchangeRateDataService.toCurrency(
|
||||||
quantity.mul(marketPrice).toNumber(),
|
quantity.mul(marketPrice ?? 0).toNumber(),
|
||||||
currency,
|
currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
)
|
)
|
||||||
@ -1290,6 +1335,10 @@ export class PortfolioService {
|
|||||||
|
|
||||||
if (filters.length === 0) {
|
if (filters.length === 0) {
|
||||||
currentAccounts = await this.accountService.getAccounts(userId);
|
currentAccounts = await this.accountService.getAccounts(userId);
|
||||||
|
} else if (filters.length === 1 && filters[0].type === 'ACCOUNT') {
|
||||||
|
currentAccounts = await this.accountService.accounts({
|
||||||
|
where: { id: filters[0].id }
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
const accountIds = uniq(
|
const accountIds = uniq(
|
||||||
orders.map(({ accountId }) => {
|
orders.map(({ accountId }) => {
|
||||||
|
@ -1,6 +1,9 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import { PROPERTY_COUPONS } from '@ghostfolio/common/config';
|
import {
|
||||||
|
DEFAULT_LANGUAGE_CODE,
|
||||||
|
PROPERTY_COUPONS
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
import { Coupon } from '@ghostfolio/common/interfaces';
|
import { Coupon } from '@ghostfolio/common/interfaces';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
@ -93,7 +96,11 @@ export class SubscriptionController {
|
|||||||
'SubscriptionController'
|
'SubscriptionController'
|
||||||
);
|
);
|
||||||
|
|
||||||
res.redirect(`${this.configurationService.get('ROOT_URL')}/account`);
|
res.redirect(
|
||||||
|
`${this.configurationService.get(
|
||||||
|
'ROOT_URL'
|
||||||
|
)}/${DEFAULT_LANGUAGE_CODE}/account`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('stripe/checkout-session')
|
@Post('stripe/checkout-session')
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Subscription } from '@prisma/client';
|
import { Subscription } from '@prisma/client';
|
||||||
@ -33,7 +34,9 @@ export class SubscriptionService {
|
|||||||
userId: string;
|
userId: string;
|
||||||
}) {
|
}) {
|
||||||
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
|
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
|
||||||
cancel_url: `${this.configurationService.get('ROOT_URL')}/account`,
|
cancel_url: `${this.configurationService.get(
|
||||||
|
'ROOT_URL'
|
||||||
|
)}/${DEFAULT_LANGUAGE_CODE}/account`,
|
||||||
client_reference_id: userId,
|
client_reference_id: userId,
|
||||||
line_items: [
|
line_items: [
|
||||||
{
|
{
|
||||||
|
@ -46,7 +46,6 @@ export class SymbolController {
|
|||||||
* Must be after /lookup
|
* Must be after /lookup
|
||||||
*/
|
*/
|
||||||
@Get(':dataSource/:symbol')
|
@Get(':dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getSymbolData(
|
public async getSymbolData(
|
||||||
|
@ -9,6 +9,10 @@ export class UpdateUserSettingDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
isRestrictedView?: boolean;
|
isRestrictedView?: boolean;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
language?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
locale?: string;
|
locale?: string;
|
||||||
|
@ -36,14 +36,7 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getUser(
|
public async getUser(
|
||||||
{
|
{ Account, id, permissions, Settings, subscription }: UserWithSettings,
|
||||||
Account,
|
|
||||||
alias,
|
|
||||||
id,
|
|
||||||
permissions,
|
|
||||||
Settings,
|
|
||||||
subscription
|
|
||||||
}: UserWithSettings,
|
|
||||||
aLocale = locale
|
aLocale = locale
|
||||||
): Promise<IUser> {
|
): Promise<IUser> {
|
||||||
const access = await this.prismaService.access.findMany({
|
const access = await this.prismaService.access.findMany({
|
||||||
@ -63,7 +56,6 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
alias,
|
|
||||||
id,
|
id,
|
||||||
permissions,
|
permissions,
|
||||||
subscription,
|
subscription,
|
||||||
@ -158,10 +150,6 @@ export class UserService {
|
|||||||
|
|
||||||
let currentPermissions = getPermissions(user.role);
|
let currentPermissions = getPermissions(user.role);
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
|
||||||
currentPermissions.push(permissions.accessFearAndGreedIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.subscription?.type === 'Premium') {
|
if (user.subscription?.type === 'Premium') {
|
||||||
currentPermissions.push(permissions.reportDataGlitch);
|
currentPermissions.push(permissions.reportDataGlitch);
|
||||||
}
|
}
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
"LUNA1": "Terra",
|
"LUNA1": "Terra",
|
||||||
"UNI1": "Uniswap"
|
"LUNA2": "Terra",
|
||||||
|
"SGB1": "Songbird",
|
||||||
|
"UNI1": "Uniswap",
|
||||||
|
"UST": "TerraUSD"
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,24 @@
|
|||||||
import { Logger, ValidationPipe, VersioningType } from '@nestjs/common';
|
import { Logger, ValidationPipe, VersioningType } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
|
||||||
import { AppModule } from './app/app.module';
|
import { AppModule } from './app/app.module';
|
||||||
import { environment } from './environments/environment';
|
import { environment } from './environments/environment';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const configApp = await NestFactory.create(AppModule);
|
||||||
|
const configService = configApp.get<ConfigService>(ConfigService);
|
||||||
|
|
||||||
|
const NODE_ENV =
|
||||||
|
configService.get<'development' | 'production'>('NODE_ENV') ??
|
||||||
|
'development';
|
||||||
|
|
||||||
|
const app = await NestFactory.create(AppModule, {
|
||||||
|
logger:
|
||||||
|
NODE_ENV === 'production'
|
||||||
|
? ['error', 'log', 'warn']
|
||||||
|
: ['debug', 'error', 'log', 'verbose', 'warn']
|
||||||
|
});
|
||||||
app.enableCors();
|
app.enableCors();
|
||||||
app.enableVersioning({
|
app.enableVersioning({
|
||||||
defaultVersion: '1',
|
defaultVersion: '1',
|
||||||
@ -20,11 +33,11 @@ async function bootstrap() {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const host = process.env.HOST || 'localhost';
|
const HOST = configService.get<string>('HOST') || '0.0.0.0';
|
||||||
const port = process.env.PORT || 3333;
|
const PORT = configService.get<number>('PORT') || 3333;
|
||||||
await app.listen(port, host, () => {
|
await app.listen(PORT, HOST, () => {
|
||||||
logLogo();
|
logLogo();
|
||||||
Logger.log(`Listening at http://${host}:${port}`);
|
Logger.log(`Listening at http://${HOST}:${PORT}`);
|
||||||
Logger.log('');
|
Logger.log('');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -15,7 +15,9 @@ export class ConfigurationService {
|
|||||||
BASE_CURRENCY: str({ default: 'USD' }),
|
BASE_CURRENCY: str({ default: 'USD' }),
|
||||||
CACHE_TTL: num({ default: 1 }),
|
CACHE_TTL: num({ default: 1 }),
|
||||||
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
|
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
|
||||||
DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }),
|
DATA_SOURCES: json({
|
||||||
|
default: [DataSource.GHOSTFOLIO, DataSource.YAHOO]
|
||||||
|
}),
|
||||||
ENABLE_FEATURE_BLOG: bool({ default: false }),
|
ENABLE_FEATURE_BLOG: bool({ default: false }),
|
||||||
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
|
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
|
||||||
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
|
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
|
||||||
@ -31,10 +33,10 @@ export class ConfigurationService {
|
|||||||
GOOGLE_SHEETS_ACCOUNT: str({ default: '' }),
|
GOOGLE_SHEETS_ACCOUNT: str({ default: '' }),
|
||||||
GOOGLE_SHEETS_ID: str({ default: '' }),
|
GOOGLE_SHEETS_ID: str({ default: '' }),
|
||||||
GOOGLE_SHEETS_PRIVATE_KEY: str({ default: '' }),
|
GOOGLE_SHEETS_PRIVATE_KEY: str({ default: '' }),
|
||||||
HOST: host({ default: 'localhost' }),
|
HOST: host({ default: '0.0.0.0' }),
|
||||||
JWT_SECRET_KEY: str({}),
|
JWT_SECRET_KEY: str({}),
|
||||||
|
MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
|
||||||
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
|
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
|
||||||
MAX_ORDERS_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
|
|
||||||
PORT: port({ default: 3333 }),
|
PORT: port({ default: 3333 }),
|
||||||
RAKUTEN_RAPID_API_KEY: str({ default: '' }),
|
RAKUTEN_RAPID_API_KEY: str({ default: '' }),
|
||||||
REDIS_HOST: host({ default: 'localhost' }),
|
REDIS_HOST: host({ default: 'localhost' }),
|
||||||
|
@ -10,6 +10,7 @@ import ms from 'ms';
|
|||||||
|
|
||||||
import { DataGatheringProcessor } from './data-gathering.processor';
|
import { DataGatheringProcessor } from './data-gathering.processor';
|
||||||
import { ExchangeRateDataModule } from './exchange-rate-data.module';
|
import { ExchangeRateDataModule } from './exchange-rate-data.module';
|
||||||
|
import { MarketDataModule } from './market-data.module';
|
||||||
import { SymbolProfileModule } from './symbol-profile.module';
|
import { SymbolProfileModule } from './symbol-profile.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@ -25,6 +26,7 @@ import { SymbolProfileModule } from './symbol-profile.module';
|
|||||||
DataEnhancerModule,
|
DataEnhancerModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
|
MarketDataModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
SymbolProfileModule
|
SymbolProfileModule
|
||||||
],
|
],
|
||||||
|
@ -17,6 +17,7 @@ import { DataProviderService } from './data-provider/data-provider.service';
|
|||||||
import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface';
|
import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface';
|
||||||
import { ExchangeRateDataService } from './exchange-rate-data.service';
|
import { ExchangeRateDataService } from './exchange-rate-data.service';
|
||||||
import { IDataGatheringItem } from './interfaces/interfaces';
|
import { IDataGatheringItem } from './interfaces/interfaces';
|
||||||
|
import { MarketDataService } from './market-data.service';
|
||||||
import { PrismaService } from './prisma.service';
|
import { PrismaService } from './prisma.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -28,6 +29,7 @@ export class DataGatheringService {
|
|||||||
private readonly dataGatheringQueue: Queue,
|
private readonly dataGatheringQueue: Queue,
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
|
private readonly marketDataService: MarketDataService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly symbolProfileService: SymbolProfileService
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
) {}
|
) {}
|
||||||
@ -56,6 +58,8 @@ export class DataGatheringService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async gatherSymbol({ dataSource, symbol }: UniqueAsset) {
|
public async gatherSymbol({ dataSource, symbol }: UniqueAsset) {
|
||||||
|
await this.marketDataService.deleteMany({ dataSource, symbol });
|
||||||
|
|
||||||
const symbols = (await this.getSymbolsMax()).filter((dataGatheringItem) => {
|
const symbols = (await this.getSymbolsMax()).filter((dataGatheringItem) => {
|
||||||
return (
|
return (
|
||||||
dataGatheringItem.dataSource === dataSource &&
|
dataGatheringItem.dataSource === dataSource &&
|
||||||
|
@ -168,6 +168,7 @@ export class DataProviderService {
|
|||||||
const response: {
|
const response: {
|
||||||
[symbol: string]: IDataProviderResponse;
|
[symbol: string]: IDataProviderResponse;
|
||||||
} = {};
|
} = {};
|
||||||
|
const startTimeTotal = performance.now();
|
||||||
|
|
||||||
const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
|
const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
|
||||||
|
|
||||||
@ -176,25 +177,59 @@ export class DataProviderService {
|
|||||||
for (const [dataSource, dataGatheringItems] of Object.entries(
|
for (const [dataSource, dataGatheringItems] of Object.entries(
|
||||||
itemsGroupedByDataSource
|
itemsGroupedByDataSource
|
||||||
)) {
|
)) {
|
||||||
|
const dataProvider = this.getDataProvider(DataSource[dataSource]);
|
||||||
|
|
||||||
const symbols = dataGatheringItems.map((dataGatheringItem) => {
|
const symbols = dataGatheringItems.map((dataGatheringItem) => {
|
||||||
return dataGatheringItem.symbol;
|
return dataGatheringItem.symbol;
|
||||||
});
|
});
|
||||||
|
|
||||||
const promise = Promise.resolve(
|
const maximumNumberOfSymbolsPerRequest =
|
||||||
this.getDataProvider(DataSource[dataSource]).getQuotes(symbols)
|
dataProvider.getMaxNumberOfSymbolsPerRequest?.() ??
|
||||||
|
Number.MAX_SAFE_INTEGER;
|
||||||
|
for (
|
||||||
|
let i = 0;
|
||||||
|
i < symbols.length;
|
||||||
|
i += maximumNumberOfSymbolsPerRequest
|
||||||
|
) {
|
||||||
|
const startTimeDataSource = performance.now();
|
||||||
|
|
||||||
|
const symbolsChunk = symbols.slice(
|
||||||
|
i,
|
||||||
|
i + maximumNumberOfSymbolsPerRequest
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const promise = Promise.resolve(dataProvider.getQuotes(symbolsChunk));
|
||||||
|
|
||||||
promises.push(
|
promises.push(
|
||||||
promise.then((result) => {
|
promise.then((result) => {
|
||||||
for (const [symbol, dataProviderResponse] of Object.entries(result)) {
|
for (const [symbol, dataProviderResponse] of Object.entries(
|
||||||
|
result
|
||||||
|
)) {
|
||||||
response[symbol] = dataProviderResponse;
|
response[symbol] = dataProviderResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Logger.debug(
|
||||||
|
`Fetched ${symbolsChunk.length} quotes from ${dataSource} in ${(
|
||||||
|
(performance.now() - startTimeDataSource) /
|
||||||
|
1000
|
||||||
|
).toFixed(3)} seconds`
|
||||||
|
);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
|
|
||||||
|
Logger.debug('------------------------------------------------');
|
||||||
|
Logger.debug(
|
||||||
|
`Fetched ${items.length} quotes in ${(
|
||||||
|
(performance.now() - startTimeTotal) /
|
||||||
|
1000
|
||||||
|
).toFixed(3)} seconds`
|
||||||
|
);
|
||||||
|
Logger.debug('================================================');
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -81,6 +81,12 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getMaxNumberOfSymbolsPerRequest() {
|
||||||
|
// It is not recommended using more than 15-20 tickers per request
|
||||||
|
// https://eodhistoricaldata.com/financial-apis/live-realtime-stocks-api
|
||||||
|
return 20;
|
||||||
|
}
|
||||||
|
|
||||||
public getName(): DataSource {
|
public getName(): DataSource {
|
||||||
return DataSource.EOD_HISTORICAL_DATA;
|
return DataSource.EOD_HISTORICAL_DATA;
|
||||||
}
|
}
|
||||||
|
@ -20,6 +20,8 @@ export interface DataProviderInterface {
|
|||||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||||
}>; // TODO: Return only one symbol
|
}>; // TODO: Return only one symbol
|
||||||
|
|
||||||
|
getMaxNumberOfSymbolsPerRequest?(): number;
|
||||||
|
|
||||||
getName(): DataSource;
|
getName(): DataSource;
|
||||||
|
|
||||||
getQuotes(
|
getQuotes(
|
||||||
|
@ -37,10 +37,15 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) {
|
public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) {
|
||||||
const symbol = aYahooFinanceSymbol.replace(
|
let symbol = aYahooFinanceSymbol.replace(
|
||||||
new RegExp(`-${this.baseCurrency}$`),
|
new RegExp(`-${this.baseCurrency}$`),
|
||||||
this.baseCurrency
|
this.baseCurrency
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (symbol.includes('=X') && !symbol.includes(this.baseCurrency)) {
|
||||||
|
symbol = `${this.baseCurrency}${symbol}`;
|
||||||
|
}
|
||||||
|
|
||||||
return symbol.replace('=X', '');
|
return symbol.replace('=X', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -181,6 +186,9 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
if (symbol === 'USDGBp') {
|
if (symbol === 'USDGBp') {
|
||||||
// Convert GPB to GBp (pence)
|
// Convert GPB to GBp (pence)
|
||||||
marketPrice = new Big(marketPrice).mul(100).toNumber();
|
marketPrice = new Big(marketPrice).mul(100).toNumber();
|
||||||
|
} else if (symbol === 'USDILA') {
|
||||||
|
// Convert ILS to ILA
|
||||||
|
marketPrice = new Big(marketPrice).mul(100).toNumber();
|
||||||
}
|
}
|
||||||
|
|
||||||
response[symbol][format(historicalItem.date, DATE_FORMAT)] = {
|
response[symbol][format(historicalItem.date, DATE_FORMAT)] = {
|
||||||
@ -200,6 +208,10 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getMaxNumberOfSymbolsPerRequest() {
|
||||||
|
return 50;
|
||||||
|
}
|
||||||
|
|
||||||
public getName(): DataSource {
|
public getName(): DataSource {
|
||||||
return DataSource.YAHOO;
|
return DataSource.YAHOO;
|
||||||
}
|
}
|
||||||
@ -243,9 +255,31 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
.mul(100)
|
.mul(100)
|
||||||
.toNumber()
|
.toNumber()
|
||||||
};
|
};
|
||||||
|
} else if (
|
||||||
|
symbol === 'USDILS' &&
|
||||||
|
yahooFinanceSymbols.includes('USDILA=X')
|
||||||
|
) {
|
||||||
|
// Convert ILS to ILA
|
||||||
|
response['USDILA'] = {
|
||||||
|
...response[symbol],
|
||||||
|
currency: 'ILA',
|
||||||
|
marketPrice: new Big(response[symbol].marketPrice)
|
||||||
|
.mul(100)
|
||||||
|
.toNumber()
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (yahooFinanceSymbols.includes('USDUSX=X')) {
|
||||||
|
// Convert USD to USX (cent)
|
||||||
|
response['USDUSX'] = {
|
||||||
|
currency: 'USX',
|
||||||
|
dataSource: this.getName(),
|
||||||
|
marketPrice: new Big(1).mul(100).toNumber(),
|
||||||
|
marketState: 'open'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'YahooFinanceService');
|
Logger.error(error, 'YahooFinanceService');
|
||||||
|
@ -22,9 +22,7 @@ export class ExchangeRateDataService {
|
|||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly propertyService: PropertyService
|
private readonly propertyService: PropertyService
|
||||||
) {
|
) {}
|
||||||
this.initialize();
|
|
||||||
}
|
|
||||||
|
|
||||||
public getCurrencies() {
|
public getCurrencies() {
|
||||||
return this.currencies?.length > 0 ? this.currencies : [this.baseCurrency];
|
return this.currencies?.length > 0 ? this.currencies : [this.baseCurrency];
|
||||||
@ -122,15 +120,6 @@ export class ExchangeRateDataService {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasNaN = Object.values(this.exchangeRates).some((exchangeRate) => {
|
|
||||||
return isNaN(exchangeRate);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hasNaN) {
|
|
||||||
// Reinitialize if data is not loaded correctly
|
|
||||||
this.initialize();
|
|
||||||
}
|
|
||||||
|
|
||||||
let factor = 1;
|
let factor = 1;
|
||||||
|
|
||||||
if (aFromCurrency !== aToCurrency) {
|
if (aFromCurrency !== aToCurrency) {
|
||||||
|
@ -6,7 +6,7 @@ export interface Environment extends CleanedEnvAccessors {
|
|||||||
BASE_CURRENCY: string;
|
BASE_CURRENCY: string;
|
||||||
CACHE_TTL: number;
|
CACHE_TTL: number;
|
||||||
DATA_SOURCE_PRIMARY: string;
|
DATA_SOURCE_PRIMARY: string;
|
||||||
DATA_SOURCES: string | string[]; // string is not correct, error in envalid?
|
DATA_SOURCES: string[];
|
||||||
ENABLE_FEATURE_BLOG: boolean;
|
ENABLE_FEATURE_BLOG: boolean;
|
||||||
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
|
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
|
||||||
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
|
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
|
||||||
@ -23,8 +23,8 @@ export interface Environment extends CleanedEnvAccessors {
|
|||||||
GOOGLE_SHEETS_ID: string;
|
GOOGLE_SHEETS_ID: string;
|
||||||
GOOGLE_SHEETS_PRIVATE_KEY: string;
|
GOOGLE_SHEETS_PRIVATE_KEY: string;
|
||||||
JWT_SECRET_KEY: string;
|
JWT_SECRET_KEY: string;
|
||||||
|
MAX_ACTIVITIES_TO_IMPORT: number;
|
||||||
MAX_ITEM_IN_CACHE: number;
|
MAX_ITEM_IN_CACHE: number;
|
||||||
MAX_ORDERS_TO_IMPORT: number;
|
|
||||||
PORT: number;
|
PORT: number;
|
||||||
RAKUTEN_RAPID_API_KEY: string;
|
RAKUTEN_RAPID_API_KEY: string;
|
||||||
REDIS_HOST: string;
|
REDIS_HOST: string;
|
||||||
|
@ -1,15 +1,25 @@
|
|||||||
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
OnModuleDestroy,
|
||||||
|
OnModuleInit
|
||||||
|
} from '@nestjs/common';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PrismaService
|
export class PrismaService
|
||||||
extends PrismaClient
|
extends PrismaClient
|
||||||
implements OnModuleInit, OnModuleDestroy {
|
implements OnModuleInit, OnModuleDestroy
|
||||||
async onModuleInit() {
|
{
|
||||||
|
public async onModuleInit() {
|
||||||
|
try {
|
||||||
await this.$connect();
|
await this.$connect();
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error, 'PrismaService');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleDestroy() {
|
public async onModuleDestroy() {
|
||||||
await this.$disconnect();
|
await this.$disconnect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -115,9 +115,16 @@ export class SymbolProfileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
item.name = item.SymbolProfileOverrides?.name ?? item.name;
|
item.name = item.SymbolProfileOverrides?.name ?? item.name;
|
||||||
item.sectors =
|
|
||||||
(item.SymbolProfileOverrides.sectors as unknown as Sector[]) ??
|
if (
|
||||||
item.sectors;
|
(item.SymbolProfileOverrides.sectors as unknown as Sector[])?.length >
|
||||||
|
0
|
||||||
|
) {
|
||||||
|
item.sectors = item.SymbolProfileOverrides
|
||||||
|
.sectors as unknown as Sector[];
|
||||||
|
}
|
||||||
|
|
||||||
|
item.url = item.SymbolProfileOverrides?.url ?? item.url;
|
||||||
|
|
||||||
delete item.SymbolProfileOverrides;
|
delete item.SymbolProfileOverrides;
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
export default {
|
||||||
displayName: 'client',
|
displayName: 'client',
|
||||||
|
|
||||||
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
|
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
|
||||||
@ -18,5 +18,5 @@ module.exports = {
|
|||||||
'^.+.(ts|mjs|js|html)$': 'jest-preset-angular'
|
'^.+.(ts|mjs|js|html)$': 'jest-preset-angular'
|
||||||
},
|
},
|
||||||
transformIgnorePatterns: ['node_modules/(?!.*.mjs$)'],
|
transformIgnorePatterns: ['node_modules/(?!.*.mjs$)'],
|
||||||
preset: '../../jest.preset.ts'
|
preset: '../../jest.preset.js'
|
||||||
};
|
};
|
||||||
|
@ -5,9 +5,6 @@ import { getDateFormatString } from '@ghostfolio/common/helper';
|
|||||||
import { format, parse } from 'date-fns';
|
import { format, parse } from 'date-fns';
|
||||||
|
|
||||||
export class CustomDateAdapter extends NativeDateAdapter {
|
export class CustomDateAdapter extends NativeDateAdapter {
|
||||||
/**
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
public constructor(
|
public constructor(
|
||||||
@Inject(MAT_DATE_LOCALE) public locale: string,
|
@Inject(MAT_DATE_LOCALE) public locale: string,
|
||||||
@Inject(forwardRef(() => MAT_DATE_LOCALE)) matDateLocale: string,
|
@Inject(forwardRef(() => MAT_DATE_LOCALE)) matDateLocale: string,
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
import { RouterModule, Routes } from '@angular/router';
|
import { RouterModule, Routes, TitleStrategy } from '@angular/router';
|
||||||
|
import { PageTitleStrategy } from '@ghostfolio/client/services/page-title.strategy';
|
||||||
|
|
||||||
import { ModulePreloadService } from './core/module-preload.service';
|
import { ModulePreloadService } from './core/module-preload.service';
|
||||||
|
|
||||||
@ -16,6 +17,13 @@ const routes: Routes = [
|
|||||||
(m) => m.ChangelogPageModule
|
(m) => m.ChangelogPageModule
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'about/privacy-policy',
|
||||||
|
loadChildren: () =>
|
||||||
|
import('./pages/about/privacy-policy/privacy-policy-page.module').then(
|
||||||
|
(m) => m.PrivacyPolicyPageModule
|
||||||
|
)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'account',
|
path: 'account',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
@ -46,26 +54,57 @@ const routes: Routes = [
|
|||||||
import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule)
|
import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'de/blog/2021/07/hallo-ghostfolio',
|
path: 'blog/2021/07/hallo-ghostfolio',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import(
|
import(
|
||||||
'./pages/blog/2021/07/hallo-ghostfolio/hallo-ghostfolio-page.module'
|
'./pages/blog/2021/07/hallo-ghostfolio/hallo-ghostfolio-page.module'
|
||||||
).then((m) => m.HalloGhostfolioPageModule)
|
).then((m) => m.HalloGhostfolioPageModule)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'en/blog/2021/07/hello-ghostfolio',
|
path: 'blog/2021/07/hello-ghostfolio',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import(
|
import(
|
||||||
'./pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.module'
|
'./pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.module'
|
||||||
).then((m) => m.HelloGhostfolioPageModule)
|
).then((m) => m.HelloGhostfolioPageModule)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'en/blog/2022/01/ghostfolio-first-months-in-open-source',
|
path: 'blog/2022/01/ghostfolio-first-months-in-open-source',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import(
|
import(
|
||||||
'./pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.module'
|
'./pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.module'
|
||||||
).then((m) => m.FirstMonthsInOpenSourcePageModule)
|
).then((m) => m.FirstMonthsInOpenSourcePageModule)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'blog/2022/07/ghostfolio-meets-internet-identity',
|
||||||
|
loadChildren: () =>
|
||||||
|
import(
|
||||||
|
'./pages/blog/2022/07/ghostfolio-meets-internet-identity/ghostfolio-meets-internet-identity-page.module'
|
||||||
|
).then((m) => m.GhostfolioMeetsInternetIdentityPageModule)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'blog/2022/07/how-do-i-get-my-finances-in-order',
|
||||||
|
loadChildren: () =>
|
||||||
|
import(
|
||||||
|
'./pages/blog/2022/07/how-do-i-get-my-finances-in-order/how-do-i-get-my-finances-in-order-page.module'
|
||||||
|
).then((m) => m.HowDoIGetMyFinancesInOrderPageModule)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'blog/2022/08/500-stars-on-github',
|
||||||
|
loadChildren: () =>
|
||||||
|
import(
|
||||||
|
'./pages/blog/2022/08/500-stars-on-github/500-stars-on-github-page.module'
|
||||||
|
).then((m) => m.FiveHundredStarsOnGitHubPageModule)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'demo',
|
||||||
|
loadChildren: () =>
|
||||||
|
import('./pages/demo/demo-page.module').then((m) => m.DemoPageModule)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'faq',
|
||||||
|
loadChildren: () =>
|
||||||
|
import('./pages/faq/faq-page.module').then((m) => m.FaqPageModule)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'features',
|
path: 'features',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
@ -78,6 +117,13 @@ const routes: Routes = [
|
|||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('./pages/home/home-page.module').then((m) => m.HomePageModule)
|
import('./pages/home/home-page.module').then((m) => m.HomePageModule)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'markets',
|
||||||
|
loadChildren: () =>
|
||||||
|
import('./pages/markets/markets-page.module').then(
|
||||||
|
(m) => m.MarketsPageModule
|
||||||
|
)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'p',
|
path: 'p',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
@ -120,6 +166,13 @@ const routes: Routes = [
|
|||||||
(m) => m.FirePageModule
|
(m) => m.FirePageModule
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'portfolio/holdings',
|
||||||
|
loadChildren: () =>
|
||||||
|
import('./pages/portfolio/holdings/holdings-page.module').then(
|
||||||
|
(m) => m.HoldingsPageModule
|
||||||
|
)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'portfolio/report',
|
path: 'portfolio/report',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
@ -189,7 +242,10 @@ const routes: Routes = [
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
providers: [ModulePreloadService],
|
providers: [
|
||||||
|
ModulePreloadService,
|
||||||
|
{ provide: TitleStrategy, useClass: PageTitleStrategy }
|
||||||
|
],
|
||||||
exports: [RouterModule]
|
exports: [RouterModule]
|
||||||
})
|
})
|
||||||
export class AppRoutingModule {}
|
export class AppRoutingModule {}
|
||||||
|
@ -15,13 +15,17 @@
|
|||||||
>
|
>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-8 offset-md-2 text-center">
|
<div class="col-md-8 offset-md-2 text-center">
|
||||||
<a *ngIf="canCreateAccount" class="text-center" [routerLink]="['/']">
|
<a
|
||||||
|
*ngIf="canCreateAccount"
|
||||||
|
class="text-center"
|
||||||
|
[routerLink]="['/register']"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="cursor-pointer d-inline-block info-message px-3 py-2"
|
class="cursor-pointer d-inline-block info-message px-3 py-2"
|
||||||
(click)="onCreateAccount()"
|
(click)="onCreateAccount()"
|
||||||
>
|
>
|
||||||
<span i18n>You are using the Live Demo.</span>
|
<span>You are using the Live Demo.</span>
|
||||||
<a class="ml-2" href="#" i18n>Create Account</a>
|
<span class="a ml-2">Create Account</span>
|
||||||
</div></a
|
</div></a
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
@ -17,7 +17,7 @@
|
|||||||
border-radius: 2rem;
|
border-radius: 2rem;
|
||||||
font-size: 80%;
|
font-size: 80%;
|
||||||
|
|
||||||
a {
|
.a {
|
||||||
color: rgba(var(--palette-primary-500), 1);
|
color: rgba(var(--palette-primary-500), 1);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import { Platform } from '@angular/cdk/platform';
|
import { Platform } from '@angular/cdk/platform';
|
||||||
import { HttpClientModule } from '@angular/common/http';
|
import { HttpClientModule } from '@angular/common/http';
|
||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
|
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
||||||
|
import { MatChipsModule } from '@angular/material/chips';
|
||||||
import {
|
import {
|
||||||
DateAdapter,
|
DateAdapter,
|
||||||
MAT_DATE_FORMATS,
|
MAT_DATE_FORMATS,
|
||||||
@ -38,6 +40,8 @@ export function NgxStripeFactory(): string {
|
|||||||
GfHeaderModule,
|
GfHeaderModule,
|
||||||
HttpClientModule,
|
HttpClientModule,
|
||||||
MarkdownModule.forRoot(),
|
MarkdownModule.forRoot(),
|
||||||
|
MatAutocompleteModule,
|
||||||
|
MatChipsModule,
|
||||||
MaterialCssVarsModule.forRoot({
|
MaterialCssVarsModule.forRoot({
|
||||||
darkThemeClass: 'is-dark-theme',
|
darkThemeClass: 'is-dark-theme',
|
||||||
isAutoContrast: true,
|
isAutoContrast: true,
|
||||||
|
@ -21,8 +21,10 @@
|
|||||||
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
|
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
|
||||||
<ng-container *ngIf="element.type === 'PUBLIC'">
|
<ng-container *ngIf="element.type === 'PUBLIC'">
|
||||||
<ion-icon class="mr-1" name="link-outline"></ion-icon>
|
<ion-icon class="mr-1" name="link-outline"></ion-icon>
|
||||||
<a href="{{ baseUrl }}/p/{{ element.id }}" target="_blank"
|
<a
|
||||||
>{{ baseUrl }}/p/{{ element.id }}</a
|
href="{{ baseUrl }}/{{ defaultLanguageCode }}/p/{{ element.id }}"
|
||||||
|
target="_blank"
|
||||||
|
>{{ baseUrl }}/{{ defaultLanguageCode }}/p/{{ element.id }}</a
|
||||||
>
|
>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</td>
|
</td>
|
||||||
@ -41,8 +43,8 @@
|
|||||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||||
</button>
|
</button>
|
||||||
<mat-menu #transactionMenu="matMenu" xPosition="before">
|
<mat-menu #transactionMenu="matMenu" xPosition="before">
|
||||||
<button i18n mat-menu-item (click)="onDeleteAccess(element.id)">
|
<button mat-menu-item (click)="onDeleteAccess(element.id)">
|
||||||
Revoke
|
<ng-container i18n>Revoke</ng-container>
|
||||||
</button>
|
</button>
|
||||||
</mat-menu>
|
</mat-menu>
|
||||||
</td>
|
</td>
|
||||||
|
@ -8,6 +8,7 @@ import {
|
|||||||
Output
|
Output
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { MatTableDataSource } from '@angular/material/table';
|
import { MatTableDataSource } from '@angular/material/table';
|
||||||
|
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||||
import { Access } from '@ghostfolio/common/interfaces';
|
import { Access } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@ -24,6 +25,7 @@ export class AccessTableComponent implements OnChanges, OnInit {
|
|||||||
|
|
||||||
public baseUrl = window.location.origin;
|
public baseUrl = window.location.origin;
|
||||||
public dataSource: MatTableDataSource<Access>;
|
public dataSource: MatTableDataSource<Access>;
|
||||||
|
public defaultLanguageCode = DEFAULT_LANGUAGE_CODE;
|
||||||
public displayedColumns = [];
|
public displayedColumns = [];
|
||||||
|
|
||||||
public constructor() {}
|
public constructor() {}
|
||||||
@ -44,7 +46,7 @@ export class AccessTableComponent implements OnChanges, OnInit {
|
|||||||
|
|
||||||
public onDeleteAccess(aId: string) {
|
public onDeleteAccess(aId: string) {
|
||||||
const confirmation = confirm(
|
const confirmation = confirm(
|
||||||
'Do you really want to revoke this granted access?'
|
$localize`Do you really want to revoke this granted access?`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (confirmation) {
|
if (confirmation) {
|
||||||
|
@ -10,7 +10,6 @@ import { AccessTableComponent } from './access-table.component';
|
|||||||
declarations: [AccessTableComponent],
|
declarations: [AccessTableComponent],
|
||||||
exports: [AccessTableComponent],
|
exports: [AccessTableComponent],
|
||||||
imports: [CommonModule, MatButtonModule, MatMenuModule, MatTableModule],
|
imports: [CommonModule, MatButtonModule, MatMenuModule, MatTableModule],
|
||||||
providers: [],
|
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
export class GfPortfolioAccessTableModule {}
|
export class GfPortfolioAccessTableModule {}
|
||||||
|
@ -0,0 +1,7 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
.mat-dialog-content {
|
||||||
|
max-height: unset;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,112 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
ChangeDetectorRef,
|
||||||
|
Component,
|
||||||
|
Inject,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit
|
||||||
|
} from '@angular/core';
|
||||||
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
|
import { downloadAsFile } from '@ghostfolio/common/helper';
|
||||||
|
import { User } from '@ghostfolio/common/interfaces';
|
||||||
|
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||||
|
import { AccountType } from '@prisma/client';
|
||||||
|
import { format, parseISO } from 'date-fns';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
|
import { AccountDetailDialogParams } from './interfaces/interfaces';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
host: { class: 'd-flex flex-column h-100' },
|
||||||
|
selector: 'gf-account-detail-dialog',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
templateUrl: 'account-detail-dialog.html',
|
||||||
|
styleUrls: ['./account-detail-dialog.component.scss']
|
||||||
|
})
|
||||||
|
export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||||
|
public accountType: AccountType;
|
||||||
|
public name: string;
|
||||||
|
public orders: OrderWithAccount[];
|
||||||
|
public platformName: string;
|
||||||
|
public user: User;
|
||||||
|
public valueInBaseCurrency: number;
|
||||||
|
|
||||||
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
|
@Inject(MAT_DIALOG_DATA) public data: AccountDetailDialogParams,
|
||||||
|
private dataService: DataService,
|
||||||
|
public dialogRef: MatDialogRef<AccountDetailDialog>,
|
||||||
|
private userService: UserService
|
||||||
|
) {
|
||||||
|
this.userService.stateChanged
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((state) => {
|
||||||
|
if (state?.user) {
|
||||||
|
this.user = state.user;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnInit(): void {
|
||||||
|
this.dataService
|
||||||
|
.fetchAccount(this.data.accountId)
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(({ accountType, name, Platform, valueInBaseCurrency }) => {
|
||||||
|
this.accountType = accountType;
|
||||||
|
this.name = name;
|
||||||
|
this.platformName = Platform?.name;
|
||||||
|
this.valueInBaseCurrency = valueInBaseCurrency;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.dataService
|
||||||
|
.fetchActivities({
|
||||||
|
filters: [{ id: this.data.accountId, type: 'ACCOUNT' }]
|
||||||
|
})
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(({ activities }) => {
|
||||||
|
this.orders = activities;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public onClose(): void {
|
||||||
|
this.dialogRef.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public onExport() {
|
||||||
|
this.dataService
|
||||||
|
.fetchExport(
|
||||||
|
this.orders.map((order) => {
|
||||||
|
return order.id;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((data) => {
|
||||||
|
downloadAsFile({
|
||||||
|
content: data,
|
||||||
|
fileName: `ghostfolio-export-${this.name
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.toLowerCase()}-${format(
|
||||||
|
parseISO(data.meta.date),
|
||||||
|
'yyyyMMddHHmm'
|
||||||
|
)}.json`,
|
||||||
|
format: 'json'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.unsubscribeSubject.next();
|
||||||
|
this.unsubscribeSubject.complete();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,56 @@
|
|||||||
|
<gf-dialog-header
|
||||||
|
mat-dialog-title
|
||||||
|
position="center"
|
||||||
|
[deviceType]="data.deviceType"
|
||||||
|
[title]="name"
|
||||||
|
(closeButtonClicked)="onClose()"
|
||||||
|
></gf-dialog-header>
|
||||||
|
|
||||||
|
<div class="flex-grow-1" mat-dialog-content>
|
||||||
|
<div class="container p-0">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 d-flex justify-content-center mb-3">
|
||||||
|
<gf-value
|
||||||
|
size="large"
|
||||||
|
[currency]="user?.settings?.baseCurrency"
|
||||||
|
[locale]="user?.settings?.locale"
|
||||||
|
[value]="valueInBaseCurrency"
|
||||||
|
></gf-value>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-6 mb-3">
|
||||||
|
<gf-value size="medium" [value]="accountType">Account Type</gf-value>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 mb-3">
|
||||||
|
<gf-value size="medium" [value]="platformName">Platform</gf-value>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="orders?.length > 0" class="row">
|
||||||
|
<div class="col mb-3">
|
||||||
|
<div class="h5 mb-0" i18n>Activities</div>
|
||||||
|
<gf-activities-table
|
||||||
|
[activities]="orders"
|
||||||
|
[baseCurrency]="user?.settings?.baseCurrency"
|
||||||
|
[deviceType]="data.deviceType"
|
||||||
|
[hasPermissionToCreateActivity]="false"
|
||||||
|
[hasPermissionToExportActivities]="!hasImpersonationId"
|
||||||
|
[hasPermissionToFilter]="false"
|
||||||
|
[hasPermissionToImportActivities]="false"
|
||||||
|
[hasPermissionToOpenDetails]="false"
|
||||||
|
[locale]="user?.settings?.locale"
|
||||||
|
[showActions]="false"
|
||||||
|
(export)="onExport()"
|
||||||
|
></gf-activities-table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<gf-dialog-footer
|
||||||
|
mat-dialog-actions
|
||||||
|
[deviceType]="data.deviceType"
|
||||||
|
(closeButtonClicked)="onClose()"
|
||||||
|
></gf-dialog-footer>
|
@ -0,0 +1,27 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatDialogModule } from '@angular/material/dialog';
|
||||||
|
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
|
||||||
|
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
|
||||||
|
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
|
||||||
|
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||||
|
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||||
|
|
||||||
|
import { AccountDetailDialog } from './account-detail-dialog.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [AccountDetailDialog],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
GfActivitiesTableModule,
|
||||||
|
GfDialogFooterModule,
|
||||||
|
GfDialogHeaderModule,
|
||||||
|
GfValueModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatDialogModule,
|
||||||
|
NgxSkeletonLoaderModule
|
||||||
|
],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
|
})
|
||||||
|
export class GfAccountDetailDialogModule {}
|
@ -0,0 +1,5 @@
|
|||||||
|
export interface AccountDetailDialogParams {
|
||||||
|
accountId: string;
|
||||||
|
deviceType: string;
|
||||||
|
hasImpersonationId: boolean;
|
||||||
|
}
|
@ -19,13 +19,8 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="currency">
|
<ng-container matColumnDef="currency">
|
||||||
<th
|
<th *matHeaderCellDef class="d-none d-lg-table-cell px-1" mat-header-cell>
|
||||||
*matHeaderCellDef
|
<ng-container i18n>Currency</ng-container>
|
||||||
class="d-none d-lg-table-cell px-1"
|
|
||||||
i18n
|
|
||||||
mat-header-cell
|
|
||||||
>
|
|
||||||
Currency
|
|
||||||
</th>
|
</th>
|
||||||
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
|
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
|
||||||
{{ element.currency }}
|
{{ element.currency }}
|
||||||
@ -36,13 +31,8 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="platform">
|
<ng-container matColumnDef="platform">
|
||||||
<th
|
<th *matHeaderCellDef class="d-none d-lg-table-cell px-1" mat-header-cell>
|
||||||
*matHeaderCellDef
|
<ng-container i18n>Platform</ng-container>
|
||||||
class="d-none d-lg-table-cell px-1"
|
|
||||||
i18n
|
|
||||||
mat-header-cell
|
|
||||||
>
|
|
||||||
Platform
|
|
||||||
</th>
|
</th>
|
||||||
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
|
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
|
||||||
<div class="d-flex">
|
<div class="d-flex">
|
||||||
@ -65,7 +55,7 @@
|
|||||||
<ng-container matColumnDef="transactions">
|
<ng-container matColumnDef="transactions">
|
||||||
<th *matHeaderCellDef class="px-1 text-right" mat-header-cell>
|
<th *matHeaderCellDef class="px-1 text-right" mat-header-cell>
|
||||||
<span class="d-block d-sm-none">#</span>
|
<span class="d-block d-sm-none">#</span>
|
||||||
<span class="d-none d-sm-block" i18n>Transactions</span>
|
<span class="d-none d-sm-block" i18n>Activities</span>
|
||||||
</th>
|
</th>
|
||||||
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||||
<ng-container *ngIf="element.accountType === 'SECURITIES'">{{
|
<ng-container *ngIf="element.accountType === 'SECURITIES'">{{
|
||||||
@ -81,10 +71,9 @@
|
|||||||
<th
|
<th
|
||||||
*matHeaderCellDef
|
*matHeaderCellDef
|
||||||
class="d-none d-lg-table-cell px-1 text-right"
|
class="d-none d-lg-table-cell px-1 text-right"
|
||||||
i18n
|
|
||||||
mat-header-cell
|
mat-header-cell
|
||||||
>
|
>
|
||||||
Cash Balance
|
<ng-container i18n>Cash Balance</ng-container>
|
||||||
</th>
|
</th>
|
||||||
<td
|
<td
|
||||||
*matCellDef="let element"
|
*matCellDef="let element"
|
||||||
@ -116,10 +105,9 @@
|
|||||||
<th
|
<th
|
||||||
*matHeaderCellDef
|
*matHeaderCellDef
|
||||||
class="d-none d-lg-table-cell px-1 text-right"
|
class="d-none d-lg-table-cell px-1 text-right"
|
||||||
i18n
|
|
||||||
mat-header-cell
|
mat-header-cell
|
||||||
>
|
>
|
||||||
Value
|
<ng-container i18n>Value</ng-container>
|
||||||
</th>
|
</th>
|
||||||
<td
|
<td
|
||||||
*matCellDef="let element"
|
*matCellDef="let element"
|
||||||
@ -151,10 +139,9 @@
|
|||||||
<th
|
<th
|
||||||
*matHeaderCellDef
|
*matHeaderCellDef
|
||||||
class="d-lg-none d-xl-none px-1 text-right"
|
class="d-lg-none d-xl-none px-1 text-right"
|
||||||
i18n
|
|
||||||
mat-header-cell
|
mat-header-cell
|
||||||
>
|
>
|
||||||
Value
|
<ng-container i18n>Value</ng-container>
|
||||||
</th>
|
</th>
|
||||||
<td
|
<td
|
||||||
*matCellDef="let element"
|
*matCellDef="let element"
|
||||||
@ -212,7 +199,12 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
|
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
|
||||||
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
|
<tr
|
||||||
|
*matRowDef="let row; columns: displayedColumns"
|
||||||
|
class="cursor-pointer"
|
||||||
|
mat-row
|
||||||
|
(click)="onOpenAccountDetailDialog(row.id)"
|
||||||
|
></tr>
|
||||||
<tr
|
<tr
|
||||||
*matFooterRowDef="displayedColumns"
|
*matFooterRowDef="displayedColumns"
|
||||||
mat-footer-row
|
mat-footer-row
|
||||||
|
@ -9,6 +9,7 @@ import {
|
|||||||
Output
|
Output
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { MatTableDataSource } from '@angular/material/table';
|
import { MatTableDataSource } from '@angular/material/table';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
import { Account as AccountModel } from '@prisma/client';
|
import { Account as AccountModel } from '@prisma/client';
|
||||||
import { Subject, Subscription } from 'rxjs';
|
import { Subject, Subscription } from 'rxjs';
|
||||||
|
|
||||||
@ -39,7 +40,7 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
public constructor() {}
|
public constructor(private router: Router) {}
|
||||||
|
|
||||||
public ngOnInit() {}
|
public ngOnInit() {}
|
||||||
|
|
||||||
@ -68,13 +69,21 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onDeleteAccount(aId: string) {
|
public onDeleteAccount(aId: string) {
|
||||||
const confirmation = confirm('Do you really want to delete this account?');
|
const confirmation = confirm(
|
||||||
|
$localize`Do you really want to delete this account?`
|
||||||
|
);
|
||||||
|
|
||||||
if (confirmation) {
|
if (confirmation) {
|
||||||
this.accountDeleted.emit(aId);
|
this.accountDeleted.emit(aId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onOpenAccountDetailDialog(accountId: string) {
|
||||||
|
this.router.navigate([], {
|
||||||
|
queryParams: { accountId, accountDetailDialog: true }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public onUpdateAccount(aAccount: AccountModel) {
|
public onUpdateAccount(aAccount: AccountModel) {
|
||||||
this.accountToUpdate.emit(aAccount);
|
this.accountToUpdate.emit(aAccount);
|
||||||
}
|
}
|
||||||
|
@ -25,7 +25,6 @@ import { AccountsTableComponent } from './accounts-table.component';
|
|||||||
NgxSkeletonLoaderModule,
|
NgxSkeletonLoaderModule,
|
||||||
RouterModule
|
RouterModule
|
||||||
],
|
],
|
||||||
providers: [],
|
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
export class GfAccountsTableModule {}
|
export class GfAccountsTableModule {}
|
||||||
|
@ -30,9 +30,6 @@ export class AdminJobsComponent implements OnDestroy, OnInit {
|
|||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
/**
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private adminService: AdminService,
|
private adminService: AdminService,
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
@ -52,9 +49,6 @@ export class AdminJobsComponent implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the controller
|
|
||||||
*/
|
|
||||||
public ngOnInit() {
|
public ngOnInit() {
|
||||||
this.filterForm = this.formBuilder.group({
|
this.filterForm = this.formBuilder.group({
|
||||||
status: []
|
status: []
|
||||||
|
@ -24,7 +24,7 @@
|
|||||||
<table class="gf-table w-100">
|
<table class="gf-table w-100">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="mat-header-row">
|
<tr class="mat-header-row">
|
||||||
<th class="mat-header-cell px-1 py-2 text-right" i18n>#</th>
|
<th class="mat-header-cell px-1 py-2 text-right">#</th>
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Type</th>
|
<th class="mat-header-cell px-1 py-2" i18n>Type</th>
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Symbol</th>
|
<th class="mat-header-cell px-1 py-2" i18n>Symbol</th>
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Data Source</th>
|
<th class="mat-header-cell px-1 py-2" i18n>Data Source</th>
|
||||||
@ -105,19 +105,18 @@
|
|||||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||||
</button>
|
</button>
|
||||||
<mat-menu #accountMenu="matMenu" xPosition="before">
|
<mat-menu #accountMenu="matMenu" xPosition="before">
|
||||||
<button i18n mat-menu-item (click)="onViewData(job.data)">
|
<button mat-menu-item (click)="onViewData(job.data)">
|
||||||
View Data
|
<ng-container i18n>View Data</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
i18n
|
|
||||||
mat-menu-item
|
mat-menu-item
|
||||||
[disabled]="job.stacktrace?.length <= 0"
|
[disabled]="job.stacktrace?.length <= 0"
|
||||||
(click)="onViewStacktrace(job.stacktrace)"
|
(click)="onViewStacktrace(job.stacktrace)"
|
||||||
>
|
>
|
||||||
View Stacktrace
|
<ng-container i18n>View Stacktrace</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<button i18n mat-menu-item (click)="onDeleteJob(job.id)">
|
<button mat-menu-item (click)="onDeleteJob(job.id)">
|
||||||
Delete Job
|
<ng-container i18n>Delete Job</ng-container>
|
||||||
</button>
|
</button>
|
||||||
</mat-menu>
|
</mat-menu>
|
||||||
</td>
|
</td>
|
||||||
|
@ -9,7 +9,6 @@ import { GfMarketDataDetailDialogModule } from './market-data-detail-dialog/mark
|
|||||||
declarations: [AdminMarketDataDetailComponent],
|
declarations: [AdminMarketDataDetailComponent],
|
||||||
exports: [AdminMarketDataDetailComponent],
|
exports: [AdminMarketDataDetailComponent],
|
||||||
imports: [CommonModule, GfLineChartModule, GfMarketDataDetailDialogModule],
|
imports: [CommonModule, GfLineChartModule, GfMarketDataDetailDialogModule],
|
||||||
providers: [],
|
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
export class GfAdminMarketDataDetailModule {}
|
export class GfAdminMarketDataDetailModule {}
|
||||||
|
@ -43,8 +43,8 @@
|
|||||||
</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 (click)="onCancel()">Cancel</button>
|
||||||
<button color="primary" i18n mat-flat-button (click)="onUpdate()">
|
<button color="primary" mat-flat-button (click)="onUpdate()">
|
||||||
Save
|
<ng-container i18n>Save</ng-container>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
@ -11,7 +11,6 @@ import { MarketDataDetailDialog } from './market-data-detail-dialog.component';
|
|||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [MarketDataDetailDialog],
|
declarations: [MarketDataDetailDialog],
|
||||||
exports: [],
|
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
@ -22,7 +21,6 @@ import { MarketDataDetailDialog } from './market-data-detail-dialog.component';
|
|||||||
MatInputModule,
|
MatInputModule,
|
||||||
ReactiveFormsModule
|
ReactiveFormsModule
|
||||||
],
|
],
|
||||||
providers: [],
|
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
export class GfMarketDataDetailDialogModule {}
|
export class GfMarketDataDetailDialogModule {}
|
||||||
|
@ -3,17 +3,27 @@ import {
|
|||||||
ChangeDetectorRef,
|
ChangeDetectorRef,
|
||||||
Component,
|
Component,
|
||||||
OnDestroy,
|
OnDestroy,
|
||||||
OnInit
|
OnInit,
|
||||||
|
ViewChild
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
|
import { MatSort } from '@angular/material/sort';
|
||||||
|
import { MatTableDataSource } from '@angular/material/table';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
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 { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
import { getDateFormatString } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, getDateFormatString } from '@ghostfolio/common/helper';
|
||||||
import { UniqueAsset, User } from '@ghostfolio/common/interfaces';
|
import { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces';
|
||||||
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
|
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
|
||||||
import { DataSource, MarketData } from '@prisma/client';
|
import { AssetSubClass, DataSource } from '@prisma/client';
|
||||||
|
import { format, parseISO } from 'date-fns';
|
||||||
|
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { takeUntil } from 'rxjs/operators';
|
import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
|
import { AssetProfileDialog } from './asset-profile-dialog/asset-profile-dialog.component';
|
||||||
|
import { AssetProfileDialogParams } from './asset-profile-dialog/interfaces/interfaces';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
@ -22,24 +32,77 @@ import { takeUntil } from 'rxjs/operators';
|
|||||||
templateUrl: './admin-market-data.html'
|
templateUrl: './admin-market-data.html'
|
||||||
})
|
})
|
||||||
export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
||||||
|
@ViewChild(MatSort) sort: MatSort;
|
||||||
|
|
||||||
|
public activeFilters: Filter[] = [];
|
||||||
|
public allFilters: Filter[] = [
|
||||||
|
AssetSubClass.BOND,
|
||||||
|
AssetSubClass.COMMODITY,
|
||||||
|
AssetSubClass.CRYPTOCURRENCY,
|
||||||
|
AssetSubClass.ETF,
|
||||||
|
AssetSubClass.MUTUALFUND,
|
||||||
|
AssetSubClass.PRECIOUS_METAL,
|
||||||
|
AssetSubClass.PRIVATE_EQUITY,
|
||||||
|
AssetSubClass.STOCK
|
||||||
|
].map((id) => {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label: id,
|
||||||
|
type: 'ASSET_SUB_CLASS'
|
||||||
|
};
|
||||||
|
});
|
||||||
public currentDataSource: DataSource;
|
public currentDataSource: DataSource;
|
||||||
public currentSymbol: string;
|
public currentSymbol: string;
|
||||||
|
public dataSource: MatTableDataSource<AdminMarketDataItem> =
|
||||||
|
new MatTableDataSource();
|
||||||
public defaultDateFormat: string;
|
public defaultDateFormat: string;
|
||||||
public marketData: AdminMarketDataItem[] = [];
|
public deviceType: string;
|
||||||
public marketDataDetails: MarketData[] = [];
|
public displayedColumns = [
|
||||||
|
'symbol',
|
||||||
|
'dataSource',
|
||||||
|
'assetClass',
|
||||||
|
'assetSubClass',
|
||||||
|
'date',
|
||||||
|
'activityCount',
|
||||||
|
'marketDataItemCount',
|
||||||
|
'countriesCount',
|
||||||
|
'sectorsCount',
|
||||||
|
'actions'
|
||||||
|
];
|
||||||
|
public filters$ = new Subject<Filter[]>();
|
||||||
|
public isLoading = false;
|
||||||
|
public placeholder = '';
|
||||||
public user: User;
|
public user: User;
|
||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
/**
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private adminService: AdminService,
|
private adminService: AdminService,
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
|
private deviceService: DeviceDetectorService,
|
||||||
|
private dialog: MatDialog,
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private router: Router,
|
||||||
private userService: UserService
|
private userService: UserService
|
||||||
) {
|
) {
|
||||||
|
this.route.queryParams
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((params) => {
|
||||||
|
if (
|
||||||
|
params['assetProfileDialog'] &&
|
||||||
|
params['dataSource'] &&
|
||||||
|
params['dateOfFirstActivity'] &&
|
||||||
|
params['symbol']
|
||||||
|
) {
|
||||||
|
this.openAssetProfileDialog({
|
||||||
|
dataSource: params['dataSource'],
|
||||||
|
dateOfFirstActivity: params['dateOfFirstActivity'],
|
||||||
|
symbol: params['symbol']
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.userService.stateChanged
|
this.userService.stateChanged
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((state) => {
|
.subscribe((state) => {
|
||||||
@ -53,11 +116,32 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the controller
|
|
||||||
*/
|
|
||||||
public ngOnInit() {
|
public ngOnInit() {
|
||||||
this.fetchAdminMarketData();
|
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||||
|
|
||||||
|
this.filters$
|
||||||
|
.pipe(
|
||||||
|
distinctUntilChanged(),
|
||||||
|
switchMap((filters) => {
|
||||||
|
this.isLoading = true;
|
||||||
|
this.activeFilters = filters;
|
||||||
|
this.placeholder =
|
||||||
|
this.activeFilters.length <= 0 ? $localize`Filter by...` : '';
|
||||||
|
|
||||||
|
return this.dataService.fetchAdminMarketData({
|
||||||
|
filters: this.activeFilters
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
takeUntil(this.unsubscribeSubject)
|
||||||
|
)
|
||||||
|
.subscribe(({ marketData }) => {
|
||||||
|
this.dataSource = new MatTableDataSource(marketData);
|
||||||
|
this.dataSource.sort = this.sort;
|
||||||
|
|
||||||
|
this.isLoading = false;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
||||||
@ -81,54 +165,60 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
|||||||
.subscribe(() => {});
|
.subscribe(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
public onMarketDataChanged(withRefresh: boolean = false) {
|
public onOpenAssetProfileDialog({
|
||||||
if (withRefresh) {
|
dataSource,
|
||||||
this.fetchAdminMarketData();
|
dateOfFirstActivity,
|
||||||
this.fetchAdminMarketDataBySymbol({
|
symbol
|
||||||
dataSource: this.currentDataSource,
|
}: UniqueAsset & { dateOfFirstActivity: string }) {
|
||||||
symbol: this.currentSymbol
|
this.router.navigate([], {
|
||||||
|
queryParams: {
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
assetProfileDialog: true,
|
||||||
|
dateOfFirstActivity: format(parseISO(dateOfFirstActivity), DATE_FORMAT)
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
public setCurrentProfile({ dataSource, symbol }: UniqueAsset) {
|
|
||||||
this.marketDataDetails = [];
|
|
||||||
|
|
||||||
if (this.currentSymbol === symbol) {
|
|
||||||
this.currentDataSource = undefined;
|
|
||||||
this.currentSymbol = '';
|
|
||||||
} else {
|
|
||||||
this.currentDataSource = dataSource;
|
|
||||||
this.currentSymbol = symbol;
|
|
||||||
|
|
||||||
this.fetchAdminMarketDataBySymbol({ dataSource, symbol });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public ngOnDestroy() {
|
public ngOnDestroy() {
|
||||||
this.unsubscribeSubject.next();
|
this.unsubscribeSubject.next();
|
||||||
this.unsubscribeSubject.complete();
|
this.unsubscribeSubject.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
private fetchAdminMarketData() {
|
private openAssetProfileDialog({
|
||||||
this.dataService
|
dataSource,
|
||||||
.fetchAdminMarketData()
|
dateOfFirstActivity,
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
dataSource: DataSource;
|
||||||
|
dateOfFirstActivity: string;
|
||||||
|
symbol: string;
|
||||||
|
}) {
|
||||||
|
this.userService
|
||||||
|
.get()
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(({ marketData }) => {
|
.subscribe((user) => {
|
||||||
this.marketData = marketData;
|
this.user = user;
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
const dialogRef = this.dialog.open(AssetProfileDialog, {
|
||||||
|
autoFocus: false,
|
||||||
|
data: <AssetProfileDialogParams>{
|
||||||
|
dataSource,
|
||||||
|
dateOfFirstActivity,
|
||||||
|
symbol,
|
||||||
|
deviceType: this.deviceType,
|
||||||
|
locale: this.user?.settings?.locale
|
||||||
|
},
|
||||||
|
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||||
|
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
private fetchAdminMarketDataBySymbol({ dataSource, symbol }: UniqueAsset) {
|
dialogRef
|
||||||
this.adminService
|
.afterClosed()
|
||||||
.fetchAdminMarketDataBySymbol({ dataSource, symbol })
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(({ marketData }) => {
|
.subscribe(() => {
|
||||||
this.marketDataDetails = marketData;
|
this.router.navigate(['.'], { relativeTo: this.route });
|
||||||
|
});
|
||||||
this.changeDetectorRef.markForCheck();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,31 +1,108 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<table class="gf-table w-100">
|
<gf-activities-filter
|
||||||
<thead>
|
[allFilters]="allFilters"
|
||||||
<tr class="mat-header-row">
|
[isLoading]="isLoading"
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Symbol</th>
|
[placeholder]="placeholder"
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Data Source</th>
|
(valueChanged)="filters$.next($event)"
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>First Activity</th>
|
></gf-activities-filter>
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Activity Count</th>
|
</div>
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Historical Data</th>
|
</div>
|
||||||
<th class="mat-header-cell px-1 py-2"></th>
|
<div class="row">
|
||||||
</tr>
|
<div class="col">
|
||||||
</thead>
|
<table
|
||||||
<tbody>
|
class="gf-table w-100"
|
||||||
<ng-container *ngFor="let item of marketData; let i = index">
|
matSort
|
||||||
<tr
|
matSortActive="symbol"
|
||||||
class="cursor-pointer mat-row"
|
matSortDirection="asc"
|
||||||
(click)="setCurrentProfile({ dataSource: item.dataSource, symbol: item.symbol })"
|
mat-table
|
||||||
|
[dataSource]="dataSource"
|
||||||
>
|
>
|
||||||
<td class="mat-cell px-1 py-2">{{ item.symbol }}</td>
|
<ng-container matColumnDef="symbol">
|
||||||
<td class="mat-cell px-1 py-2">{{ item.dataSource }}</td>
|
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||||
<td class="mat-cell px-1 py-2">
|
<ng-container i18n>Symbol</ng-container>
|
||||||
{{ (item.date | date: defaultDateFormat) ?? '' }}
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
|
{{ element.symbol }}
|
||||||
</td>
|
</td>
|
||||||
<td class="mat-cell px-1 py-2">{{ item.activityCount }}</td>
|
</ng-container>
|
||||||
<td class="mat-cell px-1 py-2">{{ item.marketDataItemCount }}</td>
|
|
||||||
<td class="mat-cell px-1 py-2">
|
<ng-container matColumnDef="dataSource">
|
||||||
|
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||||
|
<ng-container i18n>Data Source</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
|
{{ element.dataSource }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="assetClass">
|
||||||
|
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||||
|
<ng-container i18n>Asset Class</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
|
{{ element.assetClass }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="assetSubClass">
|
||||||
|
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||||
|
<ng-container i18n>Asset Sub Class</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
|
{{ element.assetSubClass }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="date">
|
||||||
|
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||||
|
<ng-container i18n>First Activity</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
|
{{ (element.date | date: defaultDateFormat) ?? '' }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="activityCount">
|
||||||
|
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||||
|
<ng-container i18n>Activity Count</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||||
|
{{ element.activityCount }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="marketDataItemCount">
|
||||||
|
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||||
|
<ng-container i18n>Historical Data</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||||
|
{{ element.marketDataItemCount }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="countriesCount">
|
||||||
|
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||||
|
<ng-container i18n>Countries Count</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||||
|
{{ element.countriesCount }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="sectorsCount">
|
||||||
|
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||||
|
<ng-container i18n>Sectors Count</ng-container>
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||||
|
{{ element.sectorsCount }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="actions">
|
||||||
|
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell></th>
|
||||||
|
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
|
||||||
<button
|
<button
|
||||||
class="mx-1 no-min-width px-2"
|
class="mx-1 no-min-width px-2"
|
||||||
mat-button
|
mat-button
|
||||||
@ -36,44 +113,35 @@
|
|||||||
</button>
|
</button>
|
||||||
<mat-menu #accountMenu="matMenu" xPosition="before">
|
<mat-menu #accountMenu="matMenu" xPosition="before">
|
||||||
<button
|
<button
|
||||||
i18n
|
|
||||||
mat-menu-item
|
mat-menu-item
|
||||||
(click)="onGatherSymbol({dataSource: item.dataSource, symbol: item.symbol})"
|
(click)="onGatherSymbol({dataSource: element.dataSource, symbol: element.symbol})"
|
||||||
>
|
>
|
||||||
Gather Data
|
<ng-container i18n>Gather Data</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
i18n
|
|
||||||
mat-menu-item
|
mat-menu-item
|
||||||
(click)="onGatherProfileDataBySymbol({dataSource: item.dataSource, symbol: item.symbol})"
|
(click)="onGatherProfileDataBySymbol({dataSource: element.dataSource, symbol: element.symbol})"
|
||||||
>
|
>
|
||||||
Gather Profile Data
|
<ng-container i18n>Gather Profile Data</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
i18n
|
|
||||||
mat-menu-item
|
mat-menu-item
|
||||||
[disabled]="item.activityCount !== 0"
|
[disabled]="element.activityCount !== 0"
|
||||||
(click)="onDeleteProfileData({dataSource: item.dataSource, symbol: item.symbol})"
|
(click)="onDeleteProfileData({dataSource: element.dataSource, symbol: element.symbol})"
|
||||||
>
|
>
|
||||||
Delete Profile Data
|
<ng-container i18n>Delete</ng-container>
|
||||||
</button>
|
</button>
|
||||||
</mat-menu>
|
</mat-menu>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
|
||||||
<tr *ngIf="currentSymbol === item.symbol" class="mat-row">
|
|
||||||
<td class="p-1" colspan="6">
|
|
||||||
<gf-admin-market-data-detail
|
|
||||||
[dataSource]="item.dataSource"
|
|
||||||
[dateOfFirstActivity]="item.date"
|
|
||||||
[locale]="user?.settings?.locale"
|
|
||||||
[marketData]="marketDataDetails"
|
|
||||||
[symbol]="item.symbol"
|
|
||||||
(marketDataChanged)="onMarketDataChanged($event)"
|
|
||||||
></gf-admin-market-data-detail>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</tbody>
|
|
||||||
|
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
|
||||||
|
<tr
|
||||||
|
*matRowDef="let row; columns: displayedColumns"
|
||||||
|
class="cursor-pointer"
|
||||||
|
mat-row
|
||||||
|
(click)="onOpenAssetProfileDialog({ dateOfFirstActivity: row.date, dataSource: row.dataSource, symbol: row.symbol })"
|
||||||
|
></tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -2,17 +2,23 @@ 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 { GfAdminMarketDataDetailModule } from '@ghostfolio/client/components/admin-market-data-detail/admin-market-data-detail.module';
|
import { MatSortModule } from '@angular/material/sort';
|
||||||
|
import { MatTableModule } from '@angular/material/table';
|
||||||
|
import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module';
|
||||||
|
|
||||||
import { AdminMarketDataComponent } from './admin-market-data.component';
|
import { AdminMarketDataComponent } from './admin-market-data.component';
|
||||||
|
import { GfAssetProfileDialogModule } from './asset-profile-dialog/assset-profile-dialog.module';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [AdminMarketDataComponent],
|
declarations: [AdminMarketDataComponent],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
GfAdminMarketDataDetailModule,
|
GfActivitiesFilterModule,
|
||||||
|
GfAssetProfileDialogModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatMenuModule
|
MatMenuModule,
|
||||||
|
MatSortModule,
|
||||||
|
MatTableModule
|
||||||
],
|
],
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
|
@ -0,0 +1,7 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
.mat-dialog-content {
|
||||||
|
max-height: unset;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,73 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
ChangeDetectorRef,
|
||||||
|
Component,
|
||||||
|
Inject,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit
|
||||||
|
} from '@angular/core';
|
||||||
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||||
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
|
import { MarketData } from '@prisma/client';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
|
import { AssetProfileDialogParams } from './interfaces/interfaces';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
host: { class: 'd-flex flex-column h-100' },
|
||||||
|
selector: 'gf-asset-profile-dialog',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
templateUrl: 'asset-profile-dialog.html',
|
||||||
|
styleUrls: ['./asset-profile-dialog.component.scss']
|
||||||
|
})
|
||||||
|
export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||||
|
public marketDataDetails: MarketData[] = [];
|
||||||
|
|
||||||
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private adminService: AdminService,
|
||||||
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
|
public dialogRef: MatDialogRef<AssetProfileDialog>,
|
||||||
|
@Inject(MAT_DIALOG_DATA) public data: AssetProfileDialogParams
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public ngOnInit(): void {
|
||||||
|
this.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
public onClose(): void {
|
||||||
|
this.dialogRef.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public onMarketDataChanged(withRefresh: boolean = false) {
|
||||||
|
if (withRefresh) {
|
||||||
|
this.initialize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.unsubscribeSubject.next();
|
||||||
|
this.unsubscribeSubject.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
private fetchAdminMarketDataBySymbol({ dataSource, symbol }: UniqueAsset) {
|
||||||
|
this.adminService
|
||||||
|
.fetchAdminMarketDataBySymbol({ dataSource, symbol })
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(({ marketData }) => {
|
||||||
|
this.marketDataDetails = marketData;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private initialize() {
|
||||||
|
this.fetchAdminMarketDataBySymbol({
|
||||||
|
dataSource: this.data.dataSource,
|
||||||
|
symbol: this.data.symbol
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,24 @@
|
|||||||
|
<gf-dialog-header
|
||||||
|
mat-dialog-title
|
||||||
|
position="center"
|
||||||
|
[deviceType]="data.deviceType"
|
||||||
|
[title]="data.symbol"
|
||||||
|
(closeButtonClicked)="onClose()"
|
||||||
|
></gf-dialog-header>
|
||||||
|
|
||||||
|
<div class="flex-grow-1" mat-dialog-content>
|
||||||
|
<gf-admin-market-data-detail
|
||||||
|
[dataSource]="data.dataSource"
|
||||||
|
[dateOfFirstActivity]="data.dateOfFirstActivity"
|
||||||
|
[locale]="data.locale"
|
||||||
|
[marketData]="marketDataDetails"
|
||||||
|
[symbol]="data.symbol"
|
||||||
|
(marketDataChanged)="onMarketDataChanged($event)"
|
||||||
|
></gf-admin-market-data-detail>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<gf-dialog-footer
|
||||||
|
mat-dialog-actions
|
||||||
|
[deviceType]="data.deviceType"
|
||||||
|
(closeButtonClicked)="onClose()"
|
||||||
|
></gf-dialog-footer>
|
@ -0,0 +1,23 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatDialogModule } from '@angular/material/dialog';
|
||||||
|
import { GfAdminMarketDataDetailModule } from '@ghostfolio/client/components/admin-market-data-detail/admin-market-data-detail.module';
|
||||||
|
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
|
||||||
|
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
|
||||||
|
|
||||||
|
import { AssetProfileDialog } from './asset-profile-dialog.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [AssetProfileDialog],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
GfAdminMarketDataDetailModule,
|
||||||
|
GfDialogFooterModule,
|
||||||
|
GfDialogHeaderModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatDialogModule
|
||||||
|
],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
|
})
|
||||||
|
export class GfAssetProfileDialogModule {}
|
@ -0,0 +1,9 @@
|
|||||||
|
import { DataSource } from '@prisma/client';
|
||||||
|
|
||||||
|
export interface AssetProfileDialogParams {
|
||||||
|
dateOfFirstActivity: string;
|
||||||
|
dataSource: DataSource;
|
||||||
|
deviceType: string;
|
||||||
|
locale: string;
|
||||||
|
symbol: string;
|
||||||
|
}
|
@ -42,9 +42,6 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
/**
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private adminService: AdminService,
|
private adminService: AdminService,
|
||||||
private cacheService: CacheService,
|
private cacheService: CacheService,
|
||||||
@ -78,9 +75,6 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the controller
|
|
||||||
*/
|
|
||||||
public ngOnInit() {
|
public ngOnInit() {
|
||||||
this.fetchAdminData();
|
this.fetchAdminData();
|
||||||
}
|
}
|
||||||
@ -109,7 +103,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onAddCurrency() {
|
public onAddCurrency() {
|
||||||
const currency = prompt('Please add a currency:');
|
const currency = prompt($localize`Please add a currency:`);
|
||||||
|
|
||||||
if (currency) {
|
if (currency) {
|
||||||
const currencies = uniq([...this.customCurrencies, currency]);
|
const currencies = uniq([...this.customCurrencies, currency]);
|
||||||
@ -122,7 +116,9 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onDeleteCoupon(aCouponCode: string) {
|
public onDeleteCoupon(aCouponCode: string) {
|
||||||
const confirmation = confirm('Do you really want to delete this coupon?');
|
const confirmation = confirm(
|
||||||
|
$localize`Do you really want to delete this coupon?`
|
||||||
|
);
|
||||||
|
|
||||||
if (confirmation === true) {
|
if (confirmation === true) {
|
||||||
const coupons = this.coupons.filter((coupon) => {
|
const coupons = this.coupons.filter((coupon) => {
|
||||||
@ -133,7 +129,9 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onDeleteCurrency(aCurrency: string) {
|
public onDeleteCurrency(aCurrency: string) {
|
||||||
const confirmation = confirm('Do you really want to delete this currency?');
|
const confirmation = confirm(
|
||||||
|
$localize`Do you really want to delete this currency?`
|
||||||
|
);
|
||||||
|
|
||||||
if (confirmation === true) {
|
if (confirmation === true) {
|
||||||
const currencies = this.customCurrencies.filter((currency) => {
|
const currencies = this.customCurrencies.filter((currency) => {
|
||||||
@ -148,7 +146,9 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onFlushCache() {
|
public onFlushCache() {
|
||||||
const confirmation = confirm('Do you really want to flush the cache?');
|
const confirmation = confirm(
|
||||||
|
$localize`Do you really want to flush the cache?`
|
||||||
|
);
|
||||||
|
|
||||||
if (confirmation === true) {
|
if (confirmation === true) {
|
||||||
this.cacheService
|
this.cacheService
|
||||||
@ -196,7 +196,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onSetSystemMessage() {
|
public onSetSystemMessage() {
|
||||||
const systemMessage = prompt('Please set your system message:');
|
const systemMessage = prompt($localize`Please set your system message:`);
|
||||||
|
|
||||||
if (systemMessage) {
|
if (systemMessage) {
|
||||||
this.putSystemMessage(systemMessage);
|
this.putSystemMessage(systemMessage);
|
||||||
|
@ -8,7 +8,7 @@
|
|||||||
<div class="w-50">{{ userCount }}</div>
|
<div class="w-50">{{ userCount }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex my-3">
|
<div class="d-flex my-3">
|
||||||
<div class="w-50" i18n>Transaction Count</div>
|
<div class="w-50" i18n>Activity Count</div>
|
||||||
<div class="w-50">
|
<div class="w-50">
|
||||||
<ng-container *ngIf="transactionCount">
|
<ng-container *ngIf="transactionCount">
|
||||||
{{ transactionCount }} ({{ transactionCount / userCount | number
|
{{ transactionCount }} ({{ transactionCount / userCount | number
|
||||||
@ -17,7 +17,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex my-3">
|
<div class="d-flex my-3">
|
||||||
<div class="w-50" i18n>Data Gathering</div>
|
<div class="w-50" i18n>Data Management</div>
|
||||||
<div class="w-50">
|
<div class="w-50">
|
||||||
<div class="overflow-hidden">
|
<div class="overflow-hidden">
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
|
@ -21,9 +21,6 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
|
|||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
/**
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
@ -38,9 +35,6 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the controller
|
|
||||||
*/
|
|
||||||
public ngOnInit() {
|
public ngOnInit() {
|
||||||
this.fetchAdminData();
|
this.fetchAdminData();
|
||||||
}
|
}
|
||||||
@ -61,7 +55,9 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onDeleteUser(aId: string) {
|
public onDeleteUser(aId: string) {
|
||||||
const confirmation = confirm('Do you really want to delete this user?');
|
const confirmation = confirm(
|
||||||
|
$localize`Do you really want to delete this user?`
|
||||||
|
);
|
||||||
|
|
||||||
if (confirmation) {
|
if (confirmation) {
|
||||||
this.dataService
|
this.dataService
|
||||||
|
@ -7,17 +7,17 @@
|
|||||||
<tr class="mat-header-row">
|
<tr class="mat-header-row">
|
||||||
<th class="mat-header-cell px-1 py-2 text-right">#</th>
|
<th class="mat-header-cell px-1 py-2 text-right">#</th>
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>User</th>
|
<th class="mat-header-cell px-1 py-2" i18n>User</th>
|
||||||
<th class="mat-header-cell px-1 py-2 text-right" i18n>
|
<th class="mat-header-cell px-1 py-2 text-right">
|
||||||
Registration
|
<ng-container i18n>Registration</ng-container>
|
||||||
</th>
|
</th>
|
||||||
<th class="mat-header-cell px-1 py-2 text-right" i18n>
|
<th class="mat-header-cell px-1 py-2 text-right">
|
||||||
Accounts
|
<ng-container i18n>Accounts</ng-container>
|
||||||
</th>
|
</th>
|
||||||
<th class="mat-header-cell px-1 py-2 text-right" i18n>
|
<th class="mat-header-cell px-1 py-2 text-right">
|
||||||
Activities
|
<ng-container i18n>Activities</ng-container>
|
||||||
</th>
|
</th>
|
||||||
<th class="mat-header-cell px-1 py-2 text-right" i18n>
|
<th class="mat-header-cell px-1 py-2 text-right">
|
||||||
Engagement per Day
|
<ng-container i18n>Engagement per Day</ng-container>
|
||||||
</th>
|
</th>
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Last Request</th>
|
<th class="mat-header-cell px-1 py-2" i18n>Last Request</th>
|
||||||
<th class="mat-header-cell px-1 py-2"></th>
|
<th class="mat-header-cell px-1 py-2"></th>
|
||||||
@ -29,17 +29,15 @@
|
|||||||
<td class="mat-cell px-1 py-2">
|
<td class="mat-cell px-1 py-2">
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<span class="d-none d-sm-inline-block"
|
<span class="d-none d-sm-inline-block"
|
||||||
>{{ userItem.alias || userItem.id }}</span
|
>{{ userItem.id }}</span
|
||||||
>
|
>
|
||||||
<span class="d-inline-block d-sm-none"
|
<span class="d-inline-block d-sm-none"
|
||||||
>{{ userItem.alias || (userItem.id | slice:0:5) +
|
>{{ (userItem.id | slice:0:5) + '...' }}</span
|
||||||
'...' }}</span
|
|
||||||
>
|
>
|
||||||
<ion-icon
|
<gf-premium-indicator
|
||||||
*ngIf="userItem?.subscription?.type === 'Premium'"
|
*ngIf="userItem?.subscription?.type === 'Premium'"
|
||||||
class="ml-1 text-muted"
|
class="ml-1"
|
||||||
name="diamond-outline"
|
></gf-premium-indicator>
|
||||||
></ion-icon>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="mat-cell px-1 py-2 text-right">
|
<td class="mat-cell px-1 py-2 text-right">
|
||||||
|
@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatMenuModule } from '@angular/material/menu';
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
|
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
|
||||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||||
|
|
||||||
import { AdminUsersComponent } from './admin-users.component';
|
import { AdminUsersComponent } from './admin-users.component';
|
||||||
@ -9,7 +10,13 @@ import { AdminUsersComponent } from './admin-users.component';
|
|||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [AdminUsersComponent],
|
declarations: [AdminUsersComponent],
|
||||||
exports: [],
|
exports: [],
|
||||||
imports: [CommonModule, GfValueModule, MatButtonModule, MatMenuModule],
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
GfPremiumIndicatorModule,
|
||||||
|
GfValueModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatMenuModule
|
||||||
|
],
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
export class GfAdminUsersModule {}
|
export class GfAdminUsersModule {}
|
||||||
|
@ -8,7 +8,6 @@ import { DialogFooterComponent } from './dialog-footer.component';
|
|||||||
declarations: [DialogFooterComponent],
|
declarations: [DialogFooterComponent],
|
||||||
exports: [DialogFooterComponent],
|
exports: [DialogFooterComponent],
|
||||||
imports: [CommonModule, MatButtonModule],
|
imports: [CommonModule, MatButtonModule],
|
||||||
providers: [],
|
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
export class GfDialogFooterModule {}
|
export class GfDialogFooterModule {}
|
||||||
|
@ -8,7 +8,6 @@ import { DialogHeaderComponent } from './dialog-header.component';
|
|||||||
declarations: [DialogHeaderComponent],
|
declarations: [DialogHeaderComponent],
|
||||||
exports: [DialogHeaderComponent],
|
exports: [DialogHeaderComponent],
|
||||||
imports: [CommonModule, MatButtonModule],
|
imports: [CommonModule, MatButtonModule],
|
||||||
providers: [],
|
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
export class GfDialogHeaderModule {}
|
export class GfDialogHeaderModule {}
|
||||||
|
@ -66,7 +66,9 @@
|
|||||||
>Resources</a
|
>Resources</a
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
*ngIf="hasPermissionForSubscription"
|
*ngIf="
|
||||||
|
hasPermissionForSubscription && user?.subscription?.type === 'Basic'
|
||||||
|
"
|
||||||
class="d-none d-sm-block mx-1"
|
class="d-none d-sm-block mx-1"
|
||||||
i18n
|
i18n
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
@ -122,13 +124,11 @@
|
|||||||
: 'radio-button-on-outline'
|
: 'radio-button-on-outline'
|
||||||
"
|
"
|
||||||
></ion-icon>
|
></ion-icon>
|
||||||
<span *ngIf="user?.alias">{{ user.alias }}</span>
|
<span i18n>Me</span>
|
||||||
<span *ngIf="!user?.alias" i18n><span></span>Me</span>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
*ngFor="let accessItem of user?.access"
|
*ngFor="let accessItem of user?.access"
|
||||||
class="align-items-center d-flex"
|
class="align-items-center d-flex"
|
||||||
disabled="false"
|
|
||||||
mat-menu-item
|
mat-menu-item
|
||||||
(click)="impersonateAccount(accessItem.id)"
|
(click)="impersonateAccount(accessItem.id)"
|
||||||
>
|
>
|
||||||
@ -203,7 +203,9 @@
|
|||||||
>Resources</a
|
>Resources</a
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
*ngIf="hasPermissionForSubscription"
|
*ngIf="
|
||||||
|
hasPermissionForSubscription && user?.subscription?.type === 'Basic'
|
||||||
|
"
|
||||||
class="d-block d-sm-none"
|
class="d-block d-sm-none"
|
||||||
i18n
|
i18n
|
||||||
mat-menu-item
|
mat-menu-item
|
||||||
@ -229,13 +231,7 @@
|
|||||||
mat-button
|
mat-button
|
||||||
[routerLink]="['/']"
|
[routerLink]="['/']"
|
||||||
>
|
>
|
||||||
<gf-logo
|
<gf-logo [hideName]="currentRoute === 'register'"></gf-logo>
|
||||||
[hideName]="
|
|
||||||
!currentRoute ||
|
|
||||||
currentRoute === 'register' ||
|
|
||||||
currentRoute === 'start'
|
|
||||||
"
|
|
||||||
></gf-logo>
|
|
||||||
</a>
|
</a>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<a
|
<a
|
||||||
@ -271,23 +267,34 @@
|
|||||||
[routerLink]="['/pricing']"
|
[routerLink]="['/pricing']"
|
||||||
>Pricing</a
|
>Pricing</a
|
||||||
>
|
>
|
||||||
|
<a
|
||||||
|
*ngIf="hasPermissionToAccessFearAndGreedIndex"
|
||||||
|
class="d-none d-sm-block mx-1"
|
||||||
|
i18n
|
||||||
|
mat-flat-button
|
||||||
|
[ngClass]="{
|
||||||
|
'font-weight-bold': currentRoute === 'markets',
|
||||||
|
'text-decoration-underline': currentRoute === 'markets'
|
||||||
|
}"
|
||||||
|
[routerLink]="['/markets']"
|
||||||
|
>Markets</a
|
||||||
|
>
|
||||||
<a
|
<a
|
||||||
class="d-none d-sm-block mx-1 no-min-width px-1"
|
class="d-none d-sm-block mx-1 no-min-width px-1"
|
||||||
href="https://github.com/ghostfolio/ghostfolio"
|
href="https://github.com/ghostfolio/ghostfolio"
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
><ion-icon name="logo-github"></ion-icon
|
><ion-icon name="logo-github"></ion-icon
|
||||||
></a>
|
></a>
|
||||||
<button class="mx-1" i18n mat-flat-button (click)="openLoginDialog()">
|
<button class="mx-1" mat-flat-button (click)="openLoginDialog()">
|
||||||
Sign In
|
<ng-container i18n>Sign in</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<a
|
<a
|
||||||
*ngIf="currentRoute !== 'register' && !info?.isReadOnlyMode"
|
*ngIf="currentRoute !== 'register' && !info?.isReadOnlyMode"
|
||||||
class="d-none d-sm-block"
|
class="d-none d-sm-block"
|
||||||
color="primary"
|
color="primary"
|
||||||
i18n
|
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[routerLink]="['/register']"
|
[routerLink]="['/register']"
|
||||||
>Get Started
|
><ng-container i18n>Get started</ng-container>
|
||||||
</a>
|
</a>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</mat-toolbar>
|
</mat-toolbar>
|
||||||
|
@ -37,6 +37,7 @@ export class HeaderComponent implements OnChanges {
|
|||||||
public hasPermissionForSocialLogin: boolean;
|
public hasPermissionForSocialLogin: boolean;
|
||||||
public hasPermissionForSubscription: boolean;
|
public hasPermissionForSubscription: boolean;
|
||||||
public hasPermissionToAccessAdminControl: boolean;
|
public hasPermissionToAccessAdminControl: boolean;
|
||||||
|
public hasPermissionToAccessFearAndGreedIndex: boolean;
|
||||||
public impersonationId: string;
|
public impersonationId: string;
|
||||||
public isMenuOpen: boolean;
|
public isMenuOpen: boolean;
|
||||||
|
|
||||||
@ -73,6 +74,11 @@ export class HeaderComponent implements OnChanges {
|
|||||||
this.user?.permissions,
|
this.user?.permissions,
|
||||||
permissions.accessAdminControl
|
permissions.accessAdminControl
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
|
||||||
|
this.info?.globalPermissions,
|
||||||
|
permissions.enableFearAndGreedIndex
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public impersonateAccount(aId: string) {
|
public impersonateAccount(aId: string) {
|
||||||
@ -103,7 +109,7 @@ export class HeaderComponent implements OnChanges {
|
|||||||
data: {
|
data: {
|
||||||
accessToken: '',
|
accessToken: '',
|
||||||
hasPermissionToUseSocialLogin: this.hasPermissionForSocialLogin,
|
hasPermissionToUseSocialLogin: this.hasPermissionForSocialLogin,
|
||||||
title: 'Sign in'
|
title: $localize`Sign in`
|
||||||
},
|
},
|
||||||
width: '30rem'
|
width: '30rem'
|
||||||
});
|
});
|
||||||
@ -117,7 +123,7 @@ export class HeaderComponent implements OnChanges {
|
|||||||
.loginAnonymous(data?.accessToken)
|
.loginAnonymous(data?.accessToken)
|
||||||
.pipe(
|
.pipe(
|
||||||
catchError(() => {
|
catchError(() => {
|
||||||
alert('Oops! Incorrect Security Token.');
|
alert($localize`Oops! Incorrect Security Token.`);
|
||||||
|
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
}),
|
}),
|
||||||
|
@ -21,7 +21,6 @@ import { HeaderComponent } from './header.component';
|
|||||||
MatToolbarModule,
|
MatToolbarModule,
|
||||||
RouterModule
|
RouterModule
|
||||||
],
|
],
|
||||||
providers: [],
|
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
export class GfHeaderModule {}
|
export class GfHeaderModule {}
|
||||||
|
@ -2,6 +2,7 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
|||||||
import { MatDialog } from '@angular/material/dialog';
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
|
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
|
||||||
|
import { ToggleComponent } from '@ghostfolio/client/components/toggle/toggle.component';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||||
import {
|
import {
|
||||||
@ -9,7 +10,6 @@ import {
|
|||||||
SettingsStorageService
|
SettingsStorageService
|
||||||
} from '@ghostfolio/client/services/settings-storage.service';
|
} from '@ghostfolio/client/services/settings-storage.service';
|
||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
import { defaultDateRangeOptions } from '@ghostfolio/common/config';
|
|
||||||
import { Position, User } from '@ghostfolio/common/interfaces';
|
import { Position, User } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import { DateRange } from '@ghostfolio/common/types';
|
import { DateRange } from '@ghostfolio/common/types';
|
||||||
@ -27,7 +27,7 @@ import { PositionDetailDialogParams } from '../position/position-detail-dialog/i
|
|||||||
})
|
})
|
||||||
export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||||
public dateRange: DateRange;
|
public dateRange: DateRange;
|
||||||
public dateRangeOptions = defaultDateRangeOptions;
|
public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS;
|
||||||
public deviceType: string;
|
public deviceType: string;
|
||||||
public hasImpersonationId: boolean;
|
public hasImpersonationId: boolean;
|
||||||
public hasPermissionToCreateOrder: boolean;
|
public hasPermissionToCreateOrder: boolean;
|
||||||
@ -36,9 +36,6 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
|||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
/**
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
@ -50,7 +47,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
|||||||
private settingsStorageService: SettingsStorageService,
|
private settingsStorageService: SettingsStorageService,
|
||||||
private userService: UserService
|
private userService: UserService
|
||||||
) {
|
) {
|
||||||
route.queryParams
|
this.route.queryParams
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((params) => {
|
.subscribe((params) => {
|
||||||
if (
|
if (
|
||||||
@ -81,9 +78,6 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the controller
|
|
||||||
*/
|
|
||||||
public ngOnInit() {
|
public ngOnInit() {
|
||||||
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||||
|
|
||||||
|
@ -11,7 +11,6 @@ import { HomeHoldingsComponent } from './home-holdings.component';
|
|||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [HomeHoldingsComponent],
|
declarations: [HomeHoldingsComponent],
|
||||||
exports: [],
|
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
GfPositionDetailDialogModule,
|
GfPositionDetailDialogModule,
|
||||||
@ -21,7 +20,6 @@ import { HomeHoldingsComponent } from './home-holdings.component';
|
|||||||
MatCardModule,
|
MatCardModule,
|
||||||
RouterModule
|
RouterModule
|
||||||
],
|
],
|
||||||
providers: [],
|
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
export class GfHomeHoldingsModule {}
|
export class GfHomeHoldingsModule {}
|
||||||
|
@ -21,6 +21,8 @@ import { takeUntil } from 'rxjs/operators';
|
|||||||
export class HomeMarketComponent implements OnDestroy, OnInit {
|
export class HomeMarketComponent implements OnDestroy, OnInit {
|
||||||
public benchmarks: Benchmark[];
|
public benchmarks: Benchmark[];
|
||||||
public fearAndGreedIndex: number;
|
public fearAndGreedIndex: number;
|
||||||
|
public fearLabel = $localize`Fear`;
|
||||||
|
public greedLabel = $localize`Greed`;
|
||||||
public hasPermissionToAccessFearAndGreedIndex: boolean;
|
public hasPermissionToAccessFearAndGreedIndex: boolean;
|
||||||
public historicalData: HistoricalDataItem[];
|
public historicalData: HistoricalDataItem[];
|
||||||
public info: InfoItem;
|
public info: InfoItem;
|
||||||
@ -30,9 +32,6 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
|
|||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
/**
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
@ -47,9 +46,15 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
|
|||||||
if (state?.user) {
|
if (state?.user) {
|
||||||
this.user = state.user;
|
this.user = state.user;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnInit() {
|
||||||
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
|
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
|
||||||
this.user.permissions,
|
this.info?.globalPermissions,
|
||||||
permissions.accessFearAndGreedIndex
|
permissions.enableFearAndGreedIndex
|
||||||
);
|
);
|
||||||
|
|
||||||
if (this.hasPermissionToAccessFearAndGreedIndex) {
|
if (this.hasPermissionToAccessFearAndGreedIndex) {
|
||||||
@ -69,7 +74,6 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
|
|||||||
value: marketPrice
|
value: marketPrice
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
this.isLoading = false;
|
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
this.changeDetectorRef.markForCheck();
|
||||||
});
|
});
|
||||||
@ -80,19 +84,11 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
|
|||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(({ benchmarks }) => {
|
.subscribe(({ benchmarks }) => {
|
||||||
this.benchmarks = benchmarks;
|
this.benchmarks = benchmarks;
|
||||||
|
this.isLoading = false;
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
this.changeDetectorRef.markForCheck();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the controller
|
|
||||||
*/
|
|
||||||
public ngOnInit() {}
|
|
||||||
|
|
||||||
public ngOnDestroy() {
|
public ngOnDestroy() {
|
||||||
this.unsubscribeSubject.next();
|
this.unsubscribeSubject.next();
|
||||||
|
@ -9,13 +9,13 @@
|
|||||||
class="mb-3"
|
class="mb-3"
|
||||||
symbol="Fear & Greed Index"
|
symbol="Fear & Greed Index"
|
||||||
yMax="100"
|
yMax="100"
|
||||||
yMaxLabel="Greed"
|
|
||||||
yMin="0"
|
yMin="0"
|
||||||
yMinLabel="Fear"
|
|
||||||
[historicalDataItems]="historicalData"
|
[historicalDataItems]="historicalData"
|
||||||
[locale]="user?.settings?.locale"
|
[locale]="user?.settings?.locale"
|
||||||
[showXAxis]="true"
|
[showXAxis]="true"
|
||||||
[showYAxis]="true"
|
[showYAxis]="true"
|
||||||
|
[yMaxLabel]="greedLabel"
|
||||||
|
[yMinLabel]="fearLabel"
|
||||||
></gf-line-chart>
|
></gf-line-chart>
|
||||||
<gf-fear-and-greed-index
|
<gf-fear-and-greed-index
|
||||||
class="d-flex justify-content-center"
|
class="d-flex justify-content-center"
|
||||||
@ -28,16 +28,18 @@
|
|||||||
<div class="mb-3 row">
|
<div class="mb-3 row">
|
||||||
<div class="col-xs-12 col-md-8 offset-md-2">
|
<div class="col-xs-12 col-md-8 offset-md-2">
|
||||||
<gf-benchmark
|
<gf-benchmark
|
||||||
*ngFor="let benchmark of benchmarks"
|
[benchmarks]="benchmarks"
|
||||||
class="py-2"
|
|
||||||
[benchmark]="benchmark"
|
|
||||||
[locale]="user?.settings?.locale"
|
[locale]="user?.settings?.locale"
|
||||||
></gf-benchmark>
|
></gf-benchmark>
|
||||||
<gf-benchmark
|
<ngx-skeleton-loader
|
||||||
*ngIf="!benchmarks"
|
*ngIf="isLoading"
|
||||||
class="py-2"
|
animation="pulse"
|
||||||
[benchmark]="undefined"
|
class="px-2 py-3"
|
||||||
></gf-benchmark>
|
[theme]="{
|
||||||
|
height: '1.5rem',
|
||||||
|
width: '100%'
|
||||||
|
}"
|
||||||
|
></ngx-skeleton-loader>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -3,19 +3,20 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
|||||||
import { GfFearAndGreedIndexModule } from '@ghostfolio/client/components/fear-and-greed-index/fear-and-greed-index.module';
|
import { GfFearAndGreedIndexModule } from '@ghostfolio/client/components/fear-and-greed-index/fear-and-greed-index.module';
|
||||||
import { GfBenchmarkModule } from '@ghostfolio/ui/benchmark/benchmark.module';
|
import { GfBenchmarkModule } from '@ghostfolio/ui/benchmark/benchmark.module';
|
||||||
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
|
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
|
||||||
|
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||||
|
|
||||||
import { HomeMarketComponent } from './home-market.component';
|
import { HomeMarketComponent } from './home-market.component';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [HomeMarketComponent],
|
declarations: [HomeMarketComponent],
|
||||||
exports: [],
|
exports: [HomeMarketComponent],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
GfBenchmarkModule,
|
GfBenchmarkModule,
|
||||||
GfFearAndGreedIndexModule,
|
GfFearAndGreedIndexModule,
|
||||||
GfLineChartModule
|
GfLineChartModule,
|
||||||
|
NgxSkeletonLoaderModule
|
||||||
],
|
],
|
||||||
providers: [],
|
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
export class GfHomeMarketModule {}
|
export class GfHomeMarketModule {}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
|
import { ToggleComponent } from '@ghostfolio/client/components/toggle/toggle.component';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||||
import {
|
import {
|
||||||
@ -6,7 +7,6 @@ import {
|
|||||||
SettingsStorageService
|
SettingsStorageService
|
||||||
} from '@ghostfolio/client/services/settings-storage.service';
|
} from '@ghostfolio/client/services/settings-storage.service';
|
||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
import { defaultDateRangeOptions } from '@ghostfolio/common/config';
|
|
||||||
import {
|
import {
|
||||||
PortfolioPerformance,
|
PortfolioPerformance,
|
||||||
UniqueAsset,
|
UniqueAsset,
|
||||||
@ -26,7 +26,7 @@ import { takeUntil } from 'rxjs/operators';
|
|||||||
})
|
})
|
||||||
export class HomeOverviewComponent implements OnDestroy, OnInit {
|
export class HomeOverviewComponent implements OnDestroy, OnInit {
|
||||||
public dateRange: DateRange;
|
public dateRange: DateRange;
|
||||||
public dateRangeOptions = defaultDateRangeOptions;
|
public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS;
|
||||||
public deviceType: string;
|
public deviceType: string;
|
||||||
public errors: UniqueAsset[];
|
public errors: UniqueAsset[];
|
||||||
public hasError: boolean;
|
public hasError: boolean;
|
||||||
@ -42,9 +42,6 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
|
|||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
/**
|
|
||||||
* @constructor
|
|
||||||
*/
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
@ -69,9 +66,6 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the controller
|
|
||||||
*/
|
|
||||||
public ngOnInit() {
|
public ngOnInit() {
|
||||||
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||||
|
|
||||||
|
@ -2,17 +2,8 @@
|
|||||||
class="align-items-center container d-flex flex-column h-100 justify-content-center overview p-0 position-relative"
|
class="align-items-center container d-flex flex-column h-100 justify-content-center overview p-0 position-relative"
|
||||||
>
|
>
|
||||||
<div class="row w-100">
|
<div class="row w-100">
|
||||||
<div class="chart-container col">
|
<div class="col p-0">
|
||||||
<gf-line-chart
|
<div class="chart-container mx-auto position-relative">
|
||||||
symbol="Performance"
|
|
||||||
[historicalDataItems]="historicalDataItems"
|
|
||||||
[locale]="user?.settings?.locale"
|
|
||||||
[ngClass]="{ 'pr-3': deviceType === 'mobile' }"
|
|
||||||
[showGradient]="true"
|
|
||||||
[showLoader]="false"
|
|
||||||
[showXAxis]="false"
|
|
||||||
[showYAxis]="false"
|
|
||||||
></gf-line-chart>
|
|
||||||
<div
|
<div
|
||||||
*ngIf="hasPermissionToCreateOrder && historicalDataItems?.length === 0"
|
*ngIf="hasPermissionToCreateOrder && historicalDataItems?.length === 0"
|
||||||
class="align-items-center d-flex h-100 justify-content-center w-100"
|
class="align-items-center d-flex h-100 justify-content-center w-100"
|
||||||
@ -21,6 +12,20 @@
|
|||||||
<gf-no-transactions-info-indicator></gf-no-transactions-info-indicator>
|
<gf-no-transactions-info-indicator></gf-no-transactions-info-indicator>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<gf-line-chart
|
||||||
|
class="position-absolute"
|
||||||
|
symbol="Performance"
|
||||||
|
[currency]="user?.settings?.baseCurrency"
|
||||||
|
[historicalDataItems]="historicalDataItems"
|
||||||
|
[hidden]="historicalDataItems?.length === 0"
|
||||||
|
[locale]="user?.settings?.locale"
|
||||||
|
[ngClass]="{ 'pr-3': deviceType === 'mobile' }"
|
||||||
|
[showGradient]="true"
|
||||||
|
[showLoader]="false"
|
||||||
|
[showXAxis]="false"
|
||||||
|
[showYAxis]="false"
|
||||||
|
></gf-line-chart>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="overview-container row mt-1">
|
<div class="overview-container row mt-1">
|
||||||
|
@ -10,7 +10,6 @@ import { HomeOverviewComponent } from './home-overview.component';
|
|||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [HomeOverviewComponent],
|
declarations: [HomeOverviewComponent],
|
||||||
exports: [],
|
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
GfLineChartModule,
|
GfLineChartModule,
|
||||||
@ -19,7 +18,6 @@ import { HomeOverviewComponent } from './home-overview.component';
|
|||||||
GfToggleModule,
|
GfToggleModule,
|
||||||
RouterModule
|
RouterModule
|
||||||
],
|
],
|
||||||
providers: [],
|
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
export class GfHomeOverviewModule {}
|
export class GfHomeOverviewModule {}
|
||||||
|
@ -5,7 +5,8 @@
|
|||||||
|
|
||||||
.chart-container {
|
.chart-container {
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
max-height: 50vh;
|
height: auto;
|
||||||
|
max-width: 50rem;
|
||||||
|
|
||||||
// Fallback for aspect-ratio (using padding hack)
|
// Fallback for aspect-ratio (using padding hack)
|
||||||
@supports not (aspect-ratio: 16 / 9) {
|
@supports not (aspect-ratio: 16 / 9) {
|
||||||
@ -25,10 +26,8 @@
|
|||||||
gf-line-chart {
|
gf-line-chart {
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
right: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: -1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user