Compare commits
402 Commits
Author | SHA1 | Date | |
---|---|---|---|
f953c6ea64 | |||
76dbf78279 | |||
f12866b9ec | |||
83ba5f3d9f | |||
7439c1bf54 | |||
e255b76053 | |||
ed7209fb53 | |||
008a2ab123 | |||
2aedd74480 | |||
11076592d1 | |||
ebee851b23 | |||
7d3f1832b4 | |||
39e6abfc8c | |||
78e0fdb0ca | |||
606350b2ff | |||
d09cad4e05 | |||
069660afe4 | |||
4d9a223491 | |||
aed8f5cf04 | |||
1beb4de62f | |||
9bc3505ded | |||
3e82de6b21 | |||
563f354e7e | |||
e96e6c717c | |||
961774ce9f | |||
49f46e1a1e | |||
050c0a4da7 | |||
4908e6d35d | |||
fe4013830d | |||
11be6f630f | |||
85d123e1b1 | |||
c5e9804c25 | |||
1f042ee791 | |||
da6eaa0d77 | |||
3f31cec859 | |||
6c07759eb7 | |||
fcf07a0fd1 | |||
2f402c0c8e | |||
a24a094407 | |||
dc9b2ce194 | |||
72067459d6 | |||
705441ecf8 | |||
fbd1475402 | |||
4dc4f13f40 | |||
3b857aa8bb | |||
1c2ca5b96b | |||
572bfc59b8 | |||
147f0162b7 | |||
f6acf5207b | |||
80782f1098 | |||
bc58ee86ca | |||
0cb632b165 | |||
fca3a659d0 | |||
904dec040e | |||
fc6c81fe02 | |||
634171e4e3 | |||
f8f36e4f4e | |||
5e7cf9d0b6 | |||
e1932eb5a1 | |||
dba47d59e3 | |||
3032126508 | |||
a50b55da75 | |||
5422df05b3 | |||
d2fabe7ce4 | |||
a42700b9fe | |||
9df8541145 | |||
0b2252755c | |||
239bd09cbd | |||
cd76f89902 | |||
7425ba94f1 | |||
b9522307c4 | |||
e01e039a00 | |||
6d3513c17f | |||
d60b444324 | |||
2873130259 | |||
d999a27159 | |||
b6902e10ea | |||
7f3f75386d | |||
678544748a | |||
632f3e3872 | |||
87301ddbd5 | |||
7d03c373ac | |||
edb66bb166 | |||
54bbc8446b | |||
9933967e42 | |||
5618513d07 | |||
1397cd62a8 | |||
e7fb31d1a6 | |||
51e7b94ad0 | |||
9133ea38f3 | |||
a864c617b9 | |||
442df9d6f8 | |||
2de0e75cb8 | |||
1296f95602 | |||
e98dff877a | |||
964b37af30 | |||
2d89d12d31 | |||
19e2d54791 | |||
e24e5e1c44 | |||
a0d4ff7920 | |||
099ad18aaf | |||
e3cd99f5d2 | |||
6dea9093ba | |||
43104f81d0 | |||
6d2e3b6e40 | |||
1d9a31dbb8 | |||
ea0e92220c | |||
b57301ef50 | |||
67dbc6b014 | |||
2e5176bacf | |||
060846023f | |||
f06a0fbbee | |||
4ab6a1a071 | |||
93dcbeb6c7 | |||
b9f0a57522 | |||
174c1d1a62 | |||
f308ae7a13 | |||
a7a6b0608b | |||
15a61b7a20 | |||
d1eedf9726 | |||
30a592b524 | |||
de94494aa0 | |||
d3c6788ad5 | |||
3ec4a73b35 | |||
1050bfa098 | |||
595ec1d7b4 | |||
c8389599b6 | |||
8769fe4c90 | |||
4219e1121e | |||
f558eb8de8 | |||
fe2bd6eea8 | |||
035052be99 | |||
bcdd2780b3 | |||
22d1ed7920 | |||
39d9828f9f | |||
6333aa972d | |||
554f2f861f | |||
dcee651098 | |||
508a48f4c3 | |||
8466e3d73f | |||
9ae9904389 | |||
af022ae316 | |||
5cd6edaf3a | |||
98be8745d9 | |||
861dff9210 | |||
f2364eed10 | |||
d5392de7c9 | |||
0f72673ef4 | |||
641fe4e8f4 | |||
18e06bb6e6 | |||
5b588c2000 | |||
162d19fa44 | |||
4a815d2031 | |||
d2aeeb3e88 | |||
ba926ffcf2 | |||
5ea455b98b | |||
39f315aba0 | |||
df2dfc20a1 | |||
81e83d4cea | |||
5d4156ecec | |||
4693a8baa2 | |||
773444b1e2 | |||
3c46bde8d5 | |||
63ee33b685 | |||
bc87c0a3e1 | |||
caa9fc3efa | |||
9ed82ac82b | |||
9c9ca4ab1e | |||
b0b0942162 | |||
9cbf789c22 | |||
ee5ab05d8a | |||
20731c67cb | |||
bf8856ad19 | |||
a31d79821d | |||
48ab862bb6 | |||
ba234a470e | |||
ccae660104 | |||
21ed91d184 | |||
5fd413e57e | |||
4c194c938a | |||
a4d049e53d | |||
f9c4408126 | |||
d046f1d498 | |||
ad96d6e53e | |||
747e5b63fa | |||
b1187cf880 | |||
ba9e6eab58 | |||
01feead017 | |||
6a0cfb8f77 | |||
6386786ac0 | |||
d3be6577c8 | |||
73a967a7e5 | |||
836ff6ec13 | |||
c5bb3023d3 | |||
695c378b48 | |||
fe975945d1 | |||
d8782b0d4c | |||
e14f08a8fb | |||
72c065a59d | |||
98dac4052a | |||
2083d28d02 | |||
addd5c36d9 | |||
aad8f77093 | |||
a904208d06 | |||
2733b78044 | |||
b43b515df1 | |||
70e14b4d3c | |||
0f7d1b7d59 | |||
c2ab6a6c44 | |||
c71a4c078e | |||
e17b217032 | |||
408e08d43c | |||
2fa324702f | |||
05b0efef82 | |||
7c91727eb1 | |||
0ee2258af8 | |||
308b218487 | |||
26694adb8e | |||
77936e3bf3 | |||
13090bf6b5 | |||
b898c0678d | |||
3330ae70b6 | |||
96a615dc5d | |||
98f44323da | |||
8adacd9760 | |||
908aba170d | |||
d599797a65 | |||
8ac1272a9d | |||
0a85a56c67 | |||
4ad5590838 | |||
5b8af68e71 | |||
80d043729d | |||
178166d86b | |||
37358fb480 | |||
616d601cf6 | |||
e88b889fdd | |||
f6cdc4ff47 | |||
818c40fc61 | |||
3589e72aea | |||
e68aa1fa68 | |||
bb76ace95d | |||
2bd9309827 | |||
f743c03e17 | |||
dfcf826b4f | |||
218efbb5bd | |||
ba3b4564cd | |||
3723a5c725 | |||
bf256ae50c | |||
c4b6273886 | |||
7f047362cc | |||
66900ffa24 | |||
33e05e949a | |||
94d2310217 | |||
b7d950f3f9 | |||
b7943889da | |||
84aa542560 | |||
4bd41ffa41 | |||
56c332c59a | |||
423ceec317 | |||
5b4a1785ae | |||
56a5664c87 | |||
823501f43e | |||
331ac7ded2 | |||
92b44ba658 | |||
982ba7377a | |||
4bbd17a37a | |||
6a7def6c48 | |||
ea219f0b88 | |||
23b2e03923 | |||
04e6518226 | |||
72dbe00091 | |||
9834c52739 | |||
c47578bd3e | |||
9e4a49d811 | |||
a3a98c68a5 | |||
21570cca19 | |||
71a3115fc6 | |||
de83dc7b84 | |||
4a0695613e | |||
328d814922 | |||
d23addb673 | |||
fb15cebb64 | |||
9c51a257ae | |||
f9b9dc32cb | |||
e7194ef3ce | |||
ec5523b459 | |||
c8c21a016a | |||
9821b7f8f0 | |||
ed731afc66 | |||
ff15d5cbc4 | |||
3c4949de35 | |||
bd0e53525b | |||
cbdb68e2f8 | |||
8571709014 | |||
e7ef1d426e | |||
39cba0a8eb | |||
a90c314e30 | |||
47d71405e1 | |||
5e9cecc6c1 | |||
fb9e66318f | |||
b8194eb64f | |||
cbb81916ee | |||
9b1e9397a8 | |||
b779964adb | |||
409afac2a9 | |||
e0a4e16ea1 | |||
dc84abdc0a | |||
b031b028f1 | |||
3b7e0a0106 | |||
ea66081073 | |||
602a770a09 | |||
e522722aa6 | |||
03ca5d7663 | |||
136563c949 | |||
948c45c602 | |||
e0be792e46 | |||
c3d010135f | |||
d6a16a6093 | |||
34c13c80ec | |||
f65a108436 | |||
993f066e08 | |||
852902d1ab | |||
ee89822bfe | |||
e0435e5cad | |||
e2c23703dc | |||
1226c26a9d | |||
fdc89f7182 | |||
1e368d6e2d | |||
04e03bd080 | |||
66e7ad3fd2 | |||
b4dc21dd61 | |||
8a482e63b9 | |||
aabfb39e8f | |||
cdc8faff7f | |||
7b696e39de | |||
c88ad2c225 | |||
fbc9269abf | |||
cbe079ae66 | |||
8e4ee7feea | |||
f1b3c61675 | |||
24dc312367 | |||
7ac7442f73 | |||
099571437e | |||
7dac059a55 | |||
48fbeda72d | |||
19007cdc34 | |||
5037393866 | |||
ddf24163b4 | |||
b26521c4bd | |||
cfee6c1ddd | |||
19bcd601d1 | |||
836df69e68 | |||
dd86adcea1 | |||
4f7628921d | |||
88f0cb095d | |||
7538133d09 | |||
50b280c5a6 | |||
67606e4026 | |||
9de56c32ac | |||
5c0f710563 | |||
1c65599a16 | |||
61e667213e | |||
ba47212057 | |||
f0c6517019 | |||
80ba112bc0 | |||
40696b425e | |||
6dbdf23a68 | |||
cdcbe3ab71 | |||
6996e5a140 | |||
be8d60968d | |||
d53e5c4da5 | |||
a3a9957196 | |||
9072cbdba1 | |||
120b691336 | |||
bd4ad76953 | |||
94d56f553f | |||
ecdd325228 | |||
51fbc538ca | |||
39a76f7f40 | |||
e4d325daab | |||
b765df65d6 | |||
c7b7efae3b | |||
be5b58f49a | |||
91c748c7ad | |||
ecfe694f0b | |||
1491bf7f76 | |||
b3b9a051c3 | |||
bf1146bfd6 | |||
0774ca91a1 | |||
f403807f2d | |||
f22991b090 | |||
1135a5b335 | |||
d9ea255c17 | |||
2c19d8c8e7 | |||
db090229ce | |||
fbe590ddb9 | |||
0d65136a9e | |||
dea87cc3cf | |||
a062a3cee4 | |||
5b1b207a6f | |||
63cc7b2871 | |||
3986e8f879 |
@ -44,7 +44,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"@typescript-eslint/member-ordering": "error",
|
"@typescript-eslint/member-ordering": "error",
|
||||||
"@typescript-eslint/naming-convention": "error",
|
"@typescript-eslint/naming-convention": "off",
|
||||||
"@typescript-eslint/no-empty-function": "off",
|
"@typescript-eslint/no-empty-function": "off",
|
||||||
"@typescript-eslint/no-empty-interface": "error",
|
"@typescript-eslint/no-empty-interface": "error",
|
||||||
"@typescript-eslint/no-inferrable-types": [
|
"@typescript-eslint/no-inferrable-types": [
|
||||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -23,6 +23,7 @@
|
|||||||
!.vscode/settings.json
|
!.vscode/settings.json
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
|
/.angular/cache
|
||||||
/.sass-cache
|
/.sass-cache
|
||||||
/connect.lock
|
/connect.lock
|
||||||
/coverage
|
/coverage
|
||||||
|
@ -1 +1,2 @@
|
|||||||
/dist
|
/dist
|
||||||
|
/test/import
|
||||||
|
11
.storybook/main.js
Normal file
11
.storybook/main.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
module.exports = {
|
||||||
|
stories: [],
|
||||||
|
addons: ['@storybook/addon-essentials']
|
||||||
|
// uncomment the property below if you want to apply some webpack config globally
|
||||||
|
// webpackFinal: async (config, { configType }) => {
|
||||||
|
// // Make whatever fine-grained changes you need that should apply to all storybook configs
|
||||||
|
|
||||||
|
// // Return the altered config
|
||||||
|
// return config;
|
||||||
|
// },
|
||||||
|
};
|
10
.storybook/tsconfig.json
Normal file
10
.storybook/tsconfig.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.base.json",
|
||||||
|
"exclude": [
|
||||||
|
"../**/*.spec.js",
|
||||||
|
"../**/*.spec.ts",
|
||||||
|
"../**/*.spec.tsx",
|
||||||
|
"../**/*.spec.jsx"
|
||||||
|
],
|
||||||
|
"include": ["../**/*"]
|
||||||
|
}
|
31
.travis.yml
31
.travis.yml
@ -3,9 +3,28 @@ git:
|
|||||||
depth: false
|
depth: false
|
||||||
node_js:
|
node_js:
|
||||||
- 14
|
- 14
|
||||||
before_script:
|
|
||||||
- yarn
|
services:
|
||||||
script:
|
- docker
|
||||||
- yarn format:check
|
|
||||||
- yarn test
|
cache: yarn
|
||||||
- yarn build:all
|
|
||||||
|
if: (type = pull_request) OR (tag IS present)
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
include:
|
||||||
|
- stage: Install dependencies
|
||||||
|
if: type = pull_request
|
||||||
|
script: yarn --frozen-lockfile
|
||||||
|
- stage: Check formatting
|
||||||
|
if: type = pull_request
|
||||||
|
script: yarn format:check
|
||||||
|
- stage: Execute tests
|
||||||
|
if: type = pull_request
|
||||||
|
script: yarn test
|
||||||
|
- stage: Build application
|
||||||
|
if: type = pull_request
|
||||||
|
script: yarn build:all
|
||||||
|
- stage: Build and publish docker image
|
||||||
|
if: tag IS present
|
||||||
|
script: ./publish-docker-image.sh
|
||||||
|
3
.vscode/launch.json
vendored
3
.vscode/launch.json
vendored
@ -26,7 +26,8 @@
|
|||||||
"skipFiles": [
|
"skipFiles": [
|
||||||
"${workspaceFolder}/node_modules/**/*.js",
|
"${workspaceFolder}/node_modules/**/*.js",
|
||||||
"<node_internals>/**/*.js"
|
"<node_internals>/**/*.js"
|
||||||
]
|
],
|
||||||
|
"console": "integratedTerminal"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
798
CHANGELOG.md
798
CHANGELOG.md
@ -5,6 +5,804 @@ 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.90.0 - 14.12.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the validation in the import functionality for transactions by checking the currency of the data provider service
|
||||||
|
- Added support for cryptocurrency _Uniswap_
|
||||||
|
- Set up pipeline for docker build
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Removed the default transactions import limit
|
||||||
|
- Improved the landing page in dark mode
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed `/bin/sh: prisma: not found` in docker build
|
||||||
|
- Added `apk` in `Dockerfile` (`python3 g++ make openssl`)
|
||||||
|
|
||||||
|
## 1.89.0 - 11.12.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the data gathering by symbol endpoint with an optional date
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Upgraded `Nx` from version `13.2.2` to `13.3.0`
|
||||||
|
- Upgraded `storybook` from version `6.4.0-rc.3` to `6.4.9`
|
||||||
|
|
||||||
|
## 1.88.0 - 09.12.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a coupon system
|
||||||
|
|
||||||
|
## 1.87.0 - 07.12.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Supported the management of additional currencies in the admin control panel
|
||||||
|
- Introduced the system message
|
||||||
|
- Introduced the read only mode
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Increased the historical data chart of the _Fear & Greed Index_ (market mood) to 10 days
|
||||||
|
- Upgraded `prisma` from version `2.30.2` to `3.6.0`
|
||||||
|
|
||||||
|
## 1.86.0 - 04.12.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the historical data chart of the _Fear & Greed Index_ (market mood)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the historical data view in the admin control panel (hide invalid and future dates)
|
||||||
|
- Enabled the import functionality for transactions by default
|
||||||
|
- Converted the symbols to uppercase to avoid case-sensitive duplicates in the symbol profile model
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Improved the allocations by currency in combination with cash balances
|
||||||
|
|
||||||
|
## 1.85.0 - 01.12.2021
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the data gathering of the _Fear & Greed Index_ (market mood)
|
||||||
|
|
||||||
|
## 1.84.0 - 30.11.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Exposed the data gathering by symbol as an endpoint
|
||||||
|
|
||||||
|
## 1.83.0 - 29.11.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Removed the experimental API
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Eliminated the redundant storage of historical exchange rates
|
||||||
|
|
||||||
|
## 1.82.0 - 28.11.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added tabs with routing to the admin control panel
|
||||||
|
- Added a new tab to manage historical data to the admin control panel
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Introduced tabs with routing to the home page
|
||||||
|
|
||||||
|
## 1.81.0 - 27.11.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the value to the position detail dialog
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Upgraded `angular` from version `12.2.4` to `13.0.2`
|
||||||
|
- Upgraded `angular-material-css-vars` from version `2.1.2` to `3.0.0`
|
||||||
|
- Upgraded `nestjs` from version `7.6.18` to `8.2.3`
|
||||||
|
- Upgraded `Nx` from version `12.8.0` to `13.2.2`
|
||||||
|
- Upgraded `rxjs` from version `6.6.7` to `7.4.0`
|
||||||
|
- Upgraded `storybook` from version `6.3.8` to `6.4.0-rc.3`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the broken line charts showing value labels if openend from the allocations page
|
||||||
|
- Fixed the click event for drafts in the transactions table
|
||||||
|
|
||||||
|
## 1.80.0 - 23.11.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Accentuated the all time high and the all time low
|
||||||
|
|
||||||
|
## 1.79.0 - 21.11.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the value column to the positions table
|
||||||
|
- Added support for cryptocurrency _Algorand_
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Locked the symbol input in the edit transaction dialog
|
||||||
|
- Filtered the account selector by account type (`SECURITIES`) in the create or edit transaction dialog
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the search functionality for cryptocurrency symbols (do not show unsupported symbols)
|
||||||
|
|
||||||
|
## 1.78.0 - 20.11.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a testimonial section to the landing page
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the footer row border of the accounts table in dark mode
|
||||||
|
|
||||||
|
## 1.77.0 - 16.11.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Hid the _Get Started_ button on the registration page
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the footer row of the accounts table on mobile
|
||||||
|
- Fixed the transactions count calculation in the accounts table (exclude drafts)
|
||||||
|
|
||||||
|
## 1.76.0 - 14.11.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the footer row with buying power and net worth to the accounts table
|
||||||
|
|
||||||
|
## 1.75.0 - 13.11.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a logo to the log on the server start
|
||||||
|
- Added the data gathering progress to the log and the admin control panel
|
||||||
|
- Added the value column to the accounts table
|
||||||
|
|
||||||
|
## 1.74.0 - 11.11.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Adapted the decimal places for cryptocurrencies in the position detail dialog
|
||||||
|
- Moved the _Fear & Greed Index_ (market mood) to a new tab on the home page
|
||||||
|
|
||||||
|
## 1.73.0 - 10.11.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the info messages to add the first transaction
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the skeleton loader of the portfolio holdings
|
||||||
|
|
||||||
|
## 1.72.0 - 08.11.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Cached the statistics section on the about page
|
||||||
|
|
||||||
|
## 1.71.0 - 07.11.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Changed the logger from `console.log()` to `Logger.log()`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an exception in the scraper configuration
|
||||||
|
|
||||||
|
## 1.70.0 - 07.11.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the validation of `json` files in the import functionality for transactions
|
||||||
|
- Moved the scraper configuration to the symbol profile model
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
|
## 1.69.0 - 07.11.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the symbol mapping attribute to the symbol profile model
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the registration page
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
|
## 1.68.0 - 01.11.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Prettified the generic scraper symbols in the portfolio proportion chart component
|
||||||
|
- Extended the statistics section on the about page by the active users count (7d)
|
||||||
|
- Extended the statistics section on the about page by the new users count
|
||||||
|
|
||||||
|
## 1.67.0 - 31.10.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added more details to the public page (currencies, sectors, continents and regions)
|
||||||
|
- Added a `Dockerfile` and documentation to build a _Docker_ image
|
||||||
|
|
||||||
|
## 1.66.0 - 30.10.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the landing page
|
||||||
|
- Ordered the granted accesses by type
|
||||||
|
|
||||||
|
## 1.65.0 - 25.10.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the user interface for granting and revoking public access to share the portfolio
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Moved the data enhancer calls from the data provider (`get()`) to the data gathering service to reduce traffic to 3rd party data providers
|
||||||
|
- Changed the profile data gathering from every 12 hours to once every weekend
|
||||||
|
|
||||||
|
## 1.64.0 - 21.10.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for more cryptocurrency symbols like _Avalanche_, _Polygon_, _Shiba Inu_ etc.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Changed the data provider service to handle a dynamic list of services
|
||||||
|
|
||||||
|
## 1.63.0 - 19.10.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a public page to share the portfolio
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the skeleton loader size of the portfolio proportion chart component
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
|
## 1.62.0 - 17.10.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the validation message of the import functionality for transactions
|
||||||
|
|
||||||
|
## 1.61.0 - 15.10.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the import functionality for transactions by `csv` files
|
||||||
|
- Introduced the primary data source
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Restricted the file selector of the import functionality for transactions to `csv` and `json`
|
||||||
|
|
||||||
|
## 1.60.0 - 13.10.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the validation of the import functionality for transactions
|
||||||
|
- Valid data types
|
||||||
|
- Maximum number of orders
|
||||||
|
- No duplicate orders
|
||||||
|
- Data provider service returns data for the `dataSource` / `symbol` pair
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Harmonized the page layouts
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the broken line charts showing value labels
|
||||||
|
|
||||||
|
## 1.59.0 - 11.10.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a data enhancer for symbol profile data (countries and sectors) via _Trackinsight_
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Changed the values of the global heat map to fixed-point notation
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the links of cryptocurrency assets in the positions table
|
||||||
|
- Fixed various values in the impersonation mode which have not been nullified
|
||||||
|
|
||||||
|
## 1.58.1 - 03.10.2021
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue in the symbol conversion for _Yahoo Finance_ (for a cryptocurrency with the same code as a currency)
|
||||||
|
|
||||||
|
## 1.58.0 - 02.10.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the symbol conversion for _Yahoo Finance_: Support for _Solana USD_ (`SOL1-USD`)
|
||||||
|
- Improved the tooltips of the allocations page
|
||||||
|
- Upgraded `envalid` from version `7.1.0` to `7.2.1`
|
||||||
|
|
||||||
|
## 1.57.0 - 29.09.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a protection for endpoints (subscriptions)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Reformatted the exchange rates table in the admin control panel
|
||||||
|
|
||||||
|
## 1.56.0 - 25.09.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a story for the line chart component
|
||||||
|
- Added a story for the portfolio proportion chart component
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Changed the navigation to always show the portfolio page
|
||||||
|
- Migrated the data type of currencies from `enum` to `string` in the database
|
||||||
|
- Supported unlimited currencies (instead of `CHF`, `EUR`, `GBP` and `USD`)
|
||||||
|
- Respected the accounts' currencies in the exchange rate service
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Hid the actions from the accounts table in the _Presenter View_
|
||||||
|
- Hid the actions from the transactions table in the _Presenter View_
|
||||||
|
- Fixed the data gathering of the initial project setup (database seeding)
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
|
## 1.55.0 - 20.09.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Removed the default value of the data source attribute
|
||||||
|
- Upgraded `@storybook` dependencies
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue in the create or edit transaction dialog
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
|
## 1.54.0 - 18.09.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the data source attribute to the symbol profile model
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Respected the data source attribute in the data provider service
|
||||||
|
- Respected the data source attribute in the symbol data endpoint
|
||||||
|
- Improved the search functionality of the data management (multiple data sources)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Hid the net performance in the _Presenter View_ (portfolio holdings and summary tab on the home page)
|
||||||
|
- Hid the sign if the performance is zero in the value component
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
|
## 1.53.0 - 13.09.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Optimized the annualized performance calculation
|
||||||
|
- Changed the data gathering selection from distinct orders to symbol profiles
|
||||||
|
|
||||||
|
## 1.52.0 - 11.09.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the annualized performance to the portfolio summary tab on the home page
|
||||||
|
- Added the Ghostfolio Slack channel to the about page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `3.0.0` to `4.1.0`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the sign in with fingerprint for some android devices
|
||||||
|
|
||||||
|
## 1.51.0 - 11.09.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Provided the name in the portfolio position endpoint
|
||||||
|
|
||||||
|
## 1.50.0 - 11.09.2021
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the _Fear & Greed Index_ (market mood)
|
||||||
|
- Fixed the overlap of the home button with tabs on iOS (_Add to Home Screen_)
|
||||||
|
|
||||||
|
## 1.49.0 - 08.09.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added labels to the allocation chart by symbol on desktop
|
||||||
|
|
||||||
|
## 1.48.0 - 07.09.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the attribute `precision` in the value component
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Hid the performance in the _Presenter View_
|
||||||
|
|
||||||
|
## 1.47.1 - 06.09.2021
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the search functionality for cryptocurrency symbols
|
||||||
|
|
||||||
|
## 1.46.0 - 05.09.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the statistics section on the about page by the _GitHub_ contributors count
|
||||||
|
- Set up _Storybook_
|
||||||
|
- Added a story for the logo component
|
||||||
|
- Added a story for the no transactions info component
|
||||||
|
- Added a story for the trend indicator component
|
||||||
|
- Added a story for the value component
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Switched from gross to net performance
|
||||||
|
- Restructured the portfolio summary tab on the home page (fees and net performance)
|
||||||
|
|
||||||
|
## 1.45.0 - 04.09.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a link below the holdings to manage the transactions
|
||||||
|
- Added the allocation chart by symbol
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Restructured the allocations page
|
||||||
|
- Upgraded `angular` from version `12.0.4` to `12.2.4`
|
||||||
|
- Upgraded `@angular/cdk` and `@angular/material` from version `12.0.6` to `12.2.4`
|
||||||
|
- Upgraded `Nx` from version `12.5.4` to `12.8.0`
|
||||||
|
- Upgraded `prisma` from version `2.24.1` to `2.30.2`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the value formatting for integers (transactions count)
|
||||||
|
|
||||||
|
## 1.44.0 - 30.08.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Extended the sub classification of assets by cash
|
||||||
|
- Upgraded `svgmap` from version `2.1.1` to `2.6.0`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Filtered out positions without any quantity in the positions table
|
||||||
|
- Improved the symbol lookup: allow saving with valid symbol in create or edit transaction dialog
|
||||||
|
|
||||||
|
## 1.43.0 - 24.08.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the data management of symbol profile data by countries (automated for stocks)
|
||||||
|
- Added a fallback for initially loading currencies if historical data is not yet available
|
||||||
|
|
||||||
|
## 1.42.0 - 22.08.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the subscription type to the users table of the admin control panel
|
||||||
|
- Introduced the sub classification of assets
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
|
## 1.41.0 - 21.08.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a link to the system status page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the wording for the _Restricted View_: _Presenter View_
|
||||||
|
- Improved the styling of the tables
|
||||||
|
- Ignored cash assets in the allocation chart by sector, continent and country
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue in the allocation chart by account (wrong calculation)
|
||||||
|
- Fixed an issue in the allocation chart by account (missing cash accounts)
|
||||||
|
|
||||||
|
## 1.40.0 - 19.08.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the fault tolerance of the portfolio details endpoint
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the node engine version mismatch in `package.json`
|
||||||
|
- Fixed an issue on the buy date in the position detail dialog
|
||||||
|
- Fixed an issue with the currency inconsistency in the _Yahoo Finance_ service (convert from `GBp` to `GBP`)
|
||||||
|
|
||||||
|
## 1.39.0 - 16.08.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added an option to hide absolute values like performances and quantities (_Restricted View_)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Restructured the allocations page
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with the performance in the portfolio summary tab on the home page (impersonation mode)
|
||||||
|
- Fixed various values in the impersonation mode which have not been nullified
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- Removed the current net performance
|
||||||
|
- Removed the read foreign portfolio permission
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
|
## 1.38.0 - 14.08.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the overview menu item on mobile
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Refactored the exchange rate service
|
||||||
|
- Improved the users table in the admin control panel
|
||||||
|
|
||||||
|
## 1.37.0 - 13.08.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the calculated net worth to the portfolio summary tab on the home page
|
||||||
|
- Added the calculated time in market to the portfolio summary tab on the home page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the usability of the tabs on the home page
|
||||||
|
- Restructured the portfolio summary tab on the home page
|
||||||
|
- Upgraded `angular-material-css-vars` from version `2.1.0` to `2.1.2`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the position detail chart if there are missing historical data around the first buy date
|
||||||
|
- Fixed the snack bar background color in dark mode
|
||||||
|
- Fixed the search functionality for symbols (filter for supported currencies)
|
||||||
|
|
||||||
|
## 1.36.0 - 09.08.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the data gathering handling on server restart
|
||||||
|
- Respected the cash balance on the allocations page
|
||||||
|
- Eliminated the name from the scraper configuration
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed hidden cryptocurrency holdings
|
||||||
|
|
||||||
|
## 1.35.0 - 08.08.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Hid the pagination of tabs
|
||||||
|
- Improved the classification of assets
|
||||||
|
- Improved the support for future transactions (drafts)
|
||||||
|
- Optimized the accounts table for mobile
|
||||||
|
- Upgraded `chart.js` from version `3.3.2` to `3.5.0`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Added a fallback if the exchange rate service has not been initialized correctly
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
|
## 1.34.0 - 07.08.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Restructured the page hierarchy
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with the currency conversion of the market price in the position detail dialog
|
||||||
|
- Fixed the chart and missing data of positions from the past in the position detail dialog
|
||||||
|
|
||||||
|
## 1.33.0 - 05.08.2021
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue of a division by zero in the portfolio calculations
|
||||||
|
- Fixed an issue with the currency conversion in the position detail dialog
|
||||||
|
|
||||||
|
## 1.32.0 - 04.08.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the name to the position detail dialog when opened from the transactions table
|
||||||
|
- Added a screenshot to the blog posts
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the missing market state in the positions tab
|
||||||
|
- Fixed the chart of positions with differing currency from user
|
||||||
|
|
||||||
|
## 1.31.1 - 01.08.2021
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue with the currency conversion in the portfolio calculations
|
||||||
|
|
||||||
|
## 1.31.0 - 01.08.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added more data points to the chart
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Rewritten the core engine for the portfolio calculations
|
||||||
|
- Switched to [Time-Weighted Rate of Return](https://www.investopedia.com/terms/t/time-weightedror.asp) (TWR) for the performance calculation
|
||||||
|
- Improved the performance of the portfolio calculations
|
||||||
|
|
||||||
|
## 1.30.0 - 31.07.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the date range component to the positions tab
|
||||||
|
- Added a blog
|
||||||
|
|
||||||
|
## 1.29.0 - 26.07.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Introduced tabs on the home page
|
||||||
|
- Changed the menu icon if the menu is open on mobile
|
||||||
|
|
||||||
|
## 1.28.0 - 24.07.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the data management by symbol profile data
|
||||||
|
- Added a currency attribute to the symbol profile model
|
||||||
|
- Added a positions button on the home page which scrolls into the view
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the style of the active page in the navigation on desktop
|
||||||
|
- Removed the footer for users
|
||||||
|
- Extended the _Zen Mode_ by positions
|
||||||
|
- Improved the _Create Account_ message in the _Live Demo_
|
||||||
|
|
||||||
|
## 1.27.0 - 18.07.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the onboarding
|
||||||
|
- Flow of creating a new account
|
||||||
|
- Info message to add the first transaction
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the chart on the landing page
|
||||||
|
- Fixed the url to the _Fear & Greed Index_ on the resources page
|
||||||
|
|
||||||
|
## 1.26.0 - 17.07.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the import functionality for transactions
|
||||||
|
- Added the `robots.txt` file
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the styling of the current pricing plan
|
||||||
|
- Improved the styling of the transaction type badge
|
||||||
|
- Set the public _Stripe_ key dynamically
|
||||||
|
- Upgraded `angular-material-css-vars` from version `2.0.0` to `2.1.0`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the warn color (button) of the theme
|
||||||
|
|
||||||
|
## 1.25.0 - 11.07.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the export functionality for transactions
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Respected the cash balance on the analysis page
|
||||||
|
- Improved the settings selectors on the account page
|
||||||
|
- Harmonized the slogan to "Open Source Wealth Management Software"
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed rendering of currency and platform in dialogs (account and transaction)
|
||||||
|
- Fixed an issue in the calculation of the average buy prices in the position detail chart
|
||||||
|
|
||||||
|
## 1.24.0 - 07.07.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the total value in the create or edit transaction dialog
|
||||||
|
- Added a balance attribute to the account model
|
||||||
|
- Calculated the total balance (cash)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Upgraded `@angular/cdk` and `@angular/material` from version `11.0.4` to `12.0.6`
|
||||||
|
- Upgraded `@nestjs` dependencies
|
||||||
|
- Upgraded `angular-material-css-vars` from version `1.2.0` to `2.0.0`
|
||||||
|
- Upgraded `Nx` from version `12.3.6` to `12.5.4`
|
||||||
|
|
||||||
## 1.23.1 - 03.07.2021
|
## 1.23.1 - 03.07.2021
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
52
Dockerfile
Normal file
52
Dockerfile
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
FROM node:14-alpine as builder
|
||||||
|
|
||||||
|
# Build application and add additional files
|
||||||
|
|
||||||
|
WORKDIR /ghostfolio
|
||||||
|
|
||||||
|
# Only add basic files without the application itself to avoid rebuilding
|
||||||
|
# layers when files (package.json etc.) have not changed
|
||||||
|
COPY ./CHANGELOG.md CHANGELOG.md
|
||||||
|
COPY ./LICENSE LICENSE
|
||||||
|
COPY ./package.json package.json
|
||||||
|
COPY ./yarn.lock yarn.lock
|
||||||
|
COPY ./prisma/schema.prisma prisma/schema.prisma
|
||||||
|
|
||||||
|
RUN apk add --no-cache python3 g++ make openssl
|
||||||
|
RUN yarn install
|
||||||
|
|
||||||
|
# See https://github.com/nrwl/nx/issues/6586 for further details
|
||||||
|
COPY ./decorate-angular-cli.js decorate-angular-cli.js
|
||||||
|
RUN node decorate-angular-cli.js
|
||||||
|
|
||||||
|
COPY ./angular.json angular.json
|
||||||
|
COPY ./nx.json nx.json
|
||||||
|
COPY ./replace.build.js replace.build.js
|
||||||
|
COPY ./jest.preset.js jest.preset.js
|
||||||
|
COPY ./jest.config.js jest.config.js
|
||||||
|
COPY ./tsconfig.base.json tsconfig.base.json
|
||||||
|
COPY ./libs libs
|
||||||
|
COPY ./apps apps
|
||||||
|
|
||||||
|
RUN yarn build:all
|
||||||
|
|
||||||
|
# Prepare the dist image with additional node_modules
|
||||||
|
WORKDIR /ghostfolio/dist/apps/api
|
||||||
|
# package.json was generated by the build process, however the original
|
||||||
|
# yarn.lock needs to be used to ensure the same versions
|
||||||
|
COPY ./yarn.lock /ghostfolio/dist/apps/api/yarn.lock
|
||||||
|
|
||||||
|
RUN yarn
|
||||||
|
COPY prisma /ghostfolio/dist/apps/api/prisma
|
||||||
|
|
||||||
|
# Overwrite the generated package.json with the original one to ensure having
|
||||||
|
# all the scripts
|
||||||
|
COPY package.json /ghostfolio/dist/apps/api
|
||||||
|
RUN yarn database:generate-typings
|
||||||
|
|
||||||
|
# Image to run, copy everything needed from builder
|
||||||
|
FROM node:14-alpine
|
||||||
|
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
||||||
|
WORKDIR /ghostfolio/apps/api
|
||||||
|
EXPOSE 3333
|
||||||
|
CMD [ "node", "main" ]
|
94
README.md
94
README.md
@ -1,13 +1,22 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
<a href="https://ghostfol.io">
|
||||||
|
<img
|
||||||
|
alt="Ghostfolio Logo"
|
||||||
|
src="https://avatars.githubusercontent.com/u/82473144?s=200"
|
||||||
|
width="100"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
|
||||||
<h1>Ghostfolio</h1>
|
<h1>Ghostfolio</h1>
|
||||||
<p>
|
<p>
|
||||||
<strong>Open Source Portfolio Tracker</strong>
|
<strong>Open Source Wealth Management Software made for Humans</strong>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a href="https://ghostfol.io"><strong>Live Demo</strong></a>
|
<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/en/blog/2021/07/hello-ghostfolio"><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>
|
||||||
<img src="https://img.shields.io/badge/contributions-welcome-orange.svg"/>
|
<a href="#contributing">
|
||||||
|
<img src="https://img.shields.io/badge/contributions-welcome-orange.svg"/></a>
|
||||||
<a href="https://travis-ci.com/github/ghostfolio/ghostfolio" rel="nofollow">
|
<a href="https://travis-ci.com/github/ghostfolio/ghostfolio" rel="nofollow">
|
||||||
<img src="https://travis-ci.com/ghostfolio/ghostfolio.svg?branch=main" alt="Build Status"/></a>
|
<img src="https://travis-ci.com/ghostfolio/ghostfolio.svg?branch=main" alt="Build Status"/></a>
|
||||||
<a href="https://www.gnu.org/licenses/agpl-3.0" rel="nofollow">
|
<a href="https://www.gnu.org/licenses/agpl-3.0" rel="nofollow">
|
||||||
@ -15,7 +24,17 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
**Ghostfolio** is an open source portfolio tracker based on web technology. The software empowers busy folks to have a sharp look of their financial assets and to make solid, data-driven investment decisions by evaluating automated static portfolio analysis rules.
|
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of their wealth like stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions.
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="./apps/client/src/assets/images/screenshot.png" width="300">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## Ghostfolio Premium
|
||||||
|
|
||||||
|
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, please find further instructions in the section [Run with Docker](#run-with-docker).
|
||||||
|
|
||||||
## Why Ghostfolio?
|
## Why Ghostfolio?
|
||||||
|
|
||||||
@ -43,7 +62,7 @@ Ghostfolio is for you if you are...
|
|||||||
|
|
||||||
- ✅ Create, update and delete transactions
|
- ✅ Create, update and delete transactions
|
||||||
- ✅ Multi account management
|
- ✅ Multi account management
|
||||||
- ✅ Portfolio performance (`Today`, `YTD`, `1Y`, `5Y`, `Max`)
|
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `YTD`, `1Y`, `5Y`, `Max`
|
||||||
- ✅ Various charts
|
- ✅ Various charts
|
||||||
- ✅ Static analysis to identify potential risks in your portfolio
|
- ✅ Static analysis to identify potential risks in your portfolio
|
||||||
- ✅ Dark Mode
|
- ✅ Dark Mode
|
||||||
@ -62,13 +81,52 @@ The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://
|
|||||||
|
|
||||||
The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
|
The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
|
||||||
|
|
||||||
## Getting Started
|
## Run with Docker
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
|
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||||
|
|
||||||
|
### Setup Docker Image
|
||||||
|
|
||||||
|
Run the following commands to build and start the Docker image:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose -f docker/docker-compose-build-local.yml build
|
||||||
|
docker-compose -f docker/docker-compose-build-local.yml up
|
||||||
|
```
|
||||||
|
|
||||||
|
### Setup Database
|
||||||
|
|
||||||
|
Run the following command to setup the database once Ghostfolio is running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn database:setup
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fetch Historical Data
|
||||||
|
|
||||||
|
Open http://localhost:3333 in your browser and accomplish these steps:
|
||||||
|
|
||||||
|
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
|
||||||
|
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
||||||
|
1. Click _Sign out_ and check out the _Live Demo_
|
||||||
|
|
||||||
|
### Migrate Database
|
||||||
|
|
||||||
|
With the following command you can keep your database schema in sync after a Ghostfolio version update:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn database:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- [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 14+)
|
||||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
@ -76,27 +134,27 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
|
|||||||
1. Run `cd docker`
|
1. Run `cd docker`
|
||||||
1. Run `docker compose up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
1. Run `docker compose up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||||
1. Run `cd -` to go back to the project root directory
|
1. Run `cd -` to go back to the project root directory
|
||||||
1. Run `yarn setup:database` to initialize the database schema and populate your database with (example) data
|
1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data
|
||||||
1. Start server and client (see [_Development_](#Development))
|
1. Start server and client (see [_Development_](#Development))
|
||||||
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
|
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
|
||||||
1. Go to the _Admin Control Panel_ and press _Gather All Data_ to fetch historical data
|
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
||||||
1. Press _Sign out_ and check out the _Live Demo_
|
1. Click _Sign out_ and check out the _Live Demo_
|
||||||
|
|
||||||
## Development
|
### Start Server
|
||||||
|
|
||||||
Please make sure you have completed the instructions from [_Setup_](#Setup).
|
|
||||||
|
|
||||||
### Start server
|
|
||||||
|
|
||||||
<ol type="a">
|
<ol type="a">
|
||||||
<li>Debug: Run <code>yarn watch:server</code> and click "Launch Program" in <i>Visual Studio Code</i></li>
|
<li>Debug: Run <code>yarn watch:server</code> and click "Launch Program" in <a href="https://code.visualstudio.com">Visual Studio Code</a></li>
|
||||||
<li>Serve: Run <code>yarn start:server</code></li>
|
<li>Serve: Run <code>yarn start:server</code></li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
### Start client
|
### Start Client
|
||||||
|
|
||||||
Run `yarn start:client`
|
Run `yarn start:client`
|
||||||
|
|
||||||
|
### Start _Storybook_
|
||||||
|
|
||||||
|
Run `yarn start:storybook`
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Run `yarn test`
|
Run `yarn test`
|
||||||
@ -105,7 +163,7 @@ Run `yarn test`
|
|||||||
|
|
||||||
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
|
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
|
||||||
|
|
||||||
Not sure what to work on? We have got some ideas. Please tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you.
|
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg), tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
122
angular.json
122
angular.json
@ -1,19 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"cli": {
|
|
||||||
"defaultCollection": "@nrwl/nest"
|
|
||||||
},
|
|
||||||
"defaultProject": "api",
|
|
||||||
"schematics": {
|
|
||||||
"@nrwl/angular:application": {
|
|
||||||
"unitTestRunner": "jest",
|
|
||||||
"e2eTestRunner": "cypress"
|
|
||||||
},
|
|
||||||
"@nrwl/angular:library": {
|
|
||||||
"unitTestRunner": "jest"
|
|
||||||
},
|
|
||||||
"@nrwl/nest": {}
|
|
||||||
},
|
|
||||||
"projects": {
|
"projects": {
|
||||||
"api": {
|
"api": {
|
||||||
"root": "apps/api",
|
"root": "apps/api",
|
||||||
@ -32,6 +18,7 @@
|
|||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
|
"generatePackageJson": true,
|
||||||
"optimization": true,
|
"optimization": true,
|
||||||
"extractLicenses": true,
|
"extractLicenses": true,
|
||||||
"inspect": false,
|
"inspect": false,
|
||||||
@ -65,7 +52,8 @@
|
|||||||
},
|
},
|
||||||
"outputs": ["coverage/apps/api"]
|
"outputs": ["coverage/apps/api"]
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"tags": []
|
||||||
},
|
},
|
||||||
"client": {
|
"client": {
|
||||||
"projectType": "application",
|
"projectType": "application",
|
||||||
@ -96,11 +84,16 @@
|
|||||||
{
|
{
|
||||||
"glob": "CHANGELOG.md",
|
"glob": "CHANGELOG.md",
|
||||||
"input": "",
|
"input": "",
|
||||||
"output": "./"
|
"output": "./assets"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"glob": "LICENSE",
|
"glob": "LICENSE",
|
||||||
"input": "",
|
"input": "",
|
||||||
|
"output": "./assets"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"glob": "robots.txt",
|
||||||
|
"input": "apps/client/src/assets",
|
||||||
"output": "./"
|
"output": "./"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -192,7 +185,8 @@
|
|||||||
},
|
},
|
||||||
"outputs": ["coverage/apps/client"]
|
"outputs": ["coverage/apps/client"]
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"tags": []
|
||||||
},
|
},
|
||||||
"client-e2e": {
|
"client-e2e": {
|
||||||
"root": "apps/client-e2e",
|
"root": "apps/client-e2e",
|
||||||
@ -212,7 +206,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"tags": [],
|
||||||
|
"implicitDependencies": ["client"]
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"root": "libs/common",
|
"root": "libs/common",
|
||||||
@ -233,7 +229,95 @@
|
|||||||
"passWithNoTests": true
|
"passWithNoTests": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"tags": []
|
||||||
|
},
|
||||||
|
"ui": {
|
||||||
|
"projectType": "library",
|
||||||
|
"schematics": {
|
||||||
|
"@schematics/angular:component": {
|
||||||
|
"style": "scss"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "libs/ui",
|
||||||
|
"sourceRoot": "libs/ui/src",
|
||||||
|
"prefix": "gf",
|
||||||
|
"architect": {
|
||||||
|
"test": {
|
||||||
|
"builder": "@nrwl/jest:jest",
|
||||||
|
"outputs": ["coverage/libs/ui"],
|
||||||
|
"options": {
|
||||||
|
"jestConfig": "libs/ui/jest.config.js",
|
||||||
|
"passWithNoTests": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"builder": "@nrwl/linter:eslint",
|
||||||
|
"options": {
|
||||||
|
"lintFilePatterns": ["libs/ui/src/**/*.ts", "libs/ui/src/**/*.html"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"storybook": {
|
||||||
|
"builder": "@nrwl/storybook:storybook",
|
||||||
|
"options": {
|
||||||
|
"uiFramework": "@storybook/angular",
|
||||||
|
"port": 4400,
|
||||||
|
"config": {
|
||||||
|
"configFolder": "libs/ui/.storybook"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"ci": {
|
||||||
|
"quiet": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"build-storybook": {
|
||||||
|
"builder": "@nrwl/storybook:build",
|
||||||
|
"outputs": ["{options.outputPath}"],
|
||||||
|
"options": {
|
||||||
|
"uiFramework": "@storybook/angular",
|
||||||
|
"outputPath": "dist/storybook/ui",
|
||||||
|
"config": {
|
||||||
|
"configFolder": "libs/ui/.storybook"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"ci": {
|
||||||
|
"quiet": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": []
|
||||||
|
},
|
||||||
|
"ui-e2e": {
|
||||||
|
"root": "apps/ui-e2e",
|
||||||
|
"sourceRoot": "apps/ui-e2e/src",
|
||||||
|
"projectType": "application",
|
||||||
|
"architect": {
|
||||||
|
"e2e": {
|
||||||
|
"builder": "@nrwl/cypress:cypress",
|
||||||
|
"options": {
|
||||||
|
"cypressConfig": "apps/ui-e2e/cypress.json",
|
||||||
|
"devServerTarget": "ui:storybook",
|
||||||
|
"tsConfig": "apps/ui-e2e/tsconfig.json"
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"ci": {
|
||||||
|
"devServerTarget": "ui:storybook:ci"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"builder": "@nrwl/linter:eslint",
|
||||||
|
"options": {
|
||||||
|
"lintFilePatterns": ["apps/ui-e2e/**/*.{js,ts}"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [],
|
||||||
|
"implicitDependencies": ["ui"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -11,5 +11,6 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||||
coverageDirectory: '../../coverage/apps/api',
|
coverageDirectory: '../../coverage/apps/api',
|
||||||
testTimeout: 10000
|
testTimeout: 10000,
|
||||||
|
testEnvironment: 'node'
|
||||||
};
|
};
|
||||||
|
@ -1,10 +1,25 @@
|
|||||||
import { Access } from '@ghostfolio/common/interfaces';
|
import { Access } from '@ghostfolio/common/interfaces';
|
||||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpException,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
UseGuards
|
||||||
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { Access as AccessModel } from '@prisma/client';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
|
import { AccessModule } from './access.module';
|
||||||
import { AccessService } from './access.service';
|
import { AccessService } from './access.service';
|
||||||
|
import { CreateAccessDto } from './create-access.dto';
|
||||||
|
|
||||||
@Controller('access')
|
@Controller('access')
|
||||||
export class AccessController {
|
export class AccessController {
|
||||||
@ -20,13 +35,63 @@ export class AccessController {
|
|||||||
include: {
|
include: {
|
||||||
GranteeUser: true
|
GranteeUser: true
|
||||||
},
|
},
|
||||||
|
orderBy: { granteeUserId: 'asc' },
|
||||||
where: { userId: this.request.user.id }
|
where: { userId: this.request.user.id }
|
||||||
});
|
});
|
||||||
|
|
||||||
return accessesWithGranteeUser.map((access) => {
|
return accessesWithGranteeUser.map((access) => {
|
||||||
|
if (access.GranteeUser) {
|
||||||
|
return {
|
||||||
|
granteeAlias: access.GranteeUser?.alias,
|
||||||
|
id: access.id,
|
||||||
|
type: 'RESTRICTED_VIEW'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
granteeAlias: access.GranteeUser.alias
|
granteeAlias: 'Public',
|
||||||
|
id: access.id,
|
||||||
|
type: 'PUBLIC'
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async createAccess(
|
||||||
|
@Body() data: CreateAccessDto
|
||||||
|
): Promise<AccessModel> {
|
||||||
|
if (
|
||||||
|
!hasPermission(this.request.user.permissions, permissions.createAccess)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.accessService.createAccess({
|
||||||
|
User: { connect: { id: this.request.user.id } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async deleteAccess(@Param('id') id: string): Promise<AccessModule> {
|
||||||
|
if (
|
||||||
|
!hasPermission(this.request.user.permissions, permissions.deleteAccess)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.accessService.deleteAccess({
|
||||||
|
id_userId: {
|
||||||
|
id,
|
||||||
|
userId: this.request.user.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,8 +5,9 @@ import { AccessController } from './access.controller';
|
|||||||
import { AccessService } from './access.service';
|
import { AccessService } from './access.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [],
|
|
||||||
controllers: [AccessController],
|
controllers: [AccessController],
|
||||||
|
exports: [AccessService],
|
||||||
|
imports: [],
|
||||||
providers: [AccessService, PrismaService]
|
providers: [AccessService, PrismaService]
|
||||||
})
|
})
|
||||||
export class AccessModule {}
|
export class AccessModule {}
|
||||||
|
@ -1,11 +1,22 @@
|
|||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { AccessWithGranteeUser } from '@ghostfolio/common/types';
|
import { AccessWithGranteeUser } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Access, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AccessService {
|
export class AccessService {
|
||||||
public constructor(private prisma: PrismaService) {}
|
public constructor(private readonly prismaService: PrismaService) {}
|
||||||
|
|
||||||
|
public async access(
|
||||||
|
accessWhereInput: Prisma.AccessWhereInput
|
||||||
|
): Promise<AccessWithGranteeUser | null> {
|
||||||
|
return this.prismaService.access.findFirst({
|
||||||
|
include: {
|
||||||
|
GranteeUser: true
|
||||||
|
},
|
||||||
|
where: accessWhereInput
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public async accesses(params: {
|
public async accesses(params: {
|
||||||
include?: Prisma.AccessInclude;
|
include?: Prisma.AccessInclude;
|
||||||
@ -13,11 +24,11 @@ export class AccessService {
|
|||||||
take?: number;
|
take?: number;
|
||||||
cursor?: Prisma.AccessWhereUniqueInput;
|
cursor?: Prisma.AccessWhereUniqueInput;
|
||||||
where?: Prisma.AccessWhereInput;
|
where?: Prisma.AccessWhereInput;
|
||||||
orderBy?: Prisma.AccessOrderByInput;
|
orderBy?: Prisma.AccessOrderByWithRelationInput;
|
||||||
}): Promise<AccessWithGranteeUser[]> {
|
}): Promise<AccessWithGranteeUser[]> {
|
||||||
const { include, skip, take, cursor, where, orderBy } = params;
|
const { include, skip, take, cursor, where, orderBy } = params;
|
||||||
|
|
||||||
return this.prisma.access.findMany({
|
return this.prismaService.access.findMany({
|
||||||
cursor,
|
cursor,
|
||||||
include,
|
include,
|
||||||
orderBy,
|
orderBy,
|
||||||
@ -26,4 +37,18 @@ export class AccessService {
|
|||||||
where
|
where
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async createAccess(data: Prisma.AccessCreateInput): Promise<Access> {
|
||||||
|
return this.prismaService.access.create({
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteAccess(
|
||||||
|
where: Prisma.AccessWhereUniqueInput
|
||||||
|
): Promise<Access> {
|
||||||
|
return this.prismaService.access.delete({
|
||||||
|
where
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
1
apps/api/src/app/access/create-access.dto.ts
Normal file
1
apps/api/src/app/access/create-access.dto.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export class CreateAccessDto {}
|
@ -1,11 +1,13 @@
|
|||||||
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
import {
|
import {
|
||||||
getPermissions,
|
nullifyValuesInObject,
|
||||||
hasPermission,
|
nullifyValuesInObjects
|
||||||
permissions
|
} from '@ghostfolio/api/helper/object.helper';
|
||||||
} from '@ghostfolio/common/permissions';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
import { Accounts } from '@ghostfolio/common/interfaces';
|
||||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@ -33,17 +35,16 @@ export class AccountController {
|
|||||||
public constructor(
|
public constructor(
|
||||||
private readonly accountService: AccountService,
|
private readonly accountService: AccountService,
|
||||||
private readonly impersonationService: ImpersonationService,
|
private readonly impersonationService: ImpersonationService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
private readonly portfolioService: PortfolioService,
|
||||||
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||||
|
private readonly userService: UserService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async deleteAccount(@Param('id') id: string): Promise<AccountModel> {
|
public async deleteAccount(@Param('id') id: string): Promise<AccountModel> {
|
||||||
if (
|
if (
|
||||||
!hasPermission(
|
!hasPermission(this.request.user.permissions, permissions.deleteAccount)
|
||||||
getPermissions(this.request.user.role),
|
|
||||||
permissions.deleteAccount
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
@ -83,33 +84,39 @@ export class AccountController {
|
|||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getAllAccounts(
|
public async getAllAccounts(
|
||||||
@Headers('impersonation-id') impersonationId
|
@Headers('impersonation-id') impersonationId
|
||||||
): Promise<AccountModel[]> {
|
): Promise<Accounts> {
|
||||||
const impersonationUserId = await this.impersonationService.validateImpersonationId(
|
const impersonationUserId =
|
||||||
impersonationId,
|
await this.impersonationService.validateImpersonationId(
|
||||||
this.request.user.id
|
impersonationId,
|
||||||
);
|
this.request.user.id
|
||||||
|
);
|
||||||
|
|
||||||
let accounts = await this.accountService.accounts({
|
let accountsWithAggregations =
|
||||||
include: { Order: true, Platform: true },
|
await this.portfolioService.getAccountsWithAggregations(
|
||||||
orderBy: { name: 'asc' },
|
impersonationUserId || this.request.user.id
|
||||||
where: { userId: impersonationUserId || this.request.user.id }
|
);
|
||||||
});
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationUserId &&
|
impersonationUserId ||
|
||||||
!hasPermission(
|
this.userService.isRestrictedView(this.request.user)
|
||||||
getPermissions(this.request.user.role),
|
|
||||||
permissions.readForeignPortfolio
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
accounts = nullifyValuesInObjects(accounts, [
|
accountsWithAggregations = {
|
||||||
'fee',
|
...nullifyValuesInObject(accountsWithAggregations, [
|
||||||
'quantity',
|
'totalBalance',
|
||||||
'unitPrice'
|
'totalValue'
|
||||||
]);
|
]),
|
||||||
|
accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [
|
||||||
|
'balance',
|
||||||
|
'convertedBalance',
|
||||||
|
'fee',
|
||||||
|
'quantity',
|
||||||
|
'unitPrice',
|
||||||
|
'value'
|
||||||
|
])
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return accounts;
|
return accountsWithAggregations;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ -129,10 +136,7 @@ export class AccountController {
|
|||||||
@Body() data: CreateAccountDto
|
@Body() data: CreateAccountDto
|
||||||
): Promise<AccountModel> {
|
): Promise<AccountModel> {
|
||||||
if (
|
if (
|
||||||
!hasPermission(
|
!hasPermission(this.request.user.permissions, permissions.createAccount)
|
||||||
getPermissions(this.request.user.role),
|
|
||||||
permissions.createAccount
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
@ -169,10 +173,7 @@ export class AccountController {
|
|||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {
|
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {
|
||||||
if (
|
if (
|
||||||
!hasPermission(
|
!hasPermission(this.request.user.permissions, permissions.updateAccount)
|
||||||
getPermissions(this.request.user.role),
|
|
||||||
permissions.updateAccount
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
@ -1,30 +1,28 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { RedisCacheModule } from '../redis-cache/redis-cache.module';
|
|
||||||
import { AccountController } from './account.controller';
|
import { AccountController } from './account.controller';
|
||||||
import { AccountService } from './account.service';
|
import { AccountService } from './account.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [RedisCacheModule],
|
|
||||||
controllers: [AccountController],
|
controllers: [AccountController],
|
||||||
providers: [
|
imports: [
|
||||||
AccountService,
|
ConfigurationModule,
|
||||||
AlphaVantageService,
|
DataProviderModule,
|
||||||
ConfigurationService,
|
ExchangeRateDataModule,
|
||||||
DataProviderService,
|
ImpersonationModule,
|
||||||
GhostfolioScraperApiService,
|
PortfolioModule,
|
||||||
ImpersonationService,
|
PrismaModule,
|
||||||
PrismaService,
|
RedisCacheModule,
|
||||||
RakutenRapidApiService,
|
UserModule
|
||||||
YahooFinanceService
|
],
|
||||||
]
|
providers: [AccountService]
|
||||||
})
|
})
|
||||||
export class AccountModule {}
|
export class AccountModule {}
|
||||||
|
@ -1,20 +1,21 @@
|
|||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Account, Order, Prisma } from '@prisma/client';
|
import { Account, Order, Platform, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
import { RedisCacheService } from '../redis-cache/redis-cache.service';
|
import { CashDetails } from './interfaces/cash-details.interface';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AccountService {
|
export class AccountService {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly redisCacheService: RedisCacheService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private prisma: PrismaService
|
private readonly prismaService: PrismaService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async account(
|
public async account(
|
||||||
accountWhereUniqueInput: Prisma.AccountWhereUniqueInput
|
accountWhereUniqueInput: Prisma.AccountWhereUniqueInput
|
||||||
): Promise<Account | null> {
|
): Promise<Account | null> {
|
||||||
return this.prisma.account.findUnique({
|
return this.prismaService.account.findUnique({
|
||||||
where: accountWhereUniqueInput
|
where: accountWhereUniqueInput
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -27,7 +28,7 @@ export class AccountService {
|
|||||||
Order?: Order[];
|
Order?: Order[];
|
||||||
}
|
}
|
||||||
> {
|
> {
|
||||||
return this.prisma.account.findUnique({
|
return this.prismaService.account.findUnique({
|
||||||
include: accountInclude,
|
include: accountInclude,
|
||||||
where: accountWhereUniqueInput
|
where: accountWhereUniqueInput
|
||||||
});
|
});
|
||||||
@ -39,11 +40,16 @@ export class AccountService {
|
|||||||
take?: number;
|
take?: number;
|
||||||
cursor?: Prisma.AccountWhereUniqueInput;
|
cursor?: Prisma.AccountWhereUniqueInput;
|
||||||
where?: Prisma.AccountWhereInput;
|
where?: Prisma.AccountWhereInput;
|
||||||
orderBy?: Prisma.AccountOrderByInput;
|
orderBy?: Prisma.AccountOrderByWithRelationInput;
|
||||||
}): Promise<Account[]> {
|
}): Promise<
|
||||||
|
(Account & {
|
||||||
|
Order?: Order[];
|
||||||
|
Platform?: Platform;
|
||||||
|
})[]
|
||||||
|
> {
|
||||||
const { include, skip, take, cursor, where, orderBy } = params;
|
const { include, skip, take, cursor, where, orderBy } = params;
|
||||||
|
|
||||||
return this.prisma.account.findMany({
|
return this.prismaService.account.findMany({
|
||||||
cursor,
|
cursor,
|
||||||
include,
|
include,
|
||||||
orderBy,
|
orderBy,
|
||||||
@ -57,7 +63,7 @@ export class AccountService {
|
|||||||
data: Prisma.AccountCreateInput,
|
data: Prisma.AccountCreateInput,
|
||||||
aUserId: string
|
aUserId: string
|
||||||
): Promise<Account> {
|
): Promise<Account> {
|
||||||
return this.prisma.account.create({
|
return this.prismaService.account.create({
|
||||||
data
|
data
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -66,13 +72,56 @@ export class AccountService {
|
|||||||
where: Prisma.AccountWhereUniqueInput,
|
where: Prisma.AccountWhereUniqueInput,
|
||||||
aUserId: string
|
aUserId: string
|
||||||
): Promise<Account> {
|
): Promise<Account> {
|
||||||
this.redisCacheService.remove(`${aUserId}.portfolio`);
|
return this.prismaService.account.delete({
|
||||||
|
|
||||||
return this.prisma.account.delete({
|
|
||||||
where
|
where
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getAccounts(aUserId: string) {
|
||||||
|
const accounts = await this.accounts({
|
||||||
|
include: { Order: true, Platform: true },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
where: { userId: aUserId }
|
||||||
|
});
|
||||||
|
|
||||||
|
return accounts.map((account) => {
|
||||||
|
let transactionCount = 0;
|
||||||
|
|
||||||
|
for (const order of account.Order) {
|
||||||
|
if (!order.isDraft) {
|
||||||
|
transactionCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = { ...account, transactionCount };
|
||||||
|
|
||||||
|
delete result.Order;
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getCashDetails(
|
||||||
|
aUserId: string,
|
||||||
|
aCurrency: string
|
||||||
|
): Promise<CashDetails> {
|
||||||
|
let totalCashBalance = 0;
|
||||||
|
|
||||||
|
const accounts = await this.accounts({
|
||||||
|
where: { userId: aUserId }
|
||||||
|
});
|
||||||
|
|
||||||
|
accounts.forEach((account) => {
|
||||||
|
totalCashBalance += this.exchangeRateDataService.toCurrency(
|
||||||
|
account.balance,
|
||||||
|
account.currency,
|
||||||
|
aCurrency
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { accounts, balance: totalCashBalance };
|
||||||
|
}
|
||||||
|
|
||||||
public async updateAccount(
|
public async updateAccount(
|
||||||
params: {
|
params: {
|
||||||
where: Prisma.AccountWhereUniqueInput;
|
where: Prisma.AccountWhereUniqueInput;
|
||||||
@ -81,7 +130,7 @@ export class AccountService {
|
|||||||
aUserId: string
|
aUserId: string
|
||||||
): Promise<Account> {
|
): Promise<Account> {
|
||||||
const { data, where } = params;
|
const { data, where } = params;
|
||||||
return this.prisma.account.update({
|
return this.prismaService.account.update({
|
||||||
data,
|
data,
|
||||||
where
|
where
|
||||||
});
|
});
|
||||||
|
@ -1,10 +1,16 @@
|
|||||||
import { AccountType } from '@prisma/client';
|
import { AccountType } from '@prisma/client';
|
||||||
import { IsString, ValidateIf } from 'class-validator';
|
import { IsNumber, IsString, ValidateIf } from 'class-validator';
|
||||||
|
|
||||||
export class CreateAccountDto {
|
export class CreateAccountDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
accountType: AccountType;
|
accountType: AccountType;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
balance: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
currency: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
|
@ -0,0 +1,6 @@
|
|||||||
|
import { Account } from '@prisma/client';
|
||||||
|
|
||||||
|
export interface CashDetails {
|
||||||
|
accounts: Account[];
|
||||||
|
balance: number;
|
||||||
|
}
|
@ -1,10 +1,16 @@
|
|||||||
import { AccountType } from '@prisma/client';
|
import { AccountType } from '@prisma/client';
|
||||||
import { IsString, ValidateIf } from 'class-validator';
|
import { IsNumber, IsString, ValidateIf } from 'class-validator';
|
||||||
|
|
||||||
export class UpdateAccountDto {
|
export class UpdateAccountDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
accountType: AccountType;
|
accountType: AccountType;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
balance: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
currency: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
|
@ -1,21 +1,28 @@
|
|||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||||
import { AdminData } from '@ghostfolio/common/interfaces';
|
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
||||||
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import {
|
import {
|
||||||
getPermissions,
|
AdminData,
|
||||||
hasPermission,
|
AdminMarketData,
|
||||||
permissions
|
AdminMarketDataDetails
|
||||||
} from '@ghostfolio/common/permissions';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
|
Param,
|
||||||
Post,
|
Post,
|
||||||
|
Put,
|
||||||
UseGuards
|
UseGuards
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { DataSource, MarketData } from '@prisma/client';
|
||||||
|
import { isDate, isValid } from 'date-fns';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { AdminService } from './admin.service';
|
import { AdminService } from './admin.service';
|
||||||
@ -33,7 +40,7 @@ export class AdminController {
|
|||||||
public async getAdminData(): Promise<AdminData> {
|
public async getAdminData(): Promise<AdminData> {
|
||||||
if (
|
if (
|
||||||
!hasPermission(
|
!hasPermission(
|
||||||
getPermissions(this.request.user.role),
|
this.request.user.permissions,
|
||||||
permissions.accessAdminControl
|
permissions.accessAdminControl
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
@ -51,7 +58,7 @@ export class AdminController {
|
|||||||
public async gatherMax(): Promise<void> {
|
public async gatherMax(): Promise<void> {
|
||||||
if (
|
if (
|
||||||
!hasPermission(
|
!hasPermission(
|
||||||
getPermissions(this.request.user.role),
|
this.request.user.permissions,
|
||||||
permissions.accessAdminControl
|
permissions.accessAdminControl
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
@ -61,8 +68,146 @@ export class AdminController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.dataGatheringService.gatherProfileData();
|
||||||
this.dataGatheringService.gatherMax();
|
this.dataGatheringService.gatherMax();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('gather/profile-data')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async gatherProfileData(): Promise<void> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dataGatheringService.gatherProfileData();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('gather/:dataSource/:symbol')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async gatherSymbol(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dataGatheringService.gatherSymbol({ dataSource, symbol });
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('gather/:dataSource/:symbol/:dateString')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async gatherSymbolForDate(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('dateString') dateString: string,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
): Promise<MarketData> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(dateString);
|
||||||
|
|
||||||
|
if (!isDate(date)) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||||
|
StatusCodes.BAD_REQUEST
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.dataGatheringService.gatherSymbolForDate({
|
||||||
|
dataSource,
|
||||||
|
date,
|
||||||
|
symbol
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('market-data')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async getMarketData(): Promise<AdminMarketData> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.adminService.getMarketData();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('market-data/:symbol')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async getMarketDataBySymbol(
|
||||||
|
@Param('symbol') symbol
|
||||||
|
): Promise<AdminMarketDataDetails> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.adminService.getMarketDataBySymbol(symbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('settings/:key')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async updateProperty(
|
||||||
|
@Param('key') key: string,
|
||||||
|
@Body() data: PropertyDto
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.adminService.putSetting(key, data.value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,31 +1,29 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||||
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
||||||
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { AdminController } from './admin.controller';
|
import { AdminController } from './admin.controller';
|
||||||
import { AdminService } from './admin.service';
|
import { AdminService } from './admin.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [],
|
imports: [
|
||||||
|
ConfigurationModule,
|
||||||
|
DataGatheringModule,
|
||||||
|
DataProviderModule,
|
||||||
|
ExchangeRateDataModule,
|
||||||
|
MarketDataModule,
|
||||||
|
PrismaModule,
|
||||||
|
PropertyModule,
|
||||||
|
SubscriptionModule
|
||||||
|
],
|
||||||
controllers: [AdminController],
|
controllers: [AdminController],
|
||||||
providers: [
|
providers: [AdminService],
|
||||||
AdminService,
|
exports: [AdminService]
|
||||||
AlphaVantageService,
|
|
||||||
ConfigurationService,
|
|
||||||
DataGatheringService,
|
|
||||||
DataProviderService,
|
|
||||||
ExchangeRateDataService,
|
|
||||||
GhostfolioScraperApiService,
|
|
||||||
PrismaService,
|
|
||||||
RakutenRapidApiService,
|
|
||||||
YahooFinanceService
|
|
||||||
]
|
|
||||||
})
|
})
|
||||||
export class AdminModule {}
|
export class AdminModule {}
|
||||||
|
@ -1,94 +1,122 @@
|
|||||||
|
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.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 { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { AdminData } from '@ghostfolio/common/interfaces';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
|
import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config';
|
||||||
|
import {
|
||||||
|
AdminData,
|
||||||
|
AdminMarketData,
|
||||||
|
AdminMarketDataDetails
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Currency } from '@prisma/client';
|
import { Property } from '@prisma/client';
|
||||||
|
import { differenceInDays } from 'date-fns';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminService {
|
export class AdminService {
|
||||||
public constructor(
|
public constructor(
|
||||||
private exchangeRateDataService: ExchangeRateDataService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private prisma: PrismaService
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
|
private readonly marketDataService: MarketDataService,
|
||||||
|
private readonly prismaService: PrismaService,
|
||||||
|
private readonly propertyService: PropertyService,
|
||||||
|
private readonly subscriptionService: SubscriptionService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async get(): Promise<AdminData> {
|
public async get(): Promise<AdminData> {
|
||||||
return {
|
return {
|
||||||
exchangeRates: [
|
dataGatheringProgress:
|
||||||
{
|
await this.dataGatheringService.getDataGatheringProgress(),
|
||||||
label1: Currency.EUR,
|
exchangeRates: this.exchangeRateDataService
|
||||||
label2: Currency.CHF,
|
.getCurrencies()
|
||||||
value: await this.exchangeRateDataService.toCurrency(
|
.filter((currency) => {
|
||||||
1,
|
return currency !== baseCurrency;
|
||||||
Currency.EUR,
|
})
|
||||||
Currency.CHF
|
.map((currency) => {
|
||||||
)
|
return {
|
||||||
},
|
label1: baseCurrency,
|
||||||
{
|
label2: currency,
|
||||||
label1: Currency.GBP,
|
value: this.exchangeRateDataService.toCurrency(
|
||||||
label2: Currency.CHF,
|
1,
|
||||||
value: await this.exchangeRateDataService.toCurrency(
|
baseCurrency,
|
||||||
1,
|
currency
|
||||||
Currency.GBP,
|
)
|
||||||
Currency.CHF
|
};
|
||||||
)
|
}),
|
||||||
},
|
|
||||||
{
|
|
||||||
label1: Currency.USD,
|
|
||||||
label2: Currency.CHF,
|
|
||||||
value: await this.exchangeRateDataService.toCurrency(
|
|
||||||
1,
|
|
||||||
Currency.USD,
|
|
||||||
Currency.CHF
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label1: Currency.USD,
|
|
||||||
label2: Currency.EUR,
|
|
||||||
value: await this.exchangeRateDataService.toCurrency(
|
|
||||||
1,
|
|
||||||
Currency.USD,
|
|
||||||
Currency.EUR
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label1: Currency.USD,
|
|
||||||
label2: Currency.GBP,
|
|
||||||
value: await this.exchangeRateDataService.toCurrency(
|
|
||||||
1,
|
|
||||||
Currency.USD,
|
|
||||||
Currency.GBP
|
|
||||||
)
|
|
||||||
}
|
|
||||||
],
|
|
||||||
lastDataGathering: await this.getLastDataGathering(),
|
lastDataGathering: await this.getLastDataGathering(),
|
||||||
transactionCount: await this.prisma.order.count(),
|
settings: await this.propertyService.get(),
|
||||||
userCount: await this.prisma.user.count(),
|
transactionCount: await this.prismaService.order.count(),
|
||||||
|
userCount: await this.prismaService.user.count(),
|
||||||
users: await this.getUsersWithAnalytics()
|
users: await this.getUsersWithAnalytics()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getLastDataGathering() {
|
public async getMarketData(): Promise<AdminMarketData> {
|
||||||
const lastDataGathering = await this.prisma.property.findUnique({
|
return {
|
||||||
where: { key: 'LAST_DATA_GATHERING' }
|
marketData: await (
|
||||||
});
|
await this.dataGatheringService.getSymbolsMax()
|
||||||
|
).map((symbol) => {
|
||||||
|
return symbol;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (lastDataGathering?.value) {
|
public async getMarketDataBySymbol(
|
||||||
return new Date(lastDataGathering.value);
|
aSymbol: string
|
||||||
|
): Promise<AdminMarketDataDetails> {
|
||||||
|
return {
|
||||||
|
marketData: await this.marketDataService.marketDataItems({
|
||||||
|
orderBy: {
|
||||||
|
date: 'asc'
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
symbol: aSymbol
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async putSetting(key: string, value: string) {
|
||||||
|
let response: Property;
|
||||||
|
|
||||||
|
if (value === '') {
|
||||||
|
response = await this.propertyService.delete({ key });
|
||||||
|
} else {
|
||||||
|
response = await this.propertyService.put({ key, value });
|
||||||
}
|
}
|
||||||
|
|
||||||
const dataGatheringInProgress = await this.prisma.property.findUnique({
|
if (key === PROPERTY_CURRENCIES) {
|
||||||
where: { key: 'LOCKED_DATA_GATHERING' }
|
await this.exchangeRateDataService.initialize();
|
||||||
});
|
await this.dataGatheringService.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getLastDataGathering() {
|
||||||
|
const lastDataGathering =
|
||||||
|
await this.dataGatheringService.getLastDataGathering();
|
||||||
|
|
||||||
|
if (lastDataGathering) {
|
||||||
|
return lastDataGathering;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataGatheringInProgress =
|
||||||
|
await this.dataGatheringService.getIsInProgress();
|
||||||
|
|
||||||
if (dataGatheringInProgress) {
|
if (dataGatheringInProgress) {
|
||||||
return 'IN_PROGRESS';
|
return 'IN_PROGRESS';
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getUsersWithAnalytics() {
|
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
|
||||||
return await this.prisma.user.findMany({
|
const usersWithAnalytics = await this.prismaService.user.findMany({
|
||||||
orderBy: {
|
orderBy: {
|
||||||
Analytics: {
|
Analytics: {
|
||||||
updatedAt: 'desc'
|
updatedAt: 'desc'
|
||||||
@ -106,7 +134,8 @@ export class AdminService {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
id: true
|
id: true,
|
||||||
|
Subscription: true
|
||||||
},
|
},
|
||||||
take: 30,
|
take: 30,
|
||||||
where: {
|
where: {
|
||||||
@ -115,5 +144,30 @@ export class AdminService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return usersWithAnalytics.map(
|
||||||
|
({ _count, alias, Analytics, createdAt, id, Subscription }) => {
|
||||||
|
const daysSinceRegistration =
|
||||||
|
differenceInDays(new Date(), createdAt) + 1;
|
||||||
|
const engagement = Analytics.activityCount / daysSinceRegistration;
|
||||||
|
|
||||||
|
const subscription = this.configurationService.get(
|
||||||
|
'ENABLE_FEATURE_SUBSCRIPTION'
|
||||||
|
)
|
||||||
|
? this.subscriptionService.getSubscription(Subscription)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
alias,
|
||||||
|
createdAt,
|
||||||
|
engagement,
|
||||||
|
id,
|
||||||
|
subscription,
|
||||||
|
accountCount: _count.Account || 0,
|
||||||
|
lastActivity: Analytics.updatedAt,
|
||||||
|
transactionCount: _count.Order || 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||||
import { Controller } from '@nestjs/common';
|
import { Controller } from '@nestjs/common';
|
||||||
|
|
||||||
import { PrismaService } from '../services/prisma.service';
|
|
||||||
import { RedisCacheService } from './redis-cache/redis-cache.service';
|
import { RedisCacheService } from './redis-cache/redis-cache.service';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AppController {
|
export class AppController {
|
||||||
public constructor(
|
public constructor(
|
||||||
private prisma: PrismaService,
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
private readonly redisCacheService: RedisCacheService
|
private readonly redisCacheService: RedisCacheService
|
||||||
) {
|
) {
|
||||||
this.initialize();
|
this.initialize();
|
||||||
@ -15,17 +15,12 @@ export class AppController {
|
|||||||
private async initialize() {
|
private async initialize() {
|
||||||
this.redisCacheService.reset();
|
this.redisCacheService.reset();
|
||||||
|
|
||||||
const isDataGatheringLocked = await this.prisma.property.findUnique({
|
const isDataGatheringInProgress =
|
||||||
where: { key: 'LOCKED_DATA_GATHERING' }
|
await this.dataGatheringService.getIsInProgress();
|
||||||
});
|
|
||||||
|
|
||||||
if (!isDataGatheringLocked) {
|
if (isDataGatheringInProgress) {
|
||||||
// Prepare for automatical data gather if not locked
|
// Prepare for automatical data gathering, if hung up in progress state
|
||||||
await this.prisma.property.deleteMany({
|
await this.dataGatheringService.reset();
|
||||||
where: {
|
|
||||||
OR: [{ key: 'LAST_DATA_GATHERING' }, { key: 'LOCKED_DATA_GATHERING' }]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,32 +1,29 @@
|
|||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
|
||||||
import { AuthDeviceModule } from '@ghostfolio/api/app/auth-device/auth-device.module';
|
import { AuthDeviceModule } from '@ghostfolio/api/app/auth-device/auth-device.module';
|
||||||
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
|
import { CronService } from '@ghostfolio/api/services/cron.service';
|
||||||
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||||
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } 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';
|
||||||
|
|
||||||
import { ConfigurationService } from '../services/configuration.service';
|
|
||||||
import { CronService } from '../services/cron.service';
|
|
||||||
import { DataGatheringService } from '../services/data-gathering.service';
|
|
||||||
import { DataProviderService } from '../services/data-provider.service';
|
|
||||||
import { AlphaVantageService } from '../services/data-provider/alpha-vantage/alpha-vantage.service';
|
|
||||||
import { GhostfolioScraperApiService } from '../services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
|
||||||
import { RakutenRapidApiService } from '../services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
|
||||||
import { YahooFinanceService } from '../services/data-provider/yahoo-finance/yahoo-finance.service';
|
|
||||||
import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
|
|
||||||
import { PrismaService } from '../services/prisma.service';
|
|
||||||
import { AccessModule } from './access/access.module';
|
import { AccessModule } from './access/access.module';
|
||||||
import { AccountModule } from './account/account.module';
|
import { AccountModule } from './account/account.module';
|
||||||
import { AdminModule } from './admin/admin.module';
|
import { AdminModule } from './admin/admin.module';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { CacheModule } from './cache/cache.module';
|
import { CacheModule } from './cache/cache.module';
|
||||||
import { ExperimentalModule } from './experimental/experimental.module';
|
import { ExportModule } from './export/export.module';
|
||||||
|
import { 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';
|
||||||
import { PortfolioModule } from './portfolio/portfolio.module';
|
import { PortfolioModule } from './portfolio/portfolio.module';
|
||||||
import { RedisCacheModule } from './redis-cache/redis-cache.module';
|
|
||||||
import { SubscriptionModule } from './subscription/subscription.module';
|
import { SubscriptionModule } from './subscription/subscription.module';
|
||||||
import { SymbolModule } from './symbol/symbol.module';
|
import { SymbolModule } from './symbol/symbol.module';
|
||||||
import { UserModule } from './user/user.module';
|
import { UserModule } from './user/user.module';
|
||||||
@ -40,10 +37,16 @@ import { UserModule } from './user/user.module';
|
|||||||
AuthModule,
|
AuthModule,
|
||||||
CacheModule,
|
CacheModule,
|
||||||
ConfigModule.forRoot(),
|
ConfigModule.forRoot(),
|
||||||
ExperimentalModule,
|
ConfigurationModule,
|
||||||
|
DataGatheringModule,
|
||||||
|
DataProviderModule,
|
||||||
|
ExchangeRateDataModule,
|
||||||
|
ExportModule,
|
||||||
|
ImportModule,
|
||||||
InfoModule,
|
InfoModule,
|
||||||
OrderModule,
|
OrderModule,
|
||||||
PortfolioModule,
|
PortfolioModule,
|
||||||
|
PrismaModule,
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
ServeStaticModule.forRoot({
|
ServeStaticModule.forRoot({
|
||||||
@ -65,17 +68,6 @@ import { UserModule } from './user/user.module';
|
|||||||
UserModule
|
UserModule
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
providers: [CronService]
|
||||||
AlphaVantageService,
|
|
||||||
ConfigurationService,
|
|
||||||
CronService,
|
|
||||||
DataGatheringService,
|
|
||||||
DataProviderService,
|
|
||||||
ExchangeRateDataService,
|
|
||||||
GhostfolioScraperApiService,
|
|
||||||
PrismaService,
|
|
||||||
RakutenRapidApiService,
|
|
||||||
YahooFinanceService
|
|
||||||
]
|
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
@ -1,10 +1,6 @@
|
|||||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||||
import {
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
getPermissions,
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
hasPermission,
|
|
||||||
permissions
|
|
||||||
} from '@ghostfolio/common/permissions';
|
|
||||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
@ -29,7 +25,7 @@ export class AuthDeviceController {
|
|||||||
public async deleteAuthDevice(@Param('id') id: string): Promise<void> {
|
public async deleteAuthDevice(@Param('id') id: string): Promise<void> {
|
||||||
if (
|
if (
|
||||||
!hasPermission(
|
!hasPermission(
|
||||||
getPermissions(this.request.user.role),
|
this.request.user.permissions,
|
||||||
permissions.deleteAuthDevice
|
permissions.deleteAuthDevice
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
|
@ -7,13 +7,13 @@ import { AuthDevice, Prisma } from '@prisma/client';
|
|||||||
export class AuthDeviceService {
|
export class AuthDeviceService {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private prisma: PrismaService
|
private readonly prismaService: PrismaService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async authDevice(
|
public async authDevice(
|
||||||
where: Prisma.AuthDeviceWhereUniqueInput
|
where: Prisma.AuthDeviceWhereUniqueInput
|
||||||
): Promise<AuthDevice | null> {
|
): Promise<AuthDevice | null> {
|
||||||
return this.prisma.authDevice.findUnique({
|
return this.prismaService.authDevice.findUnique({
|
||||||
where
|
where
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -23,10 +23,10 @@ export class AuthDeviceService {
|
|||||||
take?: number;
|
take?: number;
|
||||||
cursor?: Prisma.AuthDeviceWhereUniqueInput;
|
cursor?: Prisma.AuthDeviceWhereUniqueInput;
|
||||||
where?: Prisma.AuthDeviceWhereInput;
|
where?: Prisma.AuthDeviceWhereInput;
|
||||||
orderBy?: Prisma.AuthDeviceOrderByInput;
|
orderBy?: Prisma.AuthDeviceOrderByWithRelationInput;
|
||||||
}): Promise<AuthDevice[]> {
|
}): Promise<AuthDevice[]> {
|
||||||
const { skip, take, cursor, where, orderBy } = params;
|
const { skip, take, cursor, where, orderBy } = params;
|
||||||
return this.prisma.authDevice.findMany({
|
return this.prismaService.authDevice.findMany({
|
||||||
skip,
|
skip,
|
||||||
take,
|
take,
|
||||||
cursor,
|
cursor,
|
||||||
@ -38,7 +38,7 @@ export class AuthDeviceService {
|
|||||||
public async createAuthDevice(
|
public async createAuthDevice(
|
||||||
data: Prisma.AuthDeviceCreateInput
|
data: Prisma.AuthDeviceCreateInput
|
||||||
): Promise<AuthDevice> {
|
): Promise<AuthDevice> {
|
||||||
return this.prisma.authDevice.create({
|
return this.prismaService.authDevice.create({
|
||||||
data
|
data
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -49,7 +49,7 @@ export class AuthDeviceService {
|
|||||||
}): Promise<AuthDevice> {
|
}): Promise<AuthDevice> {
|
||||||
const { data, where } = params;
|
const { data, where } = params;
|
||||||
|
|
||||||
return this.prisma.authDevice.update({
|
return this.prismaService.authDevice.update({
|
||||||
data,
|
data,
|
||||||
where
|
where
|
||||||
});
|
});
|
||||||
@ -58,7 +58,7 @@ export class AuthDeviceService {
|
|||||||
public async deleteAuthDevice(
|
public async deleteAuthDevice(
|
||||||
where: Prisma.AuthDeviceWhereUniqueInput
|
where: Prisma.AuthDeviceWhereUniqueInput
|
||||||
): Promise<AuthDevice> {
|
): Promise<AuthDevice> {
|
||||||
return this.prisma.authDevice.delete({
|
return this.prismaService.authDevice.delete({
|
||||||
where
|
where
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -62,10 +62,10 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('webauthn/generate-attestation-options')
|
@Get('webauthn/generate-registration-options')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async generateAttestationOptions() {
|
public async generateRegistrationOptions() {
|
||||||
return this.webAuthService.generateAttestationOptions();
|
return this.webAuthService.generateRegistrationOptions();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('webauthn/verify-attestation')
|
@Post('webauthn/verify-attestation')
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||||
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
||||||
|
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||||
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
|
||||||
import { UserService } from '../user/user.service';
|
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { GoogleStrategy } from './google.strategy';
|
import { GoogleStrategy } from './google.strategy';
|
||||||
@ -17,7 +18,9 @@ import { JwtStrategy } from './jwt.strategy';
|
|||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
secret: process.env.JWT_SECRET_KEY,
|
secret: process.env.JWT_SECRET_KEY,
|
||||||
signOptions: { expiresIn: '180 days' }
|
signOptions: { expiresIn: '180 days' }
|
||||||
})
|
}),
|
||||||
|
SubscriptionModule,
|
||||||
|
UserModule
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
AuthDeviceService,
|
AuthDeviceService,
|
||||||
@ -26,7 +29,6 @@ import { JwtStrategy } from './jwt.strategy';
|
|||||||
GoogleStrategy,
|
GoogleStrategy,
|
||||||
JwtStrategy,
|
JwtStrategy,
|
||||||
PrismaService,
|
PrismaService,
|
||||||
UserService,
|
|
||||||
WebAuthService
|
WebAuthService
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
|
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 { UserService } from '../user/user.service';
|
|
||||||
import { ValidateOAuthLoginParams } from './interfaces/interfaces';
|
import { ValidateOAuthLoginParams } from './interfaces/interfaces';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { Provider } from '@prisma/client';
|
import { Provider } from '@prisma/client';
|
||||||
import { Strategy } from 'passport-google-oauth20';
|
import { Strategy } from 'passport-google-oauth20';
|
||||||
@ -41,9 +41,9 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
|||||||
};
|
};
|
||||||
|
|
||||||
done(null, user);
|
done(null, user);
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
console.error(err);
|
Logger.error(error);
|
||||||
done(err, false);
|
done(error, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,16 +1,15 @@
|
|||||||
|
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 { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
|
||||||
import { UserService } from '../user/user.service';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||||
public constructor(
|
public constructor(
|
||||||
readonly configurationService: ConfigurationService,
|
readonly configurationService: ConfigurationService,
|
||||||
private prisma: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly userService: UserService
|
private readonly userService: UserService
|
||||||
) {
|
) {
|
||||||
super({
|
super({
|
||||||
@ -24,7 +23,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|||||||
const user = await this.userService.user({ id });
|
const user = await this.userService.user({ id });
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
await this.prisma.analytics.upsert({
|
await this.prismaService.analytics.upsert({
|
||||||
create: { User: { connect: { id: user.id } } },
|
create: { User: { connect: { id: user.id } } },
|
||||||
update: { activityCount: { increment: 1 }, updatedAt: new Date() },
|
update: { activityCount: { increment: 1 }, updatedAt: new Date() },
|
||||||
where: { userId: user.id }
|
where: { userId: user.id }
|
||||||
|
@ -1,28 +1,29 @@
|
|||||||
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
|
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
|
||||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||||
|
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 { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Inject,
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
InternalServerErrorException
|
InternalServerErrorException,
|
||||||
|
Logger
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import {
|
import {
|
||||||
GenerateAssertionOptionsOpts,
|
GenerateAuthenticationOptionsOpts,
|
||||||
GenerateAttestationOptionsOpts,
|
GenerateRegistrationOptionsOpts,
|
||||||
VerifiedAssertion,
|
VerifiedAuthenticationResponse,
|
||||||
VerifiedAttestation,
|
VerifiedRegistrationResponse,
|
||||||
VerifyAssertionResponseOpts,
|
VerifyAuthenticationResponseOpts,
|
||||||
VerifyAttestationResponseOpts,
|
VerifyRegistrationResponseOpts,
|
||||||
generateAssertionOptions,
|
generateAuthenticationOptions,
|
||||||
generateAttestationOptions,
|
generateRegistrationOptions,
|
||||||
verifyAssertionResponse,
|
verifyAuthenticationResponse,
|
||||||
verifyAttestationResponse
|
verifyRegistrationResponse
|
||||||
} from '@simplewebauthn/server';
|
} from '@simplewebauthn/server';
|
||||||
|
|
||||||
import { UserService } from '../user/user.service';
|
|
||||||
import {
|
import {
|
||||||
AssertionCredentialJSON,
|
AssertionCredentialJSON,
|
||||||
AttestationCredentialJSON
|
AttestationCredentialJSON
|
||||||
@ -46,10 +47,10 @@ export class WebAuthService {
|
|||||||
return this.configurationService.get('ROOT_URL');
|
return this.configurationService.get('ROOT_URL');
|
||||||
}
|
}
|
||||||
|
|
||||||
public async generateAttestationOptions() {
|
public async generateRegistrationOptions() {
|
||||||
const user = this.request.user;
|
const user = this.request.user;
|
||||||
|
|
||||||
const opts: GenerateAttestationOptionsOpts = {
|
const opts: GenerateRegistrationOptionsOpts = {
|
||||||
rpName: 'Ghostfolio',
|
rpName: 'Ghostfolio',
|
||||||
rpID: this.rpID,
|
rpID: this.rpID,
|
||||||
userID: user.id,
|
userID: user.id,
|
||||||
@ -63,7 +64,7 @@ export class WebAuthService {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const options = generateAttestationOptions(opts);
|
const options = generateRegistrationOptions(opts);
|
||||||
|
|
||||||
await this.userService.updateUser({
|
await this.userService.updateUser({
|
||||||
data: {
|
data: {
|
||||||
@ -84,27 +85,27 @@ export class WebAuthService {
|
|||||||
const user = this.request.user;
|
const user = this.request.user;
|
||||||
const expectedChallenge = user.authChallenge;
|
const expectedChallenge = user.authChallenge;
|
||||||
|
|
||||||
let verification: VerifiedAttestation;
|
let verification: VerifiedRegistrationResponse;
|
||||||
try {
|
try {
|
||||||
const opts: VerifyAttestationResponseOpts = {
|
const opts: VerifyRegistrationResponseOpts = {
|
||||||
credential,
|
credential,
|
||||||
expectedChallenge,
|
expectedChallenge,
|
||||||
expectedOrigin: this.expectedOrigin,
|
expectedOrigin: this.expectedOrigin,
|
||||||
expectedRPID: this.rpID
|
expectedRPID: this.rpID
|
||||||
};
|
};
|
||||||
verification = await verifyAttestationResponse(opts);
|
verification = await verifyRegistrationResponse(opts);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
throw new InternalServerErrorException(error.message);
|
throw new InternalServerErrorException(error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { verified, attestationInfo } = verification;
|
const { registrationInfo, verified } = verification;
|
||||||
|
|
||||||
const devices = await this.deviceService.authDevices({
|
const devices = await this.deviceService.authDevices({
|
||||||
where: { userId: user.id }
|
where: { userId: user.id }
|
||||||
});
|
});
|
||||||
if (verified && attestationInfo) {
|
if (registrationInfo && verified) {
|
||||||
const { credentialPublicKey, credentialID, counter } = attestationInfo;
|
const { counter, credentialID, credentialPublicKey } = registrationInfo;
|
||||||
|
|
||||||
let existingDevice = devices.find(
|
let existingDevice = devices.find(
|
||||||
(device) => device.credentialId === credentialID
|
(device) => device.credentialId === credentialID
|
||||||
@ -115,9 +116,9 @@ export class WebAuthService {
|
|||||||
* Add the returned device to the user's list of devices
|
* Add the returned device to the user's list of devices
|
||||||
*/
|
*/
|
||||||
existingDevice = await this.deviceService.createAuthDevice({
|
existingDevice = await this.deviceService.createAuthDevice({
|
||||||
|
counter,
|
||||||
credentialPublicKey,
|
credentialPublicKey,
|
||||||
credentialId: credentialID,
|
credentialId: credentialID,
|
||||||
counter,
|
|
||||||
User: { connect: { id: user.id } }
|
User: { connect: { id: user.id } }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -138,20 +139,20 @@ export class WebAuthService {
|
|||||||
throw new Error('Device not found');
|
throw new Error('Device not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const opts: GenerateAssertionOptionsOpts = {
|
const opts: GenerateAuthenticationOptionsOpts = {
|
||||||
timeout: 60000,
|
|
||||||
allowCredentials: [
|
allowCredentials: [
|
||||||
{
|
{
|
||||||
id: device.credentialId,
|
id: device.credentialId,
|
||||||
type: 'public-key',
|
transports: ['internal'],
|
||||||
transports: ['internal']
|
type: 'public-key'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
userVerification: 'preferred',
|
rpID: this.rpID,
|
||||||
rpID: this.rpID
|
timeout: 60000,
|
||||||
|
userVerification: 'preferred'
|
||||||
};
|
};
|
||||||
|
|
||||||
const options = generateAssertionOptions(opts);
|
const options = generateAuthenticationOptions(opts);
|
||||||
|
|
||||||
await this.userService.updateUser({
|
await this.userService.updateUser({
|
||||||
data: {
|
data: {
|
||||||
@ -177,29 +178,29 @@ export class WebAuthService {
|
|||||||
|
|
||||||
const user = await this.userService.user({ id: device.userId });
|
const user = await this.userService.user({ id: device.userId });
|
||||||
|
|
||||||
let verification: VerifiedAssertion;
|
let verification: VerifiedAuthenticationResponse;
|
||||||
try {
|
try {
|
||||||
const opts: VerifyAssertionResponseOpts = {
|
const opts: VerifyAuthenticationResponseOpts = {
|
||||||
credential,
|
credential,
|
||||||
expectedChallenge: `${user.authChallenge}`,
|
|
||||||
expectedOrigin: this.expectedOrigin,
|
|
||||||
expectedRPID: this.rpID,
|
|
||||||
authenticator: {
|
authenticator: {
|
||||||
credentialID: device.credentialId,
|
credentialID: device.credentialId,
|
||||||
credentialPublicKey: device.credentialPublicKey,
|
credentialPublicKey: device.credentialPublicKey,
|
||||||
counter: device.counter
|
counter: device.counter
|
||||||
}
|
},
|
||||||
|
expectedChallenge: `${user.authChallenge}`,
|
||||||
|
expectedOrigin: this.expectedOrigin,
|
||||||
|
expectedRPID: this.rpID
|
||||||
};
|
};
|
||||||
verification = verifyAssertionResponse(opts);
|
verification = verifyAuthenticationResponse(opts);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
throw new InternalServerErrorException({ error: error.message });
|
throw new InternalServerErrorException({ error: error.message });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { verified, assertionInfo } = verification;
|
const { verified, authenticationInfo } = verification;
|
||||||
|
|
||||||
if (verified) {
|
if (verified) {
|
||||||
device.counter = assertionInfo.newCounter;
|
device.counter = authenticationInfo.newCounter;
|
||||||
|
|
||||||
await this.deviceService.updateAuthDevice({
|
await this.deviceService.updateAuthDevice({
|
||||||
data: device,
|
data: device,
|
||||||
|
9
apps/api/src/app/cache/cache.controller.ts
vendored
9
apps/api/src/app/cache/cache.controller.ts
vendored
@ -1,11 +1,10 @@
|
|||||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
||||||
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import { Controller, Inject, Post, UseGuards } from '@nestjs/common';
|
import { Controller, Inject, Post, UseGuards } from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
import { RedisCacheService } from '../redis-cache/redis-cache.service';
|
|
||||||
import { CacheService } from './cache.service';
|
|
||||||
|
|
||||||
@Controller('cache')
|
@Controller('cache')
|
||||||
export class CacheController {
|
export class CacheController {
|
||||||
public constructor(
|
public constructor(
|
||||||
@ -21,6 +20,6 @@ export class CacheController {
|
|||||||
public async flushCache(): Promise<void> {
|
public async flushCache(): Promise<void> {
|
||||||
this.redisCacheService.reset();
|
this.redisCacheService.reset();
|
||||||
|
|
||||||
return this.cacheService.flush(this.request.user.id);
|
return this.cacheService.flush();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
25
apps/api/src/app/cache/cache.module.ts
vendored
25
apps/api/src/app/cache/cache.module.ts
vendored
@ -1,13 +1,30 @@
|
|||||||
|
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
||||||
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||||
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||||
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { RedisCacheModule } from '../redis-cache/redis-cache.module';
|
|
||||||
import { CacheController } from './cache.controller';
|
import { CacheController } from './cache.controller';
|
||||||
import { CacheService } from './cache.service';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [RedisCacheModule],
|
imports: [
|
||||||
|
DataGatheringModule,
|
||||||
|
DataProviderModule,
|
||||||
|
ExchangeRateDataModule,
|
||||||
|
RedisCacheModule,
|
||||||
|
SymbolProfileModule
|
||||||
|
],
|
||||||
controllers: [CacheController],
|
controllers: [CacheController],
|
||||||
providers: [CacheService, PrismaService]
|
providers: [
|
||||||
|
CacheService,
|
||||||
|
ConfigurationService,
|
||||||
|
DataGatheringService,
|
||||||
|
PrismaService
|
||||||
|
]
|
||||||
})
|
})
|
||||||
export class CacheModule {}
|
export class CacheModule {}
|
||||||
|
14
apps/api/src/app/cache/cache.service.ts
vendored
14
apps/api/src/app/cache/cache.service.ts
vendored
@ -1,16 +1,14 @@
|
|||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CacheService {
|
export class CacheService {
|
||||||
public constructor(private prisma: PrismaService) {}
|
public constructor(
|
||||||
|
private readonly dataGaterhingService: DataGatheringService
|
||||||
|
) {}
|
||||||
|
|
||||||
public async flush(aUserId: string): Promise<void> {
|
public async flush(): Promise<void> {
|
||||||
await this.prisma.property.deleteMany({
|
await this.dataGaterhingService.reset();
|
||||||
where: {
|
|
||||||
OR: [{ key: 'LAST_DATA_GATHERING' }, { key: 'LOCKED_DATA_GATHERING' }]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -1,22 +0,0 @@
|
|||||||
import { Currency, Type } from '@prisma/client';
|
|
||||||
import { IsISO8601, IsNumber, IsString, ValidateIf } from 'class-validator';
|
|
||||||
|
|
||||||
export class CreateOrderDto {
|
|
||||||
@IsString()
|
|
||||||
currency: Currency;
|
|
||||||
|
|
||||||
@IsISO8601()
|
|
||||||
date: string;
|
|
||||||
|
|
||||||
@IsNumber()
|
|
||||||
quantity: number;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
symbol: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
type: Type;
|
|
||||||
|
|
||||||
@IsNumber()
|
|
||||||
unitPrice: number;
|
|
||||||
}
|
|
@ -1,90 +0,0 @@
|
|||||||
import { baseCurrency, benchmarks } from '@ghostfolio/common/config';
|
|
||||||
import { isApiTokenAuthorized } from '@ghostfolio/common/permissions';
|
|
||||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
|
||||||
import {
|
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
Headers,
|
|
||||||
HttpException,
|
|
||||||
Inject,
|
|
||||||
Param,
|
|
||||||
Post
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { REQUEST } from '@nestjs/core';
|
|
||||||
import { parse } from 'date-fns';
|
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
|
||||||
|
|
||||||
import { CreateOrderDto } from './create-order.dto';
|
|
||||||
import { ExperimentalService } from './experimental.service';
|
|
||||||
import { Data } from './interfaces/data.interface';
|
|
||||||
|
|
||||||
@Controller('experimental')
|
|
||||||
export class ExperimentalController {
|
|
||||||
public constructor(
|
|
||||||
private readonly experimentalService: ExperimentalService,
|
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Get('benchmarks')
|
|
||||||
public async getBenchmarks(
|
|
||||||
@Headers('Authorization') apiToken: string
|
|
||||||
): Promise<string[]> {
|
|
||||||
if (!isApiTokenAuthorized(apiToken)) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return benchmarks.map(({ symbol }) => {
|
|
||||||
return symbol;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('benchmarks/:symbol')
|
|
||||||
public async getBenchmark(
|
|
||||||
@Headers('Authorization') apiToken: string,
|
|
||||||
@Param('symbol') symbol: string
|
|
||||||
): Promise<{ date: Date; marketPrice: number }[]> {
|
|
||||||
if (!isApiTokenAuthorized(apiToken)) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const marketData = await this.experimentalService.getBenchmark(symbol);
|
|
||||||
|
|
||||||
if (marketData?.length === 0) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
|
||||||
StatusCodes.NOT_FOUND
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return marketData;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('value/:dateString?')
|
|
||||||
public async getValue(
|
|
||||||
@Body() orders: CreateOrderDto[],
|
|
||||||
@Headers('Authorization') apiToken: string,
|
|
||||||
@Param('dateString') dateString: string
|
|
||||||
): Promise<Data> {
|
|
||||||
if (!isApiTokenAuthorized(apiToken)) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let date = new Date();
|
|
||||||
|
|
||||||
if (dateString) {
|
|
||||||
date = parse(dateString, 'yyyy-MM-dd', new Date());
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.experimentalService.getValue(orders, date, baseCurrency);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,31 +0,0 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
|
||||||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
|
||||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
|
||||||
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
|
||||||
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
|
||||||
import { RulesService } from '@ghostfolio/api/services/rules.service';
|
|
||||||
import { Module } from '@nestjs/common';
|
|
||||||
|
|
||||||
import { ExperimentalController } from './experimental.controller';
|
|
||||||
import { ExperimentalService } from './experimental.service';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [],
|
|
||||||
controllers: [ExperimentalController],
|
|
||||||
providers: [
|
|
||||||
AlphaVantageService,
|
|
||||||
ConfigurationService,
|
|
||||||
DataProviderService,
|
|
||||||
ExchangeRateDataService,
|
|
||||||
ExperimentalService,
|
|
||||||
GhostfolioScraperApiService,
|
|
||||||
PrismaService,
|
|
||||||
RakutenRapidApiService,
|
|
||||||
RulesService,
|
|
||||||
YahooFinanceService
|
|
||||||
]
|
|
||||||
})
|
|
||||||
export class ExperimentalModule {}
|
|
@ -1,66 +0,0 @@
|
|||||||
import { Portfolio } from '@ghostfolio/api/models/portfolio';
|
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
|
||||||
import { RulesService } from '@ghostfolio/api/services/rules.service';
|
|
||||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
|
||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { Currency, Type } from '@prisma/client';
|
|
||||||
import { parseISO } from 'date-fns';
|
|
||||||
|
|
||||||
import { CreateOrderDto } from './create-order.dto';
|
|
||||||
import { Data } from './interfaces/data.interface';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class ExperimentalService {
|
|
||||||
public constructor(
|
|
||||||
private readonly dataProviderService: DataProviderService,
|
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
|
||||||
private prisma: PrismaService,
|
|
||||||
private readonly rulesService: RulesService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public async getBenchmark(aSymbol: string) {
|
|
||||||
return this.prisma.marketData.findMany({
|
|
||||||
orderBy: { date: 'asc' },
|
|
||||||
select: { date: true, marketPrice: true },
|
|
||||||
where: { symbol: aSymbol }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getValue(
|
|
||||||
aOrders: CreateOrderDto[],
|
|
||||||
aDate: Date,
|
|
||||||
aBaseCurrency: Currency
|
|
||||||
): Promise<Data> {
|
|
||||||
const ordersWithPlatform: OrderWithAccount[] = aOrders.map((order) => {
|
|
||||||
return {
|
|
||||||
...order,
|
|
||||||
accountId: undefined,
|
|
||||||
accountUserId: undefined,
|
|
||||||
createdAt: new Date(),
|
|
||||||
dataSource: undefined,
|
|
||||||
date: parseISO(order.date),
|
|
||||||
fee: 0,
|
|
||||||
id: undefined,
|
|
||||||
platformId: undefined,
|
|
||||||
symbolProfileId: undefined,
|
|
||||||
type: Type.BUY,
|
|
||||||
updatedAt: undefined,
|
|
||||||
userId: undefined
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const portfolio = new Portfolio(
|
|
||||||
this.dataProviderService,
|
|
||||||
this.exchangeRateDataService,
|
|
||||||
this.rulesService
|
|
||||||
);
|
|
||||||
await portfolio.setOrders(ordersWithPlatform);
|
|
||||||
|
|
||||||
return {
|
|
||||||
currency: aBaseCurrency,
|
|
||||||
value: portfolio.getValue(aDate)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,6 +0,0 @@
|
|||||||
import { Currency } from '@prisma/client';
|
|
||||||
|
|
||||||
export interface Data {
|
|
||||||
currency: Currency;
|
|
||||||
value: number;
|
|
||||||
}
|
|
23
apps/api/src/app/export/export.controller.ts
Normal file
23
apps/api/src/app/export/export.controller.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { Export } from '@ghostfolio/common/interfaces';
|
||||||
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
|
||||||
|
import { REQUEST } from '@nestjs/core';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
import { ExportService } from './export.service';
|
||||||
|
|
||||||
|
@Controller('export')
|
||||||
|
export class ExportController {
|
||||||
|
public constructor(
|
||||||
|
private readonly exportService: ExportService,
|
||||||
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async export(): Promise<Export> {
|
||||||
|
return await this.exportService.export({
|
||||||
|
userId: this.request.user.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
22
apps/api/src/app/export/export.module.ts
Normal file
22
apps/api/src/app/export/export.module.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||||
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { ExportController } from './export.controller';
|
||||||
|
import { ExportService } from './export.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigurationModule,
|
||||||
|
DataGatheringModule,
|
||||||
|
DataProviderModule,
|
||||||
|
PrismaModule,
|
||||||
|
RedisCacheModule
|
||||||
|
],
|
||||||
|
controllers: [ExportController],
|
||||||
|
providers: [ExportService]
|
||||||
|
})
|
||||||
|
export class ExportModule {}
|
31
apps/api/src/app/export/export.service.ts
Normal file
31
apps/api/src/app/export/export.service.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { environment } from '@ghostfolio/api/environments/environment';
|
||||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { Export } from '@ghostfolio/common/interfaces';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ExportService {
|
||||||
|
public constructor(private readonly prismaService: PrismaService) {}
|
||||||
|
|
||||||
|
public async export({ userId }: { userId: string }): Promise<Export> {
|
||||||
|
const orders = await this.prismaService.order.findMany({
|
||||||
|
orderBy: { date: 'desc' },
|
||||||
|
select: {
|
||||||
|
currency: true,
|
||||||
|
dataSource: true,
|
||||||
|
date: true,
|
||||||
|
fee: true,
|
||||||
|
quantity: true,
|
||||||
|
symbol: true,
|
||||||
|
type: true,
|
||||||
|
unitPrice: true
|
||||||
|
},
|
||||||
|
where: { userId }
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
meta: { date: new Date().toISOString(), version: environment.version },
|
||||||
|
orders
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
11
apps/api/src/app/import/import-data.dto.ts
Normal file
11
apps/api/src/app/import/import-data.dto.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||||
|
import { Order } from '@prisma/client';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsArray, ValidateNested } from 'class-validator';
|
||||||
|
|
||||||
|
export class ImportDataDto {
|
||||||
|
@IsArray()
|
||||||
|
@Type(() => CreateOrderDto)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
orders: Order[];
|
||||||
|
}
|
54
apps/api/src/app/import/import.controller.ts
Normal file
54
apps/api/src/app/import/import.controller.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
HttpException,
|
||||||
|
Inject,
|
||||||
|
Logger,
|
||||||
|
Post,
|
||||||
|
UseGuards
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { REQUEST } from '@nestjs/core';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
|
import { ImportDataDto } from './import-data.dto';
|
||||||
|
import { ImportService } from './import.service';
|
||||||
|
|
||||||
|
@Controller('import')
|
||||||
|
export class ImportController {
|
||||||
|
public constructor(
|
||||||
|
private readonly configurationService: ConfigurationService,
|
||||||
|
private readonly importService: ImportService,
|
||||||
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async import(@Body() importData: ImportDataDto): Promise<void> {
|
||||||
|
if (!this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.importService.import({
|
||||||
|
orders: importData.orders,
|
||||||
|
userId: this.request.user.id
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error);
|
||||||
|
|
||||||
|
throw new HttpException(
|
||||||
|
{
|
||||||
|
error: getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||||
|
message: [error.message]
|
||||||
|
},
|
||||||
|
StatusCodes.BAD_REQUEST
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
24
apps/api/src/app/import/import.module.ts
Normal file
24
apps/api/src/app/import/import.module.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
||||||
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||||
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { ImportController } from './import.controller';
|
||||||
|
import { ImportService } from './import.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigurationModule,
|
||||||
|
DataGatheringModule,
|
||||||
|
DataProviderModule,
|
||||||
|
PrismaModule,
|
||||||
|
RedisCacheModule
|
||||||
|
],
|
||||||
|
controllers: [ImportController],
|
||||||
|
providers: [CacheService, ImportService, OrderService]
|
||||||
|
})
|
||||||
|
export class ImportModule {}
|
115
apps/api/src/app/import/import.service.ts
Normal file
115
apps/api/src/app/import/import.service.ts
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Order } from '@prisma/client';
|
||||||
|
import { isSameDay, parseISO } from 'date-fns';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ImportService {
|
||||||
|
public constructor(
|
||||||
|
private readonly configurationService: ConfigurationService,
|
||||||
|
private readonly dataProviderService: DataProviderService,
|
||||||
|
private readonly orderService: OrderService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async import({
|
||||||
|
orders,
|
||||||
|
userId
|
||||||
|
}: {
|
||||||
|
orders: Partial<Order>[];
|
||||||
|
userId: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
await this.validateOrders({ orders, userId });
|
||||||
|
|
||||||
|
for (const {
|
||||||
|
accountId,
|
||||||
|
currency,
|
||||||
|
dataSource,
|
||||||
|
date,
|
||||||
|
fee,
|
||||||
|
quantity,
|
||||||
|
symbol,
|
||||||
|
type,
|
||||||
|
unitPrice
|
||||||
|
} of orders) {
|
||||||
|
await this.orderService.createOrder({
|
||||||
|
Account: {
|
||||||
|
connect: {
|
||||||
|
id_userId: { userId, id: accountId }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
currency,
|
||||||
|
dataSource,
|
||||||
|
fee,
|
||||||
|
quantity,
|
||||||
|
symbol,
|
||||||
|
type,
|
||||||
|
unitPrice,
|
||||||
|
date: parseISO(<string>(<unknown>date)),
|
||||||
|
User: { connect: { id: userId } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async validateOrders({
|
||||||
|
orders,
|
||||||
|
userId
|
||||||
|
}: {
|
||||||
|
orders: Partial<Order>[];
|
||||||
|
userId: string;
|
||||||
|
}) {
|
||||||
|
if (
|
||||||
|
orders?.length > this.configurationService.get('MAX_ORDERS_TO_IMPORT')
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Too many transactions (${this.configurationService.get(
|
||||||
|
'MAX_ORDERS_TO_IMPORT'
|
||||||
|
)} at most)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingOrders = await this.orderService.orders({
|
||||||
|
orderBy: { date: 'desc' },
|
||||||
|
where: { userId }
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [
|
||||||
|
index,
|
||||||
|
{ currency, dataSource, date, fee, quantity, symbol, type, unitPrice }
|
||||||
|
] of orders.entries()) {
|
||||||
|
const duplicateOrder = existingOrders.find((order) => {
|
||||||
|
return (
|
||||||
|
order.currency === currency &&
|
||||||
|
order.dataSource === dataSource &&
|
||||||
|
isSameDay(order.date, parseISO(<string>(<unknown>date))) &&
|
||||||
|
order.fee === fee &&
|
||||||
|
order.quantity === quantity &&
|
||||||
|
order.symbol === symbol &&
|
||||||
|
order.type === type &&
|
||||||
|
order.unitPrice === unitPrice
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (duplicateOrder) {
|
||||||
|
throw new Error(`orders.${index} is a duplicate transaction`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.dataProviderService.get([
|
||||||
|
{ dataSource, symbol }
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (result[symbol] === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
`orders.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result[symbol].currency !== currency) {
|
||||||
|
throw new Error(
|
||||||
|
`orders.${index}.currency ("${currency}") does not match with "${result[symbol].currency}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,12 @@
|
|||||||
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||||
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||||
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
|
||||||
@ -8,12 +15,23 @@ import { InfoService } from './info.service';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
DataGatheringModule,
|
||||||
|
DataProviderModule,
|
||||||
|
ExchangeRateDataModule,
|
||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
secret: process.env.JWT_SECRET_KEY,
|
secret: process.env.JWT_SECRET_KEY,
|
||||||
signOptions: { expiresIn: '30 days' }
|
signOptions: { expiresIn: '30 days' }
|
||||||
})
|
}),
|
||||||
|
PropertyModule,
|
||||||
|
RedisCacheModule,
|
||||||
|
SymbolProfileModule
|
||||||
],
|
],
|
||||||
controllers: [InfoController],
|
controllers: [InfoController],
|
||||||
providers: [ConfigurationService, InfoService, PrismaService]
|
providers: [
|
||||||
|
ConfigurationService,
|
||||||
|
DataGatheringService,
|
||||||
|
InfoService,
|
||||||
|
PrismaService
|
||||||
|
]
|
||||||
})
|
})
|
||||||
export class InfoModule {}
|
export class InfoModule {}
|
||||||
|
@ -1,32 +1,65 @@
|
|||||||
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.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 { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
|
import {
|
||||||
|
PROPERTY_IS_READ_ONLY_MODE,
|
||||||
|
PROPERTY_STRIPE_CONFIG,
|
||||||
|
PROPERTY_SYSTEM_MESSAGE
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
import { InfoItem } from '@ghostfolio/common/interfaces';
|
import { InfoItem } from '@ghostfolio/common/interfaces';
|
||||||
|
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
|
||||||
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
|
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
|
||||||
import { permissions } from '@ghostfolio/common/permissions';
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { Currency } from '@prisma/client';
|
|
||||||
import * as bent from 'bent';
|
import * as bent from 'bent';
|
||||||
import { subDays } from 'date-fns';
|
import { subDays } from 'date-fns';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class InfoService {
|
export class InfoService {
|
||||||
|
private static CACHE_KEY_STATISTICS = 'STATISTICS';
|
||||||
private static DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f';
|
private static DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f';
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private jwtService: JwtService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
private prisma: PrismaService
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
private readonly prismaService: PrismaService,
|
||||||
|
private readonly propertyService: PropertyService,
|
||||||
|
private readonly redisCacheService: RedisCacheService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async get(): Promise<InfoItem> {
|
public async get(): Promise<InfoItem> {
|
||||||
const platforms = await this.prisma.platform.findMany({
|
const info: Partial<InfoItem> = {};
|
||||||
|
let isReadOnlyMode: boolean;
|
||||||
|
const platforms = await this.prismaService.platform.findMany({
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' },
|
||||||
select: { id: true, name: true }
|
select: { id: true, name: true }
|
||||||
});
|
});
|
||||||
|
let systemMessage: string;
|
||||||
|
|
||||||
const globalPermissions: string[] = [];
|
const globalPermissions: string[] = [];
|
||||||
|
|
||||||
|
if (this.configurationService.get('ENABLE_FEATURE_BLOG')) {
|
||||||
|
globalPermissions.push(permissions.enableBlog);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
|
||||||
|
globalPermissions.push(permissions.enableImport);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
|
||||||
|
isReadOnlyMode = (await this.propertyService.getByKey(
|
||||||
|
PROPERTY_IS_READ_ONLY_MODE
|
||||||
|
)) as boolean;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_SOCIAL_LOGIN')) {
|
if (this.configurationService.get('ENABLE_FEATURE_SOCIAL_LOGIN')) {
|
||||||
globalPermissions.push(permissions.enableSocialLogin);
|
globalPermissions.push(permissions.enableSocialLogin);
|
||||||
}
|
}
|
||||||
@ -37,21 +70,35 @@ export class InfoService {
|
|||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
globalPermissions.push(permissions.enableSubscription);
|
globalPermissions.push(permissions.enableSubscription);
|
||||||
|
|
||||||
|
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.configurationService.get('ENABLE_FEATURE_SYSTEM_MESSAGE')) {
|
||||||
|
globalPermissions.push(permissions.enableSystemMessage);
|
||||||
|
|
||||||
|
systemMessage = (await this.propertyService.getByKey(
|
||||||
|
PROPERTY_SYSTEM_MESSAGE
|
||||||
|
)) as string;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
...info,
|
||||||
globalPermissions,
|
globalPermissions,
|
||||||
|
isReadOnlyMode,
|
||||||
platforms,
|
platforms,
|
||||||
currencies: Object.values(Currency),
|
systemMessage,
|
||||||
|
currencies: this.exchangeRateDataService.getCurrencies(),
|
||||||
demoAuthToken: this.getDemoAuthToken(),
|
demoAuthToken: this.getDemoAuthToken(),
|
||||||
lastDataGathering: await this.getLastDataGathering(),
|
lastDataGathering: await this.getLastDataGathering(),
|
||||||
|
primaryDataSource: this.dataProviderService.getPrimaryDataSource(),
|
||||||
statistics: await this.getStatistics(),
|
statistics: await this.getStatistics(),
|
||||||
subscriptions: await this.getSubscriptions()
|
subscriptions: await this.getSubscriptions()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async countActiveUsers(aDays: number) {
|
private async countActiveUsers(aDays: number) {
|
||||||
return await this.prisma.user.count({
|
return await this.prismaService.user.count({
|
||||||
orderBy: {
|
orderBy: {
|
||||||
Analytics: {
|
Analytics: {
|
||||||
updatedAt: 'desc'
|
updatedAt: 'desc'
|
||||||
@ -76,6 +123,27 @@ export class InfoService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async countGitHubContributors(): Promise<number> {
|
||||||
|
try {
|
||||||
|
const get = bent(
|
||||||
|
`https://api.github.com/repos/ghostfolio/ghostfolio/contributors`,
|
||||||
|
'GET',
|
||||||
|
'json',
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
'User-Agent': 'request'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const contributors = await get();
|
||||||
|
return contributors?.length;
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error);
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async countGitHubStargazers(): Promise<number> {
|
private async countGitHubStargazers(): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const get = bent(
|
const get = bent(
|
||||||
@ -91,12 +159,34 @@ export class InfoService {
|
|||||||
const { stargazers_count } = await get();
|
const { stargazers_count } = await get();
|
||||||
return stargazers_count;
|
return stargazers_count;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async countNewUsers(aDays: number) {
|
||||||
|
return await this.prismaService.user.count({
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc'
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
NOT: {
|
||||||
|
Analytics: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
createdAt: {
|
||||||
|
gt: subDays(new Date(), aDays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private getDemoAuthToken() {
|
private getDemoAuthToken() {
|
||||||
return this.jwtService.sign({
|
return this.jwtService.sign({
|
||||||
id: InfoService.DEMO_USER_ID
|
id: InfoService.DEMO_USER_ID
|
||||||
@ -104,11 +194,10 @@ export class InfoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async getLastDataGathering() {
|
private async getLastDataGathering() {
|
||||||
const lastDataGathering = await this.prisma.property.findUnique({
|
const lastDataGathering =
|
||||||
where: { key: 'LAST_DATA_GATHERING' }
|
await this.dataGatheringService.getLastDataGathering();
|
||||||
});
|
|
||||||
|
|
||||||
return lastDataGathering?.value ? new Date(lastDataGathering.value) : null;
|
return lastDataGathering ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getStatistics() {
|
private async getStatistics() {
|
||||||
@ -116,15 +205,40 @@ export class InfoService {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let statistics: Statistics;
|
||||||
|
|
||||||
|
try {
|
||||||
|
statistics = JSON.parse(
|
||||||
|
await this.redisCacheService.get(InfoService.CACHE_KEY_STATISTICS)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (statistics) {
|
||||||
|
return statistics;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
const activeUsers1d = await this.countActiveUsers(1);
|
const activeUsers1d = await this.countActiveUsers(1);
|
||||||
|
const activeUsers7d = await this.countActiveUsers(7);
|
||||||
const activeUsers30d = await this.countActiveUsers(30);
|
const activeUsers30d = await this.countActiveUsers(30);
|
||||||
|
const newUsers30d = await this.countNewUsers(30);
|
||||||
|
const gitHubContributors = await this.countGitHubContributors();
|
||||||
const gitHubStargazers = await this.countGitHubStargazers();
|
const gitHubStargazers = await this.countGitHubStargazers();
|
||||||
|
|
||||||
return {
|
statistics = {
|
||||||
activeUsers1d,
|
activeUsers1d,
|
||||||
|
activeUsers7d,
|
||||||
activeUsers30d,
|
activeUsers30d,
|
||||||
gitHubStargazers
|
gitHubContributors,
|
||||||
|
gitHubStargazers,
|
||||||
|
newUsers30d
|
||||||
};
|
};
|
||||||
|
|
||||||
|
await this.redisCacheService.set(
|
||||||
|
InfoService.CACHE_KEY_STATISTICS,
|
||||||
|
JSON.stringify(statistics)
|
||||||
|
);
|
||||||
|
|
||||||
|
return statistics;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getSubscriptions(): Promise<Subscription[]> {
|
private async getSubscriptions(): Promise<Subscription[]> {
|
||||||
@ -132,8 +246,8 @@ export class InfoService {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stripeConfig = await this.prisma.property.findUnique({
|
const stripeConfig = await this.prismaService.property.findUnique({
|
||||||
where: { key: 'STRIPE_CONFIG' }
|
where: { key: PROPERTY_STRIPE_CONFIG }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (stripeConfig) {
|
if (stripeConfig) {
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
import { Currency, DataSource, Type } from '@prisma/client';
|
import { DataSource, Type } from '@prisma/client';
|
||||||
import { IsISO8601, IsNumber, IsString, ValidateIf } from 'class-validator';
|
import { IsEnum, IsISO8601, IsNumber, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class CreateOrderDto {
|
export class CreateOrderDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
accountId: string;
|
accountId: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
currency: Currency;
|
currency: string;
|
||||||
|
|
||||||
@IsString()
|
@IsEnum(DataSource, { each: true })
|
||||||
dataSource: DataSource;
|
dataSource: DataSource;
|
||||||
|
|
||||||
@IsISO8601()
|
@IsISO8601()
|
||||||
@ -23,7 +23,7 @@ export class CreateOrderDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
symbol: string;
|
symbol: string;
|
||||||
|
|
||||||
@IsString()
|
@IsEnum(Type, { each: true })
|
||||||
type: Type;
|
type: Type;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
|
@ -1,11 +1,8 @@
|
|||||||
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
|
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||||
import {
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
getPermissions,
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
hasPermission,
|
|
||||||
permissions
|
|
||||||
} from '@ghostfolio/common/permissions';
|
|
||||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@ -34,17 +31,15 @@ export class OrderController {
|
|||||||
public constructor(
|
public constructor(
|
||||||
private readonly impersonationService: ImpersonationService,
|
private readonly impersonationService: ImpersonationService,
|
||||||
private readonly orderService: OrderService,
|
private readonly orderService: OrderService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||||
|
private readonly userService: UserService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
|
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
|
||||||
if (
|
if (
|
||||||
!hasPermission(
|
!hasPermission(this.request.user.permissions, permissions.deleteOrder)
|
||||||
getPermissions(this.request.user.role),
|
|
||||||
permissions.deleteOrder
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
@ -52,15 +47,12 @@ export class OrderController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.orderService.deleteOrder(
|
return this.orderService.deleteOrder({
|
||||||
{
|
id_userId: {
|
||||||
id_userId: {
|
id,
|
||||||
id,
|
userId: this.request.user.id
|
||||||
userId: this.request.user.id
|
}
|
||||||
}
|
});
|
||||||
},
|
|
||||||
this.request.user.id
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ -80,6 +72,11 @@ export class OrderController {
|
|||||||
include: {
|
include: {
|
||||||
Platform: true
|
Platform: true
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
SymbolProfile: {
|
||||||
|
select: {
|
||||||
|
name: true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
orderBy: { date: 'desc' },
|
orderBy: { date: 'desc' },
|
||||||
@ -87,11 +84,8 @@ export class OrderController {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationUserId &&
|
impersonationUserId ||
|
||||||
!hasPermission(
|
this.userService.isRestrictedView(this.request.user)
|
||||||
getPermissions(this.request.user.role),
|
|
||||||
permissions.readForeignPortfolio
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
orders = nullifyValuesInObjects(orders, ['fee', 'quantity', 'unitPrice']);
|
orders = nullifyValuesInObjects(orders, ['fee', 'quantity', 'unitPrice']);
|
||||||
}
|
}
|
||||||
@ -114,10 +108,7 @@ export class OrderController {
|
|||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> {
|
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> {
|
||||||
if (
|
if (
|
||||||
!hasPermission(
|
!hasPermission(this.request.user.permissions, permissions.createOrder)
|
||||||
getPermissions(this.request.user.role),
|
|
||||||
permissions.createOrder
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
@ -130,43 +121,37 @@ export class OrderController {
|
|||||||
const accountId = data.accountId;
|
const accountId = data.accountId;
|
||||||
delete data.accountId;
|
delete data.accountId;
|
||||||
|
|
||||||
return this.orderService.createOrder(
|
return this.orderService.createOrder({
|
||||||
{
|
...data,
|
||||||
...data,
|
Account: {
|
||||||
Account: {
|
connect: {
|
||||||
connect: {
|
id_userId: { id: accountId, userId: this.request.user.id }
|
||||||
id_userId: { id: accountId, userId: this.request.user.id }
|
}
|
||||||
}
|
},
|
||||||
},
|
date,
|
||||||
date,
|
SymbolProfile: {
|
||||||
SymbolProfile: {
|
connectOrCreate: {
|
||||||
connectOrCreate: {
|
where: {
|
||||||
where: {
|
dataSource_symbol: {
|
||||||
dataSource_symbol: {
|
|
||||||
dataSource: data.dataSource,
|
|
||||||
symbol: data.symbol
|
|
||||||
}
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
dataSource: data.dataSource,
|
dataSource: data.dataSource,
|
||||||
symbol: data.symbol
|
symbol: data.symbol
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
dataSource: data.dataSource,
|
||||||
|
symbol: data.symbol
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
User: { connect: { id: this.request.user.id } }
|
|
||||||
},
|
},
|
||||||
this.request.user.id
|
User: { connect: { id: this.request.user.id } }
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
|
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
|
||||||
if (
|
if (
|
||||||
!hasPermission(
|
!hasPermission(this.request.user.permissions, permissions.updateOrder)
|
||||||
getPermissions(this.request.user.role),
|
|
||||||
permissions.updateOrder
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
@ -193,26 +178,23 @@ export class OrderController {
|
|||||||
const accountId = data.accountId;
|
const accountId = data.accountId;
|
||||||
delete data.accountId;
|
delete data.accountId;
|
||||||
|
|
||||||
return this.orderService.updateOrder(
|
return this.orderService.updateOrder({
|
||||||
{
|
data: {
|
||||||
data: {
|
...data,
|
||||||
...data,
|
date,
|
||||||
date,
|
Account: {
|
||||||
Account: {
|
connect: {
|
||||||
connect: {
|
id_userId: { id: accountId, userId: this.request.user.id }
|
||||||
id_userId: { id: accountId, userId: this.request.user.id }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
User: { connect: { id: this.request.user.id } }
|
|
||||||
},
|
|
||||||
where: {
|
|
||||||
id_userId: {
|
|
||||||
id,
|
|
||||||
userId: this.request.user.id
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
User: { connect: { id: this.request.user.id } }
|
||||||
},
|
},
|
||||||
this.request.user.id
|
where: {
|
||||||
);
|
id_userId: {
|
||||||
|
id,
|
||||||
|
userId: this.request.user.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,34 +1,28 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||||
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { CacheService } from '../cache/cache.service';
|
|
||||||
import { RedisCacheModule } from '../redis-cache/redis-cache.module';
|
|
||||||
import { OrderController } from './order.controller';
|
import { OrderController } from './order.controller';
|
||||||
import { OrderService } from './order.service';
|
import { OrderService } from './order.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [RedisCacheModule],
|
imports: [
|
||||||
|
ConfigurationModule,
|
||||||
|
DataGatheringModule,
|
||||||
|
DataProviderModule,
|
||||||
|
ImpersonationModule,
|
||||||
|
PrismaModule,
|
||||||
|
RedisCacheModule,
|
||||||
|
UserModule
|
||||||
|
],
|
||||||
controllers: [OrderController],
|
controllers: [OrderController],
|
||||||
providers: [
|
providers: [CacheService, OrderService],
|
||||||
AlphaVantageService,
|
exports: [OrderService]
|
||||||
CacheService,
|
|
||||||
ConfigurationService,
|
|
||||||
DataGatheringService,
|
|
||||||
DataProviderService,
|
|
||||||
GhostfolioScraperApiService,
|
|
||||||
ImpersonationService,
|
|
||||||
OrderService,
|
|
||||||
PrismaService,
|
|
||||||
RakutenRapidApiService,
|
|
||||||
YahooFinanceService
|
|
||||||
]
|
|
||||||
})
|
})
|
||||||
export class OrderModule {}
|
export class OrderModule {}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||||
@ -5,22 +6,18 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { DataSource, Order, Prisma } from '@prisma/client';
|
import { DataSource, Order, Prisma } from '@prisma/client';
|
||||||
import { endOfToday, isAfter } from 'date-fns';
|
import { endOfToday, isAfter } from 'date-fns';
|
||||||
|
|
||||||
import { CacheService } from '../cache/cache.service';
|
|
||||||
import { RedisCacheService } from '../redis-cache/redis-cache.service';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OrderService {
|
export class OrderService {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly cacheService: CacheService,
|
private readonly cacheService: CacheService,
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
private readonly redisCacheService: RedisCacheService,
|
private readonly prismaService: PrismaService
|
||||||
private prisma: PrismaService
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async order(
|
public async order(
|
||||||
orderWhereUniqueInput: Prisma.OrderWhereUniqueInput
|
orderWhereUniqueInput: Prisma.OrderWhereUniqueInput
|
||||||
): Promise<Order | null> {
|
): Promise<Order | null> {
|
||||||
return this.prisma.order.findUnique({
|
return this.prismaService.order.findUnique({
|
||||||
where: orderWhereUniqueInput
|
where: orderWhereUniqueInput
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -31,11 +28,11 @@ export class OrderService {
|
|||||||
take?: number;
|
take?: number;
|
||||||
cursor?: Prisma.OrderWhereUniqueInput;
|
cursor?: Prisma.OrderWhereUniqueInput;
|
||||||
where?: Prisma.OrderWhereInput;
|
where?: Prisma.OrderWhereInput;
|
||||||
orderBy?: Prisma.OrderOrderByInput;
|
orderBy?: Prisma.OrderOrderByWithRelationInput;
|
||||||
}): Promise<OrderWithAccount[]> {
|
}): Promise<OrderWithAccount[]> {
|
||||||
const { include, skip, take, cursor, where, orderBy } = params;
|
const { include, skip, take, cursor, where, orderBy } = params;
|
||||||
|
|
||||||
return this.prisma.order.findMany({
|
return this.prismaService.order.findMany({
|
||||||
cursor,
|
cursor,
|
||||||
include,
|
include,
|
||||||
orderBy,
|
orderBy,
|
||||||
@ -45,65 +42,97 @@ export class OrderService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createOrder(
|
public async createOrder(data: Prisma.OrderCreateInput): Promise<Order> {
|
||||||
data: Prisma.OrderCreateInput,
|
const isDraft = isAfter(data.date as Date, endOfToday());
|
||||||
aUserId: string
|
|
||||||
): Promise<Order> {
|
|
||||||
this.redisCacheService.remove(`${aUserId}.portfolio`);
|
|
||||||
|
|
||||||
if (!isAfter(data.date as Date, endOfToday())) {
|
// Convert the symbol to uppercase to avoid case-sensitive duplicates
|
||||||
|
const symbol = data.symbol.toUpperCase();
|
||||||
|
|
||||||
|
if (!isDraft) {
|
||||||
// Gather symbol data of order in the background, if not draft
|
// Gather symbol data of order in the background, if not draft
|
||||||
this.dataGatheringService.gatherSymbols([
|
this.dataGatheringService.gatherSymbols([
|
||||||
{
|
{
|
||||||
|
symbol,
|
||||||
dataSource: data.dataSource,
|
dataSource: data.dataSource,
|
||||||
date: <Date>data.date,
|
date: <Date>data.date
|
||||||
symbol: data.symbol
|
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.cacheService.flush(aUserId);
|
this.dataGatheringService.gatherProfileData([
|
||||||
|
{ symbol, dataSource: data.dataSource }
|
||||||
|
]);
|
||||||
|
|
||||||
return this.prisma.order.create({
|
await this.cacheService.flush();
|
||||||
data
|
|
||||||
|
return this.prismaService.order.create({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
isDraft,
|
||||||
|
symbol
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deleteOrder(
|
public async deleteOrder(
|
||||||
where: Prisma.OrderWhereUniqueInput,
|
where: Prisma.OrderWhereUniqueInput
|
||||||
aUserId: string
|
|
||||||
): Promise<Order> {
|
): Promise<Order> {
|
||||||
this.redisCacheService.remove(`${aUserId}.portfolio`);
|
return this.prismaService.order.delete({
|
||||||
|
|
||||||
return this.prisma.order.delete({
|
|
||||||
where
|
where
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async updateOrder(
|
public getOrders({
|
||||||
params: {
|
includeDrafts = false,
|
||||||
where: Prisma.OrderWhereUniqueInput;
|
userId
|
||||||
data: Prisma.OrderUpdateInput;
|
}: {
|
||||||
},
|
includeDrafts?: boolean;
|
||||||
aUserId: string
|
userId: string;
|
||||||
): Promise<Order> {
|
}) {
|
||||||
|
const where: Prisma.OrderWhereInput = { userId };
|
||||||
|
|
||||||
|
if (includeDrafts === false) {
|
||||||
|
where.isDraft = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.orders({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
Account: true,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
SymbolProfile: true
|
||||||
|
},
|
||||||
|
orderBy: { date: 'asc' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateOrder(params: {
|
||||||
|
where: Prisma.OrderWhereUniqueInput;
|
||||||
|
data: Prisma.OrderUpdateInput;
|
||||||
|
}): Promise<Order> {
|
||||||
const { data, where } = params;
|
const { data, where } = params;
|
||||||
|
|
||||||
this.redisCacheService.remove(`${aUserId}.portfolio`);
|
const isDraft = isAfter(data.date as Date, endOfToday());
|
||||||
|
|
||||||
// Gather symbol data of order in the background
|
if (!isDraft) {
|
||||||
this.dataGatheringService.gatherSymbols([
|
// Gather symbol data of order in the background, if not draft
|
||||||
{
|
this.dataGatheringService.gatherSymbols([
|
||||||
dataSource: <DataSource>data.dataSource,
|
{
|
||||||
date: <Date>data.date,
|
dataSource: <DataSource>data.dataSource,
|
||||||
symbol: <string>data.symbol
|
date: <Date>data.date,
|
||||||
}
|
symbol: <string>data.symbol
|
||||||
]);
|
}
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
await this.cacheService.flush(aUserId);
|
await this.cacheService.flush();
|
||||||
|
|
||||||
return this.prisma.order.update({
|
return this.prismaService.order.update({
|
||||||
data,
|
data: {
|
||||||
|
...data,
|
||||||
|
isDraft
|
||||||
|
},
|
||||||
where
|
where
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import { Currency, DataSource, Type } from '@prisma/client';
|
import { DataSource, Type } from '@prisma/client';
|
||||||
import { IsISO8601, IsNumber, IsString, ValidateIf } from 'class-validator';
|
import { IsISO8601, IsNumber, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class UpdateOrderDto {
|
export class UpdateOrderDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
accountId: string;
|
accountId: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
currency: Currency;
|
currency: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
dataSource: DataSource;
|
dataSource: DataSource;
|
||||||
|
125
apps/api/src/app/portfolio/current-rate.service.spec.ts
Normal file
125
apps/api/src/app/portfolio/current-rate.service.spec.ts
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
|
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||||
|
import { DataSource, MarketData } from '@prisma/client';
|
||||||
|
|
||||||
|
import { CurrentRateService } from './current-rate.service';
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/services/market-data.service', () => {
|
||||||
|
return {
|
||||||
|
MarketDataService: jest.fn().mockImplementation(() => {
|
||||||
|
return {
|
||||||
|
get: (date: Date, symbol: string) => {
|
||||||
|
return Promise.resolve<MarketData>({
|
||||||
|
date,
|
||||||
|
symbol,
|
||||||
|
createdAt: date,
|
||||||
|
dataSource: DataSource.YAHOO,
|
||||||
|
id: 'aefcbe3a-ee10-4c4f-9f2d-8ffad7b05584',
|
||||||
|
marketPrice: 1847.839966
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getRange: ({
|
||||||
|
dateRangeEnd,
|
||||||
|
dateRangeStart,
|
||||||
|
symbols
|
||||||
|
}: {
|
||||||
|
dateRangeEnd: Date;
|
||||||
|
dateRangeStart: Date;
|
||||||
|
symbols: string[];
|
||||||
|
}) => {
|
||||||
|
return Promise.resolve<MarketData[]>([
|
||||||
|
{
|
||||||
|
createdAt: dateRangeStart,
|
||||||
|
dataSource: DataSource.YAHOO,
|
||||||
|
date: dateRangeStart,
|
||||||
|
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
|
||||||
|
marketPrice: 1841.823902,
|
||||||
|
symbol: symbols[0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
createdAt: dateRangeEnd,
|
||||||
|
dataSource: DataSource.YAHOO,
|
||||||
|
date: dateRangeEnd,
|
||||||
|
id: '082d6893-df27-4c91-8a5d-092e84315b56',
|
||||||
|
marketPrice: 1847.839966,
|
||||||
|
symbol: symbols[0]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/services/exchange-rate-data.service', () => {
|
||||||
|
return {
|
||||||
|
ExchangeRateDataService: jest.fn().mockImplementation(() => {
|
||||||
|
return {
|
||||||
|
initialize: () => Promise.resolve(),
|
||||||
|
toCurrency: (value: number) => {
|
||||||
|
return 1 * value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CurrentRateService', () => {
|
||||||
|
let currentRateService: CurrentRateService;
|
||||||
|
let dataProviderService: DataProviderService;
|
||||||
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
let marketDataService: MarketDataService;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
dataProviderService = new DataProviderService(null, [], null);
|
||||||
|
exchangeRateDataService = new ExchangeRateDataService(null, null, null);
|
||||||
|
marketDataService = new MarketDataService(null);
|
||||||
|
|
||||||
|
await exchangeRateDataService.initialize();
|
||||||
|
|
||||||
|
currentRateService = new CurrentRateService(
|
||||||
|
dataProviderService,
|
||||||
|
exchangeRateDataService,
|
||||||
|
marketDataService
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getValue', async () => {
|
||||||
|
expect(
|
||||||
|
await currentRateService.getValue({
|
||||||
|
currency: 'USD',
|
||||||
|
date: new Date(Date.UTC(2020, 0, 1, 0, 0, 0)),
|
||||||
|
symbol: 'AMZN',
|
||||||
|
userCurrency: 'CHF'
|
||||||
|
})
|
||||||
|
).toMatchObject({
|
||||||
|
marketPrice: 1847.839966
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getValues', async () => {
|
||||||
|
expect(
|
||||||
|
await currentRateService.getValues({
|
||||||
|
currencies: { AMZN: 'USD' },
|
||||||
|
dataGatheringItems: [{ dataSource: DataSource.YAHOO, symbol: 'AMZN' }],
|
||||||
|
dateQuery: {
|
||||||
|
lt: new Date(Date.UTC(2020, 0, 2, 0, 0, 0)),
|
||||||
|
gte: new Date(Date.UTC(2020, 0, 1, 0, 0, 0))
|
||||||
|
},
|
||||||
|
userCurrency: 'CHF'
|
||||||
|
})
|
||||||
|
).toMatchObject([
|
||||||
|
{
|
||||||
|
date: undefined,
|
||||||
|
marketPrice: 1841.823902,
|
||||||
|
symbol: 'AMZN'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: undefined,
|
||||||
|
marketPrice: 1847.839966,
|
||||||
|
symbol: 'AMZN'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
140
apps/api/src/app/portfolio/current-rate.service.ts
Normal file
140
apps/api/src/app/portfolio/current-rate.service.ts
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
|
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||||
|
import { resetHours } from '@ghostfolio/common/helper';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { isBefore, isToday } from 'date-fns';
|
||||||
|
import { flatten } from 'lodash';
|
||||||
|
|
||||||
|
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||||
|
import { GetValueParams } from './interfaces/get-value-params.interface';
|
||||||
|
import { GetValuesParams } from './interfaces/get-values-params.interface';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CurrentRateService {
|
||||||
|
public constructor(
|
||||||
|
private readonly dataProviderService: DataProviderService,
|
||||||
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
|
private readonly marketDataService: MarketDataService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async getValue({
|
||||||
|
currency,
|
||||||
|
date,
|
||||||
|
symbol,
|
||||||
|
userCurrency
|
||||||
|
}: GetValueParams): Promise<GetValueObject> {
|
||||||
|
if (isToday(date)) {
|
||||||
|
const dataProviderResult = await this.dataProviderService.get([
|
||||||
|
{
|
||||||
|
symbol,
|
||||||
|
dataSource: this.dataProviderService.getPrimaryDataSource()
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
symbol,
|
||||||
|
date: resetHours(date),
|
||||||
|
marketPrice: dataProviderResult?.[symbol]?.marketPrice ?? 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const marketData = await this.marketDataService.get({
|
||||||
|
date,
|
||||||
|
symbol
|
||||||
|
});
|
||||||
|
|
||||||
|
if (marketData) {
|
||||||
|
return {
|
||||||
|
date: marketData.date,
|
||||||
|
marketPrice: this.exchangeRateDataService.toCurrency(
|
||||||
|
marketData.marketPrice,
|
||||||
|
currency,
|
||||||
|
userCurrency
|
||||||
|
),
|
||||||
|
symbol: marketData.symbol
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Value not found for ${symbol} at ${resetHours(date)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getValues({
|
||||||
|
currencies,
|
||||||
|
dataGatheringItems,
|
||||||
|
dateQuery,
|
||||||
|
userCurrency
|
||||||
|
}: GetValuesParams): Promise<GetValueObject[]> {
|
||||||
|
const includeToday =
|
||||||
|
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
|
||||||
|
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
|
||||||
|
(!dateQuery.in || this.containsToday(dateQuery.in));
|
||||||
|
|
||||||
|
const promises: Promise<
|
||||||
|
{
|
||||||
|
date: Date;
|
||||||
|
marketPrice: number;
|
||||||
|
symbol: string;
|
||||||
|
}[]
|
||||||
|
>[] = [];
|
||||||
|
|
||||||
|
if (includeToday) {
|
||||||
|
const today = resetHours(new Date());
|
||||||
|
promises.push(
|
||||||
|
this.dataProviderService
|
||||||
|
.get(dataGatheringItems)
|
||||||
|
.then((dataResultProvider) => {
|
||||||
|
const result = [];
|
||||||
|
for (const dataGatheringItem of dataGatheringItems) {
|
||||||
|
result.push({
|
||||||
|
date: today,
|
||||||
|
marketPrice: this.exchangeRateDataService.toCurrency(
|
||||||
|
dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice ??
|
||||||
|
0,
|
||||||
|
dataResultProvider?.[dataGatheringItem.symbol]?.currency,
|
||||||
|
userCurrency
|
||||||
|
),
|
||||||
|
symbol: dataGatheringItem.symbol
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const symbols = dataGatheringItems.map((dataGatheringItem) => {
|
||||||
|
return dataGatheringItem.symbol;
|
||||||
|
});
|
||||||
|
|
||||||
|
promises.push(
|
||||||
|
this.marketDataService
|
||||||
|
.getRange({
|
||||||
|
dateQuery,
|
||||||
|
symbols
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
return data.map((marketDataItem) => {
|
||||||
|
return {
|
||||||
|
date: marketDataItem.date,
|
||||||
|
marketPrice: this.exchangeRateDataService.toCurrency(
|
||||||
|
marketDataItem.marketPrice,
|
||||||
|
currencies[marketDataItem.symbol],
|
||||||
|
userCurrency
|
||||||
|
),
|
||||||
|
symbol: marketDataItem.symbol
|
||||||
|
};
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return flatten(await Promise.all(promises));
|
||||||
|
}
|
||||||
|
|
||||||
|
private containsToday(dates: Date[]): boolean {
|
||||||
|
for (const date of dates) {
|
||||||
|
if (isToday(date)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,14 @@
|
|||||||
|
import { TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||||
|
import Big from 'big.js';
|
||||||
|
|
||||||
|
export interface CurrentPositions {
|
||||||
|
hasErrors: boolean;
|
||||||
|
positions: TimelinePosition[];
|
||||||
|
grossPerformance: Big;
|
||||||
|
grossPerformancePercentage: Big;
|
||||||
|
netAnnualizedPerformance: Big;
|
||||||
|
netPerformance: Big;
|
||||||
|
netPerformancePercentage: Big;
|
||||||
|
currentValue: Big;
|
||||||
|
totalInvestment: Big;
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
export interface DateQuery {
|
||||||
|
gte?: Date;
|
||||||
|
in?: Date[];
|
||||||
|
lt?: Date;
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
export interface GetValueObject {
|
||||||
|
date: Date;
|
||||||
|
marketPrice: number;
|
||||||
|
symbol: string;
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
export interface GetValueParams {
|
||||||
|
currency: string;
|
||||||
|
date: Date;
|
||||||
|
symbol: string;
|
||||||
|
userCurrency: string;
|
||||||
|
}
|
@ -0,0 +1,10 @@
|
|||||||
|
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
|
|
||||||
|
import { DateQuery } from './date-query.interface';
|
||||||
|
|
||||||
|
export interface GetValuesParams {
|
||||||
|
currencies: { [symbol: string]: string };
|
||||||
|
dataGatheringItems: IDataGatheringItem[];
|
||||||
|
dateQuery: DateQuery;
|
||||||
|
userCurrency: string;
|
||||||
|
}
|
@ -0,0 +1,15 @@
|
|||||||
|
import { OrderType } from '@ghostfolio/api/models/order-type';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
|
import Big from 'big.js';
|
||||||
|
|
||||||
|
export interface PortfolioOrder {
|
||||||
|
currency: string;
|
||||||
|
date: string;
|
||||||
|
dataSource: DataSource;
|
||||||
|
fee: Big;
|
||||||
|
name: string;
|
||||||
|
quantity: Big;
|
||||||
|
symbol: string;
|
||||||
|
type: OrderType;
|
||||||
|
unitPrice: Big;
|
||||||
|
}
|
@ -1,8 +1,10 @@
|
|||||||
import { Currency } from '@prisma/client';
|
import { AssetClass, AssetSubClass } from '@prisma/client';
|
||||||
|
|
||||||
export interface PortfolioPositionDetail {
|
export interface PortfolioPositionDetail {
|
||||||
|
assetClass?: AssetClass;
|
||||||
|
assetSubClass?: AssetSubClass;
|
||||||
averagePrice: number;
|
averagePrice: number;
|
||||||
currency: Currency;
|
currency: string;
|
||||||
firstBuyDate: string;
|
firstBuyDate: string;
|
||||||
grossPerformance: number;
|
grossPerformance: number;
|
||||||
grossPerformancePercent: number;
|
grossPerformancePercent: number;
|
||||||
@ -11,9 +13,19 @@ export interface PortfolioPositionDetail {
|
|||||||
marketPrice: number;
|
marketPrice: number;
|
||||||
maxPrice: number;
|
maxPrice: number;
|
||||||
minPrice: number;
|
minPrice: number;
|
||||||
|
name: string;
|
||||||
|
netPerformance: number;
|
||||||
|
netPerformancePercent: number;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
transactionCount: number;
|
transactionCount: number;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoricalDataContainer {
|
||||||
|
isAllTimeHigh: boolean;
|
||||||
|
isAllTimeLow: boolean;
|
||||||
|
items: HistoricalDataItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HistoricalDataItem {
|
export interface HistoricalDataItem {
|
||||||
|
@ -0,0 +1,5 @@
|
|||||||
|
import { Position } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
|
export interface PortfolioPositions {
|
||||||
|
positions: Position[];
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
import { TimelinePeriod } from '@ghostfolio/api/app/portfolio/interfaces/timeline-period.interface';
|
||||||
|
import Big from 'big.js';
|
||||||
|
|
||||||
|
export interface TimelineInfoInterface {
|
||||||
|
maxNetPerformance: Big;
|
||||||
|
minNetPerformance: Big;
|
||||||
|
timelinePeriods: TimelinePeriod[];
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
import Big from 'big.js';
|
||||||
|
|
||||||
|
export interface TimelinePeriod {
|
||||||
|
date: string;
|
||||||
|
grossPerformance: Big;
|
||||||
|
investment: Big;
|
||||||
|
netPerformance: Big;
|
||||||
|
value: Big;
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
export type Accuracy = 'day' | 'month' | 'year';
|
||||||
|
|
||||||
|
export interface TimelineSpecification {
|
||||||
|
accuracy: Accuracy;
|
||||||
|
start: string;
|
||||||
|
}
|
@ -0,0 +1,13 @@
|
|||||||
|
import { DataSource } from '@prisma/client';
|
||||||
|
import Big from 'big.js';
|
||||||
|
|
||||||
|
export interface TransactionPointSymbol {
|
||||||
|
currency: string;
|
||||||
|
dataSource: DataSource;
|
||||||
|
fee: Big;
|
||||||
|
firstBuyDate: string;
|
||||||
|
investment: Big;
|
||||||
|
quantity: Big;
|
||||||
|
symbol: string;
|
||||||
|
transactionCount: number;
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
import { TransactionPointSymbol } from './transaction-point-symbol.interface';
|
||||||
|
|
||||||
|
export interface TransactionPoint {
|
||||||
|
date: string;
|
||||||
|
items: TransactionPointSymbol[];
|
||||||
|
}
|
2734
apps/api/src/app/portfolio/portfolio-calculator.spec.ts
Normal file
2734
apps/api/src/app/portfolio/portfolio-calculator.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
702
apps/api/src/app/portfolio/portfolio-calculator.ts
Normal file
702
apps/api/src/app/portfolio/portfolio-calculator.ts
Normal file
@ -0,0 +1,702 @@
|
|||||||
|
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
|
||||||
|
import { OrderType } from '@ghostfolio/api/models/order-type';
|
||||||
|
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
|
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||||
|
import { TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import Big from 'big.js';
|
||||||
|
import {
|
||||||
|
addDays,
|
||||||
|
addMonths,
|
||||||
|
addYears,
|
||||||
|
differenceInDays,
|
||||||
|
endOfDay,
|
||||||
|
format,
|
||||||
|
isAfter,
|
||||||
|
isBefore,
|
||||||
|
max,
|
||||||
|
min
|
||||||
|
} from 'date-fns';
|
||||||
|
import { flatten, isNumber } from 'lodash';
|
||||||
|
|
||||||
|
import { CurrentRateService } from './current-rate.service';
|
||||||
|
import { CurrentPositions } from './interfaces/current-positions.interface';
|
||||||
|
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||||
|
import { PortfolioOrder } from './interfaces/portfolio-order.interface';
|
||||||
|
import { TimelinePeriod } from './interfaces/timeline-period.interface';
|
||||||
|
import {
|
||||||
|
Accuracy,
|
||||||
|
TimelineSpecification
|
||||||
|
} from './interfaces/timeline-specification.interface';
|
||||||
|
import { TransactionPointSymbol } from './interfaces/transaction-point-symbol.interface';
|
||||||
|
import { TransactionPoint } from './interfaces/transaction-point.interface';
|
||||||
|
|
||||||
|
export class PortfolioCalculator {
|
||||||
|
private transactionPoints: TransactionPoint[];
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private currentRateService: CurrentRateService,
|
||||||
|
private currency: string
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public computeTransactionPoints(orders: PortfolioOrder[]) {
|
||||||
|
orders.sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
|
||||||
|
this.transactionPoints = [];
|
||||||
|
const symbols: { [symbol: string]: TransactionPointSymbol } = {};
|
||||||
|
|
||||||
|
let lastDate: string = null;
|
||||||
|
let lastTransactionPoint: TransactionPoint = null;
|
||||||
|
for (const order of orders) {
|
||||||
|
const currentDate = order.date;
|
||||||
|
|
||||||
|
let currentTransactionPointItem: TransactionPointSymbol;
|
||||||
|
const oldAccumulatedSymbol = symbols[order.symbol];
|
||||||
|
|
||||||
|
const factor = this.getFactor(order.type);
|
||||||
|
const unitPrice = new Big(order.unitPrice);
|
||||||
|
if (oldAccumulatedSymbol) {
|
||||||
|
const newQuantity = order.quantity
|
||||||
|
.mul(factor)
|
||||||
|
.plus(oldAccumulatedSymbol.quantity);
|
||||||
|
currentTransactionPointItem = {
|
||||||
|
currency: order.currency,
|
||||||
|
dataSource: order.dataSource,
|
||||||
|
fee: order.fee.plus(oldAccumulatedSymbol.fee),
|
||||||
|
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
|
||||||
|
investment: newQuantity.eq(0)
|
||||||
|
? new Big(0)
|
||||||
|
: unitPrice
|
||||||
|
.mul(order.quantity)
|
||||||
|
.mul(factor)
|
||||||
|
.add(oldAccumulatedSymbol.investment),
|
||||||
|
quantity: newQuantity,
|
||||||
|
symbol: order.symbol,
|
||||||
|
transactionCount: oldAccumulatedSymbol.transactionCount + 1
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
currentTransactionPointItem = {
|
||||||
|
currency: order.currency,
|
||||||
|
dataSource: order.dataSource,
|
||||||
|
fee: order.fee,
|
||||||
|
firstBuyDate: order.date,
|
||||||
|
investment: unitPrice.mul(order.quantity).mul(factor),
|
||||||
|
quantity: order.quantity.mul(factor),
|
||||||
|
symbol: order.symbol,
|
||||||
|
transactionCount: 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
symbols[order.symbol] = currentTransactionPointItem;
|
||||||
|
|
||||||
|
const items = lastTransactionPoint?.items ?? [];
|
||||||
|
const newItems = items.filter(
|
||||||
|
(transactionPointItem) => transactionPointItem.symbol !== order.symbol
|
||||||
|
);
|
||||||
|
newItems.push(currentTransactionPointItem);
|
||||||
|
newItems.sort((a, b) => a.symbol.localeCompare(b.symbol));
|
||||||
|
if (lastDate !== currentDate || lastTransactionPoint === null) {
|
||||||
|
lastTransactionPoint = {
|
||||||
|
date: currentDate,
|
||||||
|
items: newItems
|
||||||
|
};
|
||||||
|
this.transactionPoints.push(lastTransactionPoint);
|
||||||
|
} else {
|
||||||
|
lastTransactionPoint.items = newItems;
|
||||||
|
}
|
||||||
|
lastDate = currentDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getAnnualizedPerformancePercent({
|
||||||
|
daysInMarket,
|
||||||
|
netPerformancePercent
|
||||||
|
}: {
|
||||||
|
daysInMarket: number;
|
||||||
|
netPerformancePercent: Big;
|
||||||
|
}): Big {
|
||||||
|
if (isNumber(daysInMarket) && daysInMarket > 0) {
|
||||||
|
const exponent = new Big(365).div(daysInMarket).toNumber();
|
||||||
|
return new Big(
|
||||||
|
Math.pow(netPerformancePercent.plus(1).toNumber(), exponent)
|
||||||
|
).minus(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Big(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getTransactionPoints(): TransactionPoint[] {
|
||||||
|
return this.transactionPoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setTransactionPoints(transactionPoints: TransactionPoint[]) {
|
||||||
|
this.transactionPoints = transactionPoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getCurrentPositions(start: Date): Promise<CurrentPositions> {
|
||||||
|
if (!this.transactionPoints?.length) {
|
||||||
|
return {
|
||||||
|
currentValue: new Big(0),
|
||||||
|
hasErrors: false,
|
||||||
|
grossPerformance: new Big(0),
|
||||||
|
grossPerformancePercentage: new Big(0),
|
||||||
|
netAnnualizedPerformance: new Big(0),
|
||||||
|
netPerformance: new Big(0),
|
||||||
|
netPerformancePercentage: new Big(0),
|
||||||
|
positions: [],
|
||||||
|
totalInvestment: new Big(0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastTransactionPoint =
|
||||||
|
this.transactionPoints[this.transactionPoints.length - 1];
|
||||||
|
|
||||||
|
// use Date.now() to use the mock for today
|
||||||
|
const today = new Date(Date.now());
|
||||||
|
|
||||||
|
let firstTransactionPoint: TransactionPoint = null;
|
||||||
|
let firstIndex = this.transactionPoints.length;
|
||||||
|
const dates = [];
|
||||||
|
const dataGatheringItems: IDataGatheringItem[] = [];
|
||||||
|
const currencies: { [symbol: string]: string } = {};
|
||||||
|
|
||||||
|
dates.push(resetHours(start));
|
||||||
|
for (const item of this.transactionPoints[firstIndex - 1].items) {
|
||||||
|
dataGatheringItems.push({
|
||||||
|
dataSource: item.dataSource,
|
||||||
|
symbol: item.symbol
|
||||||
|
});
|
||||||
|
currencies[item.symbol] = item.currency;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < this.transactionPoints.length; i++) {
|
||||||
|
if (
|
||||||
|
!isBefore(parseDate(this.transactionPoints[i].date), start) &&
|
||||||
|
firstTransactionPoint === null
|
||||||
|
) {
|
||||||
|
firstTransactionPoint = this.transactionPoints[i];
|
||||||
|
firstIndex = i;
|
||||||
|
}
|
||||||
|
if (firstTransactionPoint !== null) {
|
||||||
|
dates.push(resetHours(parseDate(this.transactionPoints[i].date)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dates.push(resetHours(today));
|
||||||
|
|
||||||
|
const marketSymbols = await this.currentRateService.getValues({
|
||||||
|
currencies,
|
||||||
|
dataGatheringItems,
|
||||||
|
dateQuery: {
|
||||||
|
in: dates
|
||||||
|
},
|
||||||
|
userCurrency: this.currency
|
||||||
|
});
|
||||||
|
|
||||||
|
const marketSymbolMap: {
|
||||||
|
[date: string]: { [symbol: string]: Big };
|
||||||
|
} = {};
|
||||||
|
for (const marketSymbol of marketSymbols) {
|
||||||
|
const date = format(marketSymbol.date, DATE_FORMAT);
|
||||||
|
if (!marketSymbolMap[date]) {
|
||||||
|
marketSymbolMap[date] = {};
|
||||||
|
}
|
||||||
|
if (marketSymbol.marketPrice) {
|
||||||
|
marketSymbolMap[date][marketSymbol.symbol] = new Big(
|
||||||
|
marketSymbol.marketPrice
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasErrors = false;
|
||||||
|
const startString = format(start, DATE_FORMAT);
|
||||||
|
|
||||||
|
const holdingPeriodReturns: { [symbol: string]: Big } = {};
|
||||||
|
const netHoldingPeriodReturns: { [symbol: string]: Big } = {};
|
||||||
|
const grossPerformance: { [symbol: string]: Big } = {};
|
||||||
|
const netPerformance: { [symbol: string]: Big } = {};
|
||||||
|
const todayString = format(today, DATE_FORMAT);
|
||||||
|
|
||||||
|
if (firstIndex > 0) {
|
||||||
|
firstIndex--;
|
||||||
|
}
|
||||||
|
const invalidSymbols = [];
|
||||||
|
const lastInvestments: { [symbol: string]: Big } = {};
|
||||||
|
const lastQuantities: { [symbol: string]: Big } = {};
|
||||||
|
const lastFees: { [symbol: string]: Big } = {};
|
||||||
|
const initialValues: { [symbol: string]: Big } = {};
|
||||||
|
|
||||||
|
for (let i = firstIndex; i < this.transactionPoints.length; i++) {
|
||||||
|
const currentDate =
|
||||||
|
i === firstIndex ? startString : this.transactionPoints[i].date;
|
||||||
|
const nextDate =
|
||||||
|
i + 1 < this.transactionPoints.length
|
||||||
|
? this.transactionPoints[i + 1].date
|
||||||
|
: todayString;
|
||||||
|
|
||||||
|
const items = this.transactionPoints[i].items;
|
||||||
|
for (const item of items) {
|
||||||
|
if (!marketSymbolMap[nextDate]?.[item.symbol]) {
|
||||||
|
invalidSymbols.push(item.symbol);
|
||||||
|
hasErrors = true;
|
||||||
|
Logger.error(
|
||||||
|
`Missing value for symbol ${item.symbol} at ${nextDate}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let lastInvestment: Big = new Big(0);
|
||||||
|
let lastQuantity: Big = item.quantity;
|
||||||
|
if (lastInvestments[item.symbol] && lastQuantities[item.symbol]) {
|
||||||
|
lastInvestment = item.investment.minus(lastInvestments[item.symbol]);
|
||||||
|
lastQuantity = lastQuantities[item.symbol];
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemValue = marketSymbolMap[currentDate]?.[item.symbol];
|
||||||
|
let initialValue = itemValue?.mul(lastQuantity);
|
||||||
|
let investedValue = itemValue?.mul(item.quantity);
|
||||||
|
const isFirstOrderAndIsStartBeforeCurrentDate =
|
||||||
|
i === firstIndex &&
|
||||||
|
isBefore(parseDate(this.transactionPoints[i].date), start);
|
||||||
|
const lastFee: Big = lastFees[item.symbol] ?? new Big(0);
|
||||||
|
const fee = isFirstOrderAndIsStartBeforeCurrentDate
|
||||||
|
? new Big(0)
|
||||||
|
: item.fee.minus(lastFee);
|
||||||
|
if (!isAfter(parseDate(currentDate), parseDate(item.firstBuyDate))) {
|
||||||
|
initialValue = item.investment;
|
||||||
|
investedValue = item.investment;
|
||||||
|
}
|
||||||
|
if (i === firstIndex || !initialValues[item.symbol]) {
|
||||||
|
initialValues[item.symbol] = initialValue;
|
||||||
|
}
|
||||||
|
if (!item.quantity.eq(0)) {
|
||||||
|
if (!initialValue) {
|
||||||
|
invalidSymbols.push(item.symbol);
|
||||||
|
hasErrors = true;
|
||||||
|
Logger.error(
|
||||||
|
`Missing value for symbol ${item.symbol} at ${currentDate}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cashFlow = lastInvestment;
|
||||||
|
const endValue = marketSymbolMap[nextDate][item.symbol].mul(
|
||||||
|
item.quantity
|
||||||
|
);
|
||||||
|
|
||||||
|
const holdingPeriodReturn = endValue.div(initialValue.plus(cashFlow));
|
||||||
|
holdingPeriodReturns[item.symbol] = (
|
||||||
|
holdingPeriodReturns[item.symbol] ?? new Big(1)
|
||||||
|
).mul(holdingPeriodReturn);
|
||||||
|
grossPerformance[item.symbol] = (
|
||||||
|
grossPerformance[item.symbol] ?? new Big(0)
|
||||||
|
).plus(endValue.minus(investedValue));
|
||||||
|
|
||||||
|
const netHoldingPeriodReturn = endValue.div(
|
||||||
|
initialValue.plus(cashFlow).plus(fee)
|
||||||
|
);
|
||||||
|
netHoldingPeriodReturns[item.symbol] = (
|
||||||
|
netHoldingPeriodReturns[item.symbol] ?? new Big(1)
|
||||||
|
).mul(netHoldingPeriodReturn);
|
||||||
|
netPerformance[item.symbol] = (
|
||||||
|
netPerformance[item.symbol] ?? new Big(0)
|
||||||
|
).plus(endValue.minus(investedValue).minus(fee));
|
||||||
|
}
|
||||||
|
lastInvestments[item.symbol] = item.investment;
|
||||||
|
lastQuantities[item.symbol] = item.quantity;
|
||||||
|
lastFees[item.symbol] = item.fee;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const positions: TimelinePosition[] = [];
|
||||||
|
|
||||||
|
for (const item of lastTransactionPoint.items) {
|
||||||
|
const marketValue = marketSymbolMap[todayString]?.[item.symbol];
|
||||||
|
const isValid = invalidSymbols.indexOf(item.symbol) === -1;
|
||||||
|
positions.push({
|
||||||
|
averagePrice: item.quantity.eq(0)
|
||||||
|
? new Big(0)
|
||||||
|
: item.investment.div(item.quantity),
|
||||||
|
currency: item.currency,
|
||||||
|
dataSource: item.dataSource,
|
||||||
|
firstBuyDate: item.firstBuyDate,
|
||||||
|
grossPerformance: isValid
|
||||||
|
? grossPerformance[item.symbol] ?? null
|
||||||
|
: null,
|
||||||
|
grossPerformancePercentage:
|
||||||
|
isValid && holdingPeriodReturns[item.symbol]
|
||||||
|
? holdingPeriodReturns[item.symbol].minus(1)
|
||||||
|
: null,
|
||||||
|
investment: item.investment,
|
||||||
|
marketPrice: marketValue?.toNumber() ?? null,
|
||||||
|
netPerformance: isValid ? netPerformance[item.symbol] ?? null : null,
|
||||||
|
netPerformancePercentage:
|
||||||
|
isValid && netHoldingPeriodReturns[item.symbol]
|
||||||
|
? netHoldingPeriodReturns[item.symbol].minus(1)
|
||||||
|
: null,
|
||||||
|
quantity: item.quantity,
|
||||||
|
symbol: item.symbol,
|
||||||
|
transactionCount: item.transactionCount
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const overall = this.calculateOverallPerformance(positions, initialValues);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...overall,
|
||||||
|
positions,
|
||||||
|
hasErrors: hasErrors || overall.hasErrors
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public getInvestments(): { date: string; investment: Big }[] {
|
||||||
|
if (this.transactionPoints.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.transactionPoints.map((transactionPoint) => {
|
||||||
|
return {
|
||||||
|
date: transactionPoint.date,
|
||||||
|
investment: transactionPoint.items.reduce(
|
||||||
|
(investment, transactionPointSymbol) =>
|
||||||
|
investment.add(transactionPointSymbol.investment),
|
||||||
|
new Big(0)
|
||||||
|
)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async calculateTimeline(
|
||||||
|
timelineSpecification: TimelineSpecification[],
|
||||||
|
endDate: string
|
||||||
|
): Promise<TimelineInfoInterface> {
|
||||||
|
if (timelineSpecification.length === 0) {
|
||||||
|
return {
|
||||||
|
maxNetPerformance: new Big(0),
|
||||||
|
minNetPerformance: new Big(0),
|
||||||
|
timelinePeriods: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = timelineSpecification[0].start;
|
||||||
|
const start = parseDate(startDate);
|
||||||
|
const end = parseDate(endDate);
|
||||||
|
|
||||||
|
const timelinePeriodPromises: Promise<TimelineInfoInterface>[] = [];
|
||||||
|
let i = 0;
|
||||||
|
let j = -1;
|
||||||
|
for (
|
||||||
|
let currentDate = start;
|
||||||
|
!isAfter(currentDate, end);
|
||||||
|
currentDate = this.addToDate(
|
||||||
|
currentDate,
|
||||||
|
timelineSpecification[i].accuracy
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if (this.isNextItemActive(timelineSpecification, currentDate, i)) {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
while (
|
||||||
|
j + 1 < this.transactionPoints.length &&
|
||||||
|
!isAfter(parseDate(this.transactionPoints[j + 1].date), currentDate)
|
||||||
|
) {
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
|
||||||
|
let periodEndDate = currentDate;
|
||||||
|
if (timelineSpecification[i].accuracy === 'day') {
|
||||||
|
let nextEndDate = end;
|
||||||
|
if (j + 1 < this.transactionPoints.length) {
|
||||||
|
nextEndDate = parseDate(this.transactionPoints[j + 1].date);
|
||||||
|
}
|
||||||
|
periodEndDate = min([
|
||||||
|
addMonths(currentDate, 3),
|
||||||
|
max([currentDate, nextEndDate])
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
const timePeriodForDates = this.getTimePeriodForDate(
|
||||||
|
j,
|
||||||
|
currentDate,
|
||||||
|
endOfDay(periodEndDate)
|
||||||
|
);
|
||||||
|
currentDate = periodEndDate;
|
||||||
|
if (timePeriodForDates != null) {
|
||||||
|
timelinePeriodPromises.push(timePeriodForDates);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const timelineInfoInterfaces: TimelineInfoInterface[] = await Promise.all(
|
||||||
|
timelinePeriodPromises
|
||||||
|
);
|
||||||
|
const minNetPerformance = timelineInfoInterfaces
|
||||||
|
.map((timelineInfo) => timelineInfo.minNetPerformance)
|
||||||
|
.filter((performance) => performance !== null)
|
||||||
|
.reduce((minPerformance, current) => {
|
||||||
|
if (minPerformance.lt(current)) {
|
||||||
|
return minPerformance;
|
||||||
|
} else {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxNetPerformance = timelineInfoInterfaces
|
||||||
|
.map((timelineInfo) => timelineInfo.maxNetPerformance)
|
||||||
|
.filter((performance) => performance !== null)
|
||||||
|
.reduce((maxPerformance, current) => {
|
||||||
|
if (maxPerformance.gt(current)) {
|
||||||
|
return maxPerformance;
|
||||||
|
} else {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const timelinePeriods = timelineInfoInterfaces.map(
|
||||||
|
(timelineInfo) => timelineInfo.timelinePeriods
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
maxNetPerformance,
|
||||||
|
minNetPerformance,
|
||||||
|
timelinePeriods: flatten(timelinePeriods)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateOverallPerformance(
|
||||||
|
positions: TimelinePosition[],
|
||||||
|
initialValues: { [p: string]: Big }
|
||||||
|
) {
|
||||||
|
let hasErrors = false;
|
||||||
|
let currentValue = new Big(0);
|
||||||
|
let totalInvestment = new Big(0);
|
||||||
|
let grossPerformance = new Big(0);
|
||||||
|
let grossPerformancePercentage = new Big(0);
|
||||||
|
let netPerformance = new Big(0);
|
||||||
|
let netPerformancePercentage = new Big(0);
|
||||||
|
let completeInitialValue = new Big(0);
|
||||||
|
let netAnnualizedPerformance = new Big(0);
|
||||||
|
|
||||||
|
// use Date.now() to use the mock for today
|
||||||
|
const today = new Date(Date.now());
|
||||||
|
|
||||||
|
for (const currentPosition of positions) {
|
||||||
|
if (currentPosition.marketPrice) {
|
||||||
|
currentValue = currentValue.add(
|
||||||
|
new Big(currentPosition.marketPrice).mul(currentPosition.quantity)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
totalInvestment = totalInvestment.add(currentPosition.investment);
|
||||||
|
if (currentPosition.grossPerformance) {
|
||||||
|
grossPerformance = grossPerformance.plus(
|
||||||
|
currentPosition.grossPerformance
|
||||||
|
);
|
||||||
|
netPerformance = netPerformance.plus(currentPosition.netPerformance);
|
||||||
|
} else if (!currentPosition.quantity.eq(0)) {
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
currentPosition.grossPerformancePercentage &&
|
||||||
|
initialValues[currentPosition.symbol]
|
||||||
|
) {
|
||||||
|
const currentInitialValue = initialValues[currentPosition.symbol];
|
||||||
|
completeInitialValue = completeInitialValue.plus(currentInitialValue);
|
||||||
|
grossPerformancePercentage = grossPerformancePercentage.plus(
|
||||||
|
currentPosition.grossPerformancePercentage.mul(currentInitialValue)
|
||||||
|
);
|
||||||
|
netAnnualizedPerformance = netAnnualizedPerformance.plus(
|
||||||
|
this.getAnnualizedPerformancePercent({
|
||||||
|
daysInMarket: differenceInDays(
|
||||||
|
today,
|
||||||
|
parseDate(currentPosition.firstBuyDate)
|
||||||
|
),
|
||||||
|
netPerformancePercent: currentPosition.netPerformancePercentage
|
||||||
|
}).mul(currentInitialValue)
|
||||||
|
);
|
||||||
|
netPerformancePercentage = netPerformancePercentage.plus(
|
||||||
|
currentPosition.netPerformancePercentage.mul(currentInitialValue)
|
||||||
|
);
|
||||||
|
} else if (!currentPosition.quantity.eq(0)) {
|
||||||
|
Logger.error(
|
||||||
|
`Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`
|
||||||
|
);
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!completeInitialValue.eq(0)) {
|
||||||
|
grossPerformancePercentage =
|
||||||
|
grossPerformancePercentage.div(completeInitialValue);
|
||||||
|
netPerformancePercentage =
|
||||||
|
netPerformancePercentage.div(completeInitialValue);
|
||||||
|
netAnnualizedPerformance =
|
||||||
|
netAnnualizedPerformance.div(completeInitialValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentValue,
|
||||||
|
grossPerformance,
|
||||||
|
grossPerformancePercentage,
|
||||||
|
hasErrors,
|
||||||
|
netAnnualizedPerformance,
|
||||||
|
netPerformance,
|
||||||
|
netPerformancePercentage,
|
||||||
|
totalInvestment
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getTimePeriodForDate(
|
||||||
|
j: number,
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date
|
||||||
|
): Promise<TimelineInfoInterface> {
|
||||||
|
let investment: Big = new Big(0);
|
||||||
|
let fees: Big = new Big(0);
|
||||||
|
|
||||||
|
const marketSymbolMap: {
|
||||||
|
[date: string]: { [symbol: string]: Big };
|
||||||
|
} = {};
|
||||||
|
if (j >= 0) {
|
||||||
|
const currencies: { [name: string]: string } = {};
|
||||||
|
const dataGatheringItems: IDataGatheringItem[] = [];
|
||||||
|
|
||||||
|
for (const item of this.transactionPoints[j].items) {
|
||||||
|
currencies[item.symbol] = item.currency;
|
||||||
|
dataGatheringItems.push({
|
||||||
|
dataSource: item.dataSource,
|
||||||
|
symbol: item.symbol
|
||||||
|
});
|
||||||
|
investment = investment.add(item.investment);
|
||||||
|
fees = fees.add(item.fee);
|
||||||
|
}
|
||||||
|
|
||||||
|
let marketSymbols: GetValueObject[] = [];
|
||||||
|
if (dataGatheringItems.length > 0) {
|
||||||
|
try {
|
||||||
|
marketSymbols = await this.currentRateService.getValues({
|
||||||
|
currencies,
|
||||||
|
dataGatheringItems,
|
||||||
|
dateQuery: {
|
||||||
|
gte: startDate,
|
||||||
|
lt: endOfDay(endDate)
|
||||||
|
},
|
||||||
|
userCurrency: this.currency
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(
|
||||||
|
`Failed to fetch info for date ${startDate} with exception`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const marketSymbol of marketSymbols) {
|
||||||
|
const date = format(marketSymbol.date, DATE_FORMAT);
|
||||||
|
if (!marketSymbolMap[date]) {
|
||||||
|
marketSymbolMap[date] = {};
|
||||||
|
}
|
||||||
|
if (marketSymbol.marketPrice) {
|
||||||
|
marketSymbolMap[date][marketSymbol.symbol] = new Big(
|
||||||
|
marketSymbol.marketPrice
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: TimelinePeriod[] = [];
|
||||||
|
let maxNetPerformance: Big = null;
|
||||||
|
let minNetPerformance: Big = null;
|
||||||
|
for (
|
||||||
|
let currentDate = startDate;
|
||||||
|
isBefore(currentDate, endDate);
|
||||||
|
currentDate = addDays(currentDate, 1)
|
||||||
|
) {
|
||||||
|
let value = new Big(0);
|
||||||
|
const currentDateAsString = format(currentDate, DATE_FORMAT);
|
||||||
|
let invalid = false;
|
||||||
|
if (j >= 0) {
|
||||||
|
for (const item of this.transactionPoints[j].items) {
|
||||||
|
if (
|
||||||
|
!marketSymbolMap[currentDateAsString]?.hasOwnProperty(item.symbol)
|
||||||
|
) {
|
||||||
|
invalid = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
value = value.add(
|
||||||
|
item.quantity.mul(marketSymbolMap[currentDateAsString][item.symbol])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!invalid) {
|
||||||
|
const grossPerformance = value.minus(investment);
|
||||||
|
const netPerformance = grossPerformance.minus(fees);
|
||||||
|
if (
|
||||||
|
minNetPerformance === null ||
|
||||||
|
minNetPerformance.gt(netPerformance)
|
||||||
|
) {
|
||||||
|
minNetPerformance = netPerformance;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
maxNetPerformance === null ||
|
||||||
|
maxNetPerformance.lt(netPerformance)
|
||||||
|
) {
|
||||||
|
maxNetPerformance = netPerformance;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
grossPerformance,
|
||||||
|
investment,
|
||||||
|
netPerformance,
|
||||||
|
value,
|
||||||
|
date: currentDateAsString
|
||||||
|
};
|
||||||
|
results.push(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
maxNetPerformance,
|
||||||
|
minNetPerformance,
|
||||||
|
timelinePeriods: results
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFactor(type: OrderType) {
|
||||||
|
let factor: number;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case OrderType.Buy:
|
||||||
|
factor = 1;
|
||||||
|
break;
|
||||||
|
case OrderType.Sell:
|
||||||
|
factor = -1;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
factor = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return factor;
|
||||||
|
}
|
||||||
|
|
||||||
|
private addToDate(date: Date, accuracy: Accuracy): Date {
|
||||||
|
switch (accuracy) {
|
||||||
|
case 'day':
|
||||||
|
return addDays(date, 1);
|
||||||
|
case 'month':
|
||||||
|
return addMonths(date, 1);
|
||||||
|
case 'year':
|
||||||
|
return addYears(date, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isNextItemActive(
|
||||||
|
timelineSpecification: TimelineSpecification[],
|
||||||
|
currentDate: Date,
|
||||||
|
i: number
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
i + 1 < timelineSpecification.length &&
|
||||||
|
!isBefore(currentDate, parseDate(timelineSpecification[i + 1].start))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -1,22 +1,22 @@
|
|||||||
|
import { AccessService } from '@ghostfolio/api/app/access/access.service';
|
||||||
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
import {
|
import {
|
||||||
hasNotDefinedValuesInObject,
|
hasNotDefinedValuesInObject,
|
||||||
nullifyValuesInObject
|
nullifyValuesInObject
|
||||||
} from '@ghostfolio/api/helper/object.helper';
|
} from '@ghostfolio/api/helper/object.helper';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
import { baseCurrency } from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
PortfolioItem,
|
PortfolioChart,
|
||||||
PortfolioOverview,
|
PortfolioDetails,
|
||||||
PortfolioPerformance,
|
PortfolioPerformance,
|
||||||
PortfolioPosition,
|
PortfolioPublicDetails,
|
||||||
PortfolioReport
|
PortfolioReport,
|
||||||
|
PortfolioSummary
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import {
|
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||||
getPermissions,
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
hasPermission,
|
|
||||||
permissions
|
|
||||||
} from '@ghostfolio/common/permissions';
|
|
||||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
@ -33,54 +33,55 @@ import { AuthGuard } from '@nestjs/passport';
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import {
|
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
|
||||||
HistoricalDataItem,
|
import { PortfolioPositions } from './interfaces/portfolio-positions.interface';
|
||||||
PortfolioPositionDetail
|
|
||||||
} from './interfaces/portfolio-position-detail.interface';
|
|
||||||
import { PortfolioService } from './portfolio.service';
|
import { PortfolioService } from './portfolio.service';
|
||||||
|
|
||||||
@Controller('portfolio')
|
@Controller('portfolio')
|
||||||
export class PortfolioController {
|
export class PortfolioController {
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly accessService: AccessService,
|
||||||
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly impersonationService: ImpersonationService,
|
private readonly portfolioService: PortfolioService,
|
||||||
private portfolioService: PortfolioService,
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
private readonly userService: UserService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get('investments')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async findAll(
|
public async findAll(
|
||||||
@Headers('impersonation-id') impersonationId
|
@Headers('impersonation-id') impersonationId,
|
||||||
): Promise<PortfolioItem[]> {
|
@Res() res: Response
|
||||||
let portfolio = await this.portfolioService.findAll(impersonationId);
|
): Promise<InvestmentItem[]> {
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationId &&
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
!hasPermission(
|
this.request.user.subscription.type === 'Basic'
|
||||||
getPermissions(this.request.user.role),
|
|
||||||
permissions.readForeignPortfolio
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
portfolio = portfolio.map((portfolioItem) => {
|
res.status(StatusCodes.FORBIDDEN);
|
||||||
Object.keys(portfolioItem.positions).forEach((symbol) => {
|
return <any>res.json([]);
|
||||||
portfolioItem.positions[symbol].investment =
|
|
||||||
portfolioItem.positions[symbol].investment > 0 ? 1 : 0;
|
|
||||||
portfolioItem.positions[symbol].investmentInOriginalCurrency =
|
|
||||||
portfolioItem.positions[symbol].investmentInOriginalCurrency > 0
|
|
||||||
? 1
|
|
||||||
: 0;
|
|
||||||
portfolioItem.positions[symbol].quantity =
|
|
||||||
portfolioItem.positions[symbol].quantity > 0 ? 1 : 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
portfolioItem.investment = null;
|
|
||||||
|
|
||||||
return portfolioItem;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return portfolio;
|
let investments = await this.portfolioService.getInvestments(
|
||||||
|
impersonationId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
impersonationId ||
|
||||||
|
this.userService.isRestrictedView(this.request.user)
|
||||||
|
) {
|
||||||
|
const maxInvestment = investments.reduce(
|
||||||
|
(investment, item) => Math.max(investment, item.investment),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
investments = investments.map((item) => ({
|
||||||
|
date: item.date,
|
||||||
|
investment: item.investment / maxInvestment
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return <any>res.json(investments);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('chart')
|
@Get('chart')
|
||||||
@ -89,12 +90,14 @@ export class PortfolioController {
|
|||||||
@Headers('impersonation-id') impersonationId,
|
@Headers('impersonation-id') impersonationId,
|
||||||
@Query('range') range,
|
@Query('range') range,
|
||||||
@Res() res: Response
|
@Res() res: Response
|
||||||
): Promise<HistoricalDataItem[]> {
|
): Promise<PortfolioChart> {
|
||||||
let chartData = await this.portfolioService.getChart(
|
const historicalDataContainer = await this.portfolioService.getChart(
|
||||||
impersonationId,
|
impersonationId,
|
||||||
range
|
range
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let chartData = historicalDataContainer.items;
|
||||||
|
|
||||||
let hasNullValue = false;
|
let hasNullValue = false;
|
||||||
|
|
||||||
chartData.forEach((chartDataItem) => {
|
chartData.forEach((chartDataItem) => {
|
||||||
@ -108,11 +111,8 @@ export class PortfolioController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationId &&
|
impersonationId ||
|
||||||
!hasPermission(
|
this.userService.isRestrictedView(this.request.user)
|
||||||
getPermissions(this.request.user.role),
|
|
||||||
permissions.readForeignPortfolio
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
let maxValue = 0;
|
let maxValue = 0;
|
||||||
|
|
||||||
@ -130,7 +130,11 @@ export class PortfolioController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return <any>res.json(chartData);
|
return <any>res.json({
|
||||||
|
chart: chartData,
|
||||||
|
isAllTimeHigh: historicalDataContainer.isAllTimeHigh,
|
||||||
|
isAllTimeLow: historicalDataContainer.isAllTimeLow
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('details')
|
@Get('details')
|
||||||
@ -139,44 +143,37 @@ export class PortfolioController {
|
|||||||
@Headers('impersonation-id') impersonationId,
|
@Headers('impersonation-id') impersonationId,
|
||||||
@Query('range') range,
|
@Query('range') range,
|
||||||
@Res() res: Response
|
@Res() res: Response
|
||||||
): Promise<{ [symbol: string]: PortfolioPosition }> {
|
): Promise<PortfolioDetails> {
|
||||||
let details: { [symbol: string]: PortfolioPosition } = {};
|
if (
|
||||||
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
const impersonationUserId = await this.impersonationService.validateImpersonationId(
|
this.request.user.subscription.type === 'Basic'
|
||||||
impersonationId,
|
) {
|
||||||
this.request.user.id
|
res.status(StatusCodes.FORBIDDEN);
|
||||||
);
|
return <any>res.json({ accounts: {}, holdings: {} });
|
||||||
|
|
||||||
const portfolio = await this.portfolioService.createPortfolio(
|
|
||||||
impersonationUserId || this.request.user.id
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
details = await portfolio.getDetails(range);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
|
|
||||||
res.status(StatusCodes.ACCEPTED);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasNotDefinedValuesInObject(details)) {
|
const { accounts, holdings, hasErrors } =
|
||||||
|
await this.portfolioService.getDetails(
|
||||||
|
impersonationId,
|
||||||
|
this.request.user.id,
|
||||||
|
range
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
||||||
res.status(StatusCodes.ACCEPTED);
|
res.status(StatusCodes.ACCEPTED);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationId &&
|
impersonationId ||
|
||||||
!hasPermission(
|
this.userService.isRestrictedView(this.request.user)
|
||||||
getPermissions(this.request.user.role),
|
|
||||||
permissions.readForeignPortfolio
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
const totalInvestment = Object.values(details)
|
const totalInvestment = Object.values(holdings)
|
||||||
.map((portfolioPosition) => {
|
.map((portfolioPosition) => {
|
||||||
return portfolioPosition.investment;
|
return portfolioPosition.investment;
|
||||||
})
|
})
|
||||||
.reduce((a, b) => a + b, 0);
|
.reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
const totalValue = Object.values(details)
|
const totalValue = Object.values(holdings)
|
||||||
.map((portfolioPosition) => {
|
.map((portfolioPosition) => {
|
||||||
return this.exchangeRateDataService.toCurrency(
|
return this.exchangeRateDataService.toCurrency(
|
||||||
portfolioPosition.quantity * portfolioPosition.marketPrice,
|
portfolioPosition.quantity * portfolioPosition.marketPrice,
|
||||||
@ -186,49 +183,22 @@ export class PortfolioController {
|
|||||||
})
|
})
|
||||||
.reduce((a, b) => a + b, 0);
|
.reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
for (const [symbol, portfolioPosition] of Object.entries(details)) {
|
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||||
portfolioPosition.grossPerformance = null;
|
portfolioPosition.grossPerformance = null;
|
||||||
portfolioPosition.investment =
|
portfolioPosition.investment =
|
||||||
portfolioPosition.investment / totalInvestment;
|
portfolioPosition.investment / totalInvestment;
|
||||||
|
portfolioPosition.netPerformance = null;
|
||||||
for (const [account, { current, original }] of Object.entries(
|
|
||||||
portfolioPosition.accounts
|
|
||||||
)) {
|
|
||||||
portfolioPosition.accounts[account].current = current / totalValue;
|
|
||||||
portfolioPosition.accounts[account].original =
|
|
||||||
original / totalInvestment;
|
|
||||||
}
|
|
||||||
|
|
||||||
portfolioPosition.quantity = null;
|
portfolioPosition.quantity = null;
|
||||||
|
portfolioPosition.value = portfolioPosition.value / totalValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, { current, original }] of Object.entries(accounts)) {
|
||||||
|
accounts[name].current = current / totalValue;
|
||||||
|
accounts[name].original = original / totalInvestment;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return <any>res.json(details);
|
return <any>res.json({ accounts, holdings });
|
||||||
}
|
|
||||||
|
|
||||||
@Get('overview')
|
|
||||||
@UseGuards(AuthGuard('jwt'))
|
|
||||||
public async getOverview(
|
|
||||||
@Headers('impersonation-id') impersonationId
|
|
||||||
): Promise<PortfolioOverview> {
|
|
||||||
let overview = await this.portfolioService.getOverview(impersonationId);
|
|
||||||
|
|
||||||
if (
|
|
||||||
impersonationId &&
|
|
||||||
!hasPermission(
|
|
||||||
getPermissions(this.request.user.role),
|
|
||||||
permissions.readForeignPortfolio
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
overview = nullifyValuesInObject(overview, [
|
|
||||||
'committedFunds',
|
|
||||||
'fees',
|
|
||||||
'totalBuy',
|
|
||||||
'totalSell'
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return overview;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('performance')
|
@Get('performance')
|
||||||
@ -238,31 +208,22 @@ export class PortfolioController {
|
|||||||
@Query('range') range,
|
@Query('range') range,
|
||||||
@Res() res: Response
|
@Res() res: Response
|
||||||
): Promise<PortfolioPerformance> {
|
): Promise<PortfolioPerformance> {
|
||||||
const impersonationUserId = await this.impersonationService.validateImpersonationId(
|
const performanceInformation = await this.portfolioService.getPerformance(
|
||||||
impersonationId,
|
impersonationId,
|
||||||
this.request.user.id
|
range
|
||||||
);
|
);
|
||||||
|
|
||||||
const portfolio = await this.portfolioService.createPortfolio(
|
if (performanceInformation?.hasErrors) {
|
||||||
impersonationUserId || this.request.user.id
|
|
||||||
);
|
|
||||||
|
|
||||||
let performance = await portfolio.getPerformance(range);
|
|
||||||
|
|
||||||
if (hasNotDefinedValuesInObject(performance)) {
|
|
||||||
res.status(StatusCodes.ACCEPTED);
|
res.status(StatusCodes.ACCEPTED);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let performance = performanceInformation.performance;
|
||||||
if (
|
if (
|
||||||
impersonationId &&
|
impersonationId ||
|
||||||
!hasPermission(
|
this.userService.isRestrictedView(this.request.user)
|
||||||
getPermissions(this.request.user.role),
|
|
||||||
permissions.readForeignPortfolio
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
performance = nullifyValuesInObject(performance, [
|
performance = nullifyValuesInObject(performance, [
|
||||||
'currentGrossPerformance',
|
'currentGrossPerformance',
|
||||||
'currentNetPerformance',
|
|
||||||
'currentValue'
|
'currentValue'
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@ -270,6 +231,125 @@ export class PortfolioController {
|
|||||||
return <any>res.json(performance);
|
return <any>res.json(performance);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('positions')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async getPositions(
|
||||||
|
@Headers('impersonation-id') impersonationId,
|
||||||
|
@Query('range') range,
|
||||||
|
@Res() res: Response
|
||||||
|
): Promise<PortfolioPositions> {
|
||||||
|
const result = await this.portfolioService.getPositions(
|
||||||
|
impersonationId,
|
||||||
|
range
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result?.hasErrors) {
|
||||||
|
res.status(StatusCodes.ACCEPTED);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
impersonationId ||
|
||||||
|
this.userService.isRestrictedView(this.request.user)
|
||||||
|
) {
|
||||||
|
result.positions = result.positions.map((position) => {
|
||||||
|
return nullifyValuesInObject(position, [
|
||||||
|
'grossPerformance',
|
||||||
|
'investment',
|
||||||
|
'netPerformance',
|
||||||
|
'quantity'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return <any>res.json(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('public/:accessId')
|
||||||
|
public async getPublic(
|
||||||
|
@Param('accessId') accessId,
|
||||||
|
@Res() res: Response
|
||||||
|
): Promise<PortfolioPublicDetails> {
|
||||||
|
const access = await this.accessService.access({ id: accessId });
|
||||||
|
const user = await this.userService.user({
|
||||||
|
id: access.userId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!access) {
|
||||||
|
res.status(StatusCodes.NOT_FOUND);
|
||||||
|
return <any>res.json({ accounts: {}, holdings: {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasDetails = true;
|
||||||
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
|
hasDetails = user.subscription.type === 'Premium';
|
||||||
|
}
|
||||||
|
|
||||||
|
const { holdings } = await this.portfolioService.getDetails(
|
||||||
|
access.userId,
|
||||||
|
access.userId
|
||||||
|
);
|
||||||
|
|
||||||
|
const portfolioPublicDetails: PortfolioPublicDetails = {
|
||||||
|
hasDetails,
|
||||||
|
holdings: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalValue = Object.values(holdings)
|
||||||
|
.filter((holding) => {
|
||||||
|
return holding.assetClass === 'EQUITY';
|
||||||
|
})
|
||||||
|
.map((portfolioPosition) => {
|
||||||
|
return this.exchangeRateDataService.toCurrency(
|
||||||
|
portfolioPosition.quantity * portfolioPosition.marketPrice,
|
||||||
|
portfolioPosition.currency,
|
||||||
|
this.request.user?.Settings?.currency ?? baseCurrency
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||||
|
if (portfolioPosition.assetClass === 'EQUITY') {
|
||||||
|
portfolioPublicDetails.holdings[symbol] = {
|
||||||
|
allocationCurrent: portfolioPosition.allocationCurrent,
|
||||||
|
countries: hasDetails ? portfolioPosition.countries : [],
|
||||||
|
currency: portfolioPosition.currency,
|
||||||
|
name: portfolioPosition.name,
|
||||||
|
sectors: hasDetails ? portfolioPosition.sectors : [],
|
||||||
|
value: portfolioPosition.value / totalValue
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <any>res.json(portfolioPublicDetails);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('summary')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async getSummary(
|
||||||
|
@Headers('impersonation-id') impersonationId
|
||||||
|
): Promise<PortfolioSummary> {
|
||||||
|
let summary = await this.portfolioService.getSummary(impersonationId);
|
||||||
|
|
||||||
|
if (
|
||||||
|
impersonationId ||
|
||||||
|
this.userService.isRestrictedView(this.request.user)
|
||||||
|
) {
|
||||||
|
summary = nullifyValuesInObject(summary, [
|
||||||
|
'cash',
|
||||||
|
'committedFunds',
|
||||||
|
'currentGrossPerformance',
|
||||||
|
'currentNetPerformance',
|
||||||
|
'currentValue',
|
||||||
|
'fees',
|
||||||
|
'netWorth',
|
||||||
|
'totalBuy',
|
||||||
|
'totalSell'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
@Get('position/:symbol')
|
@Get('position/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getPosition(
|
public async getPosition(
|
||||||
@ -283,13 +363,16 @@ export class PortfolioController {
|
|||||||
|
|
||||||
if (position) {
|
if (position) {
|
||||||
if (
|
if (
|
||||||
impersonationId &&
|
impersonationId ||
|
||||||
!hasPermission(
|
this.userService.isRestrictedView(this.request.user)
|
||||||
getPermissions(this.request.user.role),
|
|
||||||
permissions.readForeignPortfolio
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
position = nullifyValuesInObject(position, ['grossPerformance']);
|
position = nullifyValuesInObject(position, [
|
||||||
|
'grossPerformance',
|
||||||
|
'investment',
|
||||||
|
'netPerformance',
|
||||||
|
'quantity',
|
||||||
|
'value'
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return position;
|
return position;
|
||||||
@ -304,17 +387,19 @@ export class PortfolioController {
|
|||||||
@Get('report')
|
@Get('report')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getReport(
|
public async getReport(
|
||||||
@Headers('impersonation-id') impersonationId
|
@Headers('impersonation-id') impersonationId,
|
||||||
|
@Res() res: Response
|
||||||
): Promise<PortfolioReport> {
|
): Promise<PortfolioReport> {
|
||||||
const impersonationUserId = await this.impersonationService.validateImpersonationId(
|
if (
|
||||||
impersonationId,
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
this.request.user.id
|
this.request.user.subscription.type === 'Basic'
|
||||||
);
|
) {
|
||||||
|
res.status(StatusCodes.FORBIDDEN);
|
||||||
|
return <any>res.json({ rules: [] });
|
||||||
|
}
|
||||||
|
|
||||||
const portfolio = await this.portfolioService.createPortfolio(
|
return <any>(
|
||||||
impersonationUserId || this.request.user.id
|
res.json(await this.portfolioService.getReport(impersonationId))
|
||||||
);
|
);
|
||||||
|
|
||||||
return await portfolio.getReport();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,42 +1,43 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { AccessModule } from '@ghostfolio/api/app/access/access.module';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||||
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
||||||
import { RulesService } from '@ghostfolio/api/services/rules.service';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { CacheService } from '../cache/cache.service';
|
import { CurrentRateService } from './current-rate.service';
|
||||||
import { OrderService } from '../order/order.service';
|
|
||||||
import { RedisCacheModule } from '../redis-cache/redis-cache.module';
|
|
||||||
import { UserService } from '../user/user.service';
|
|
||||||
import { PortfolioController } from './portfolio.controller';
|
import { PortfolioController } from './portfolio.controller';
|
||||||
import { PortfolioService } from './portfolio.service';
|
import { PortfolioService } from './portfolio.service';
|
||||||
|
import { RulesService } from './rules.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [RedisCacheModule],
|
exports: [PortfolioService],
|
||||||
|
imports: [
|
||||||
|
AccessModule,
|
||||||
|
ConfigurationModule,
|
||||||
|
DataGatheringModule,
|
||||||
|
DataProviderModule,
|
||||||
|
ExchangeRateDataModule,
|
||||||
|
ImpersonationModule,
|
||||||
|
MarketDataModule,
|
||||||
|
OrderModule,
|
||||||
|
PrismaModule,
|
||||||
|
SymbolProfileModule,
|
||||||
|
UserModule
|
||||||
|
],
|
||||||
controllers: [PortfolioController],
|
controllers: [PortfolioController],
|
||||||
providers: [
|
providers: [
|
||||||
AlphaVantageService,
|
AccountService,
|
||||||
CacheService,
|
CurrentRateService,
|
||||||
ConfigurationService,
|
|
||||||
DataGatheringService,
|
|
||||||
DataProviderService,
|
|
||||||
ExchangeRateDataService,
|
|
||||||
GhostfolioScraperApiService,
|
|
||||||
ImpersonationService,
|
|
||||||
OrderService,
|
|
||||||
PortfolioService,
|
PortfolioService,
|
||||||
PrismaService,
|
RulesService
|
||||||
RakutenRapidApiService,
|
|
||||||
RulesService,
|
|
||||||
UserService,
|
|
||||||
YahooFinanceService
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class PortfolioModule {}
|
export class PortfolioModule {}
|
||||||
|
File diff suppressed because it is too large
Load Diff
22
apps/api/src/app/portfolio/rules.service.ts
Normal file
22
apps/api/src/app/portfolio/rules.service.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
|
import { Rule } from '@ghostfolio/api/models/rule';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RulesService {
|
||||||
|
public constructor() {}
|
||||||
|
|
||||||
|
public async evaluate<T extends RuleSettings>(
|
||||||
|
aRules: Rule<T>[],
|
||||||
|
aUserSettings: { baseCurrency: string }
|
||||||
|
) {
|
||||||
|
return aRules
|
||||||
|
.filter((rule) => {
|
||||||
|
return rule.getSettings(aUserSettings)?.isActive;
|
||||||
|
})
|
||||||
|
.map((rule) => {
|
||||||
|
const evaluationResult = rule.evaluate(rule.getSettings(aUserSettings));
|
||||||
|
return { ...evaluationResult, name: rule.getName() };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -21,9 +21,9 @@ export class RedisCacheService {
|
|||||||
await this.cache.reset();
|
await this.cache.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async set(key: string, value: string) {
|
public async set(key: string, value: string, ttlInSeconds?: number) {
|
||||||
await this.cache.set(key, value, {
|
await this.cache.set(key, value, {
|
||||||
ttl: this.configurationService.get('CACHE_TTL')
|
ttl: ttlInSeconds ?? this.configurationService.get('CACHE_TTL')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,15 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
|
import { PROPERTY_COUPONS } from '@ghostfolio/common/config';
|
||||||
|
import { Coupon } from '@ghostfolio/common/interfaces';
|
||||||
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
|
Logger,
|
||||||
Post,
|
Post,
|
||||||
Req,
|
Req,
|
||||||
Res,
|
Res,
|
||||||
@ -13,6 +17,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { Response } from 'express';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { SubscriptionService } from './subscription.service';
|
import { SubscriptionService } from './subscription.service';
|
||||||
@ -21,16 +26,70 @@ import { SubscriptionService } from './subscription.service';
|
|||||||
export class SubscriptionController {
|
export class SubscriptionController {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
|
private readonly propertyService: PropertyService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||||
private readonly subscriptionService: SubscriptionService
|
private readonly subscriptionService: SubscriptionService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@Post('redeem-coupon')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async redeemCoupon(
|
||||||
|
@Body() { couponCode }: { couponCode: string },
|
||||||
|
@Res() res: Response
|
||||||
|
) {
|
||||||
|
if (!this.request.user) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let coupons =
|
||||||
|
((await this.propertyService.getByKey(PROPERTY_COUPONS)) as Coupon[]) ??
|
||||||
|
[];
|
||||||
|
|
||||||
|
const isValid = coupons.some((coupon) => {
|
||||||
|
return coupon.code === couponCode;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||||
|
StatusCodes.BAD_REQUEST
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.subscriptionService.createSubscription(this.request.user.id);
|
||||||
|
|
||||||
|
// Destroy coupon
|
||||||
|
coupons = coupons.filter((coupon) => {
|
||||||
|
return coupon.code !== couponCode;
|
||||||
|
});
|
||||||
|
await this.propertyService.put({
|
||||||
|
key: PROPERTY_COUPONS,
|
||||||
|
value: JSON.stringify(coupons)
|
||||||
|
});
|
||||||
|
|
||||||
|
Logger.log(
|
||||||
|
`Subscription for user '${this.request.user.id}' has been created with coupon`
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(StatusCodes.OK);
|
||||||
|
|
||||||
|
return <any>res.json({
|
||||||
|
message: getReasonPhrase(StatusCodes.OK),
|
||||||
|
statusCode: StatusCodes.OK
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Get('stripe/callback')
|
@Get('stripe/callback')
|
||||||
public async stripeCallback(@Req() req, @Res() res) {
|
public async stripeCallback(@Req() req, @Res() res) {
|
||||||
await this.subscriptionService.createSubscription(
|
const userId = await this.subscriptionService.createSubscriptionViaStripe(
|
||||||
req.query.checkoutSessionId
|
req.query.checkoutSessionId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Logger.log(`Subscription for user '${userId}' has been created via Stripe`);
|
||||||
|
|
||||||
res.redirect(`${this.configurationService.get('ROOT_URL')}/account`);
|
res.redirect(`${this.configurationService.get('ROOT_URL')}/account`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -46,7 +105,7 @@ export class SubscriptionController {
|
|||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
|
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||||
|
@ -1,13 +1,15 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { SubscriptionController } from './subscription.controller';
|
import { SubscriptionController } from './subscription.controller';
|
||||||
import { SubscriptionService } from './subscription.service';
|
import { SubscriptionService } from './subscription.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [],
|
imports: [PropertyModule],
|
||||||
controllers: [SubscriptionController],
|
controllers: [SubscriptionController],
|
||||||
providers: [ConfigurationService, PrismaService, SubscriptionService]
|
providers: [ConfigurationService, PrismaService, SubscriptionService],
|
||||||
|
exports: [SubscriptionService]
|
||||||
})
|
})
|
||||||
export class SubscriptionModule {}
|
export class SubscriptionModule {}
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
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 { Injectable } from '@nestjs/common';
|
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
||||||
import { addDays } from 'date-fns';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Subscription, User } from '@prisma/client';
|
||||||
|
import { addDays, isBefore } from 'date-fns';
|
||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -10,7 +12,7 @@ export class SubscriptionService {
|
|||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private prisma: PrismaService
|
private readonly prismaService: PrismaService
|
||||||
) {
|
) {
|
||||||
this.stripe = new Stripe(
|
this.stripe = new Stripe(
|
||||||
this.configurationService.get('STRIPE_SECRET_KEY'),
|
this.configurationService.get('STRIPE_SECRET_KEY'),
|
||||||
@ -62,28 +64,53 @@ export class SubscriptionService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createSubscription(aCheckoutSessionId: string) {
|
public async createSubscription(aUserId: string) {
|
||||||
|
await this.prismaService.subscription.create({
|
||||||
|
data: {
|
||||||
|
expiresAt: addDays(new Date(), 365),
|
||||||
|
User: {
|
||||||
|
connect: {
|
||||||
|
id: aUserId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createSubscriptionViaStripe(aCheckoutSessionId: string) {
|
||||||
try {
|
try {
|
||||||
const session = await this.stripe.checkout.sessions.retrieve(
|
const session = await this.stripe.checkout.sessions.retrieve(
|
||||||
aCheckoutSessionId
|
aCheckoutSessionId
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.prisma.subscription.create({
|
await this.createSubscription(session.client_reference_id);
|
||||||
data: {
|
|
||||||
expiresAt: addDays(new Date(), 365),
|
|
||||||
User: {
|
|
||||||
connect: {
|
|
||||||
id: session.client_reference_id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.stripe.customers.update(session.customer as string, {
|
await this.stripe.customers.update(session.customer as string, {
|
||||||
description: session.client_reference_id
|
description: session.client_reference_id
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return session.client_reference_id;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getSubscription(aSubscriptions: Subscription[]) {
|
||||||
|
if (aSubscriptions.length > 0) {
|
||||||
|
const latestSubscription = aSubscriptions.reduce((a, b) => {
|
||||||
|
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
expiresAt: latestSubscription.expiresAt,
|
||||||
|
type: isBefore(new Date(), latestSubscription.expiresAt)
|
||||||
|
? SubscriptionType.Premium
|
||||||
|
: SubscriptionType.Basic
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
type: SubscriptionType.Basic
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
|
|
||||||
export interface LookupItem {
|
export interface LookupItem {
|
||||||
|
currency: string;
|
||||||
dataSource: DataSource;
|
dataSource: DataSource;
|
||||||
name: string;
|
name: string;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
import { Currency, DataSource } from '@prisma/client';
|
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
|
|
||||||
export interface SymbolItem {
|
export interface SymbolItem {
|
||||||
currency: Currency;
|
currency: string;
|
||||||
dataSource: DataSource;
|
dataSource: DataSource;
|
||||||
|
historicalData: HistoricalDataItem[];
|
||||||
marketPrice: number;
|
marketPrice: number;
|
||||||
}
|
}
|
||||||
|
@ -1,16 +1,20 @@
|
|||||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
|
DefaultValuePipe,
|
||||||
Get,
|
Get,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
Param,
|
Param,
|
||||||
|
ParseBoolPipe,
|
||||||
Query,
|
Query,
|
||||||
UseGuards
|
UseGuards
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
import { isEmpty } from 'lodash';
|
||||||
|
|
||||||
import { LookupItem } from './interfaces/lookup-item.interface';
|
import { LookupItem } from './interfaces/lookup-item.interface';
|
||||||
import { SymbolItem } from './interfaces/symbol-item.interface';
|
import { SymbolItem } from './interfaces/symbol-item.interface';
|
||||||
@ -45,9 +49,33 @@ export class SymbolController {
|
|||||||
/**
|
/**
|
||||||
* Must be after /lookup
|
* Must be after /lookup
|
||||||
*/
|
*/
|
||||||
@Get(':symbol')
|
@Get(':dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getPosition(@Param('symbol') symbol): Promise<SymbolItem> {
|
public async getSymbolData(
|
||||||
return this.symbolService.get(symbol);
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string,
|
||||||
|
@Query('includeHistoricalData', new DefaultValuePipe(false), ParseBoolPipe)
|
||||||
|
includeHistoricalData: boolean
|
||||||
|
): Promise<SymbolItem> {
|
||||||
|
if (!DataSource[dataSource]) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
StatusCodes.NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.symbolService.get({
|
||||||
|
includeHistoricalData,
|
||||||
|
dataGatheringItem: { dataSource, symbol }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result || isEmpty(result)) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
StatusCodes.NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,27 +1,20 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
||||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
|
||||||
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { SymbolController } from './symbol.controller';
|
import { SymbolController } from './symbol.controller';
|
||||||
import { SymbolService } from './symbol.service';
|
import { SymbolService } from './symbol.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [],
|
imports: [
|
||||||
|
ConfigurationModule,
|
||||||
|
DataProviderModule,
|
||||||
|
MarketDataModule,
|
||||||
|
PrismaModule
|
||||||
|
],
|
||||||
controllers: [SymbolController],
|
controllers: [SymbolController],
|
||||||
providers: [
|
providers: [SymbolService]
|
||||||
AlphaVantageService,
|
|
||||||
ConfigurationService,
|
|
||||||
DataProviderService,
|
|
||||||
GhostfolioScraperApiService,
|
|
||||||
PrismaService,
|
|
||||||
RakutenRapidApiService,
|
|
||||||
SymbolService,
|
|
||||||
YahooFinanceService
|
|
||||||
]
|
|
||||||
})
|
})
|
||||||
export class SymbolModule {}
|
export class SymbolModule {}
|
||||||
|
@ -1,8 +1,11 @@
|
|||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
|
||||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { convertFromYahooSymbol } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||||
import { Currency, DataSource } from '@prisma/client';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
|
import { subDays } from 'date-fns';
|
||||||
|
|
||||||
import { LookupItem } from './interfaces/lookup-item.interface';
|
import { LookupItem } from './interfaces/lookup-item.interface';
|
||||||
import { SymbolItem } from './interfaces/symbol-item.interface';
|
import { SymbolItem } from './interfaces/symbol-item.interface';
|
||||||
@ -11,18 +14,48 @@ import { SymbolItem } from './interfaces/symbol-item.interface';
|
|||||||
export class SymbolService {
|
export class SymbolService {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly ghostfolioScraperApiService: GhostfolioScraperApiService
|
private readonly marketDataService: MarketDataService,
|
||||||
|
private readonly prismaService: PrismaService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async get(aSymbol: string): Promise<SymbolItem> {
|
public async get({
|
||||||
const response = await this.dataProviderService.get([aSymbol]);
|
dataGatheringItem,
|
||||||
const { currency, dataSource, marketPrice } = response[aSymbol];
|
includeHistoricalData = false
|
||||||
|
}: {
|
||||||
|
dataGatheringItem: IDataGatheringItem;
|
||||||
|
includeHistoricalData?: boolean;
|
||||||
|
}): Promise<SymbolItem> {
|
||||||
|
const response = await this.dataProviderService.get([dataGatheringItem]);
|
||||||
|
const { currency, marketPrice } = response[dataGatheringItem.symbol] ?? {};
|
||||||
|
|
||||||
return {
|
if (dataGatheringItem.dataSource && marketPrice) {
|
||||||
dataSource,
|
let historicalData: HistoricalDataItem[];
|
||||||
marketPrice,
|
|
||||||
currency: <Currency>(<unknown>currency)
|
if (includeHistoricalData) {
|
||||||
};
|
const days = 10;
|
||||||
|
|
||||||
|
const marketData = await this.marketDataService.getRange({
|
||||||
|
dateQuery: { gte: subDays(new Date(), days) },
|
||||||
|
symbols: [dataGatheringItem.symbol]
|
||||||
|
});
|
||||||
|
|
||||||
|
historicalData = marketData.map(({ date, marketPrice }) => {
|
||||||
|
return {
|
||||||
|
date: date.toISOString(),
|
||||||
|
value: marketPrice
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
currency,
|
||||||
|
historicalData,
|
||||||
|
marketPrice,
|
||||||
|
dataSource: dataGatheringItem.dataSource
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> {
|
public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||||
@ -37,20 +70,33 @@ export class SymbolService {
|
|||||||
results.items = items;
|
results.items = items;
|
||||||
|
|
||||||
// Add custom symbols
|
// Add custom symbols
|
||||||
const scraperConfigurations = await this.ghostfolioScraperApiService.getScraperConfigurations();
|
const ghostfolioSymbolProfiles =
|
||||||
scraperConfigurations.forEach((scraperConfiguration) => {
|
await this.prismaService.symbolProfile.findMany({
|
||||||
if (scraperConfiguration.name.toLowerCase().startsWith(aQuery)) {
|
select: {
|
||||||
results.items.push({
|
currency: true,
|
||||||
dataSource: DataSource.GHOSTFOLIO,
|
dataSource: true,
|
||||||
name: scraperConfiguration.name,
|
name: true,
|
||||||
symbol: scraperConfiguration.symbol
|
symbol: true
|
||||||
});
|
},
|
||||||
}
|
where: {
|
||||||
});
|
AND: [
|
||||||
|
{
|
||||||
|
dataSource: DataSource.GHOSTFOLIO,
|
||||||
|
name: {
|
||||||
|
startsWith: aQuery
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const ghostfolioSymbolProfile of ghostfolioSymbolProfiles) {
|
||||||
|
results.items.push(ghostfolioSymbolProfile);
|
||||||
|
}
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { Currency, ViewMode } from '@prisma/client';
|
import { ViewMode } from '@prisma/client';
|
||||||
|
|
||||||
export interface UserSettingsParams {
|
export interface UserSettingsParams {
|
||||||
currency?: Currency;
|
currency?: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
viewMode?: ViewMode;
|
viewMode?: ViewMode;
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,3 @@
|
|||||||
|
export interface UserSettings {
|
||||||
|
isRestrictedView?: boolean;
|
||||||
|
}
|
6
apps/api/src/app/user/update-user-setting.dto.ts
Normal file
6
apps/api/src/app/user/update-user-setting.dto.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { IsBoolean } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateUserSettingDto {
|
||||||
|
@IsBoolean()
|
||||||
|
isRestrictedView?: boolean;
|
||||||
|
}
|
@ -1,9 +1,9 @@
|
|||||||
import { Currency, ViewMode } from '@prisma/client';
|
import { ViewMode } from '@prisma/client';
|
||||||
import { IsString } from 'class-validator';
|
import { IsString } from 'class-validator';
|
||||||
|
|
||||||
export class UpdateUserSettingsDto {
|
export class UpdateUserSettingsDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
baseCurrency: Currency;
|
baseCurrency: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
viewMode: ViewMode;
|
viewMode: ViewMode;
|
||||||
|
@ -1,10 +1,13 @@
|
|||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
|
import { PROPERTY_IS_READ_ONLY_MODE } from '@ghostfolio/common/config';
|
||||||
import { User } from '@ghostfolio/common/interfaces';
|
import { User } from '@ghostfolio/common/interfaces';
|
||||||
import {
|
import {
|
||||||
getPermissions,
|
|
||||||
hasPermission,
|
hasPermission,
|
||||||
|
hasRole,
|
||||||
permissions
|
permissions
|
||||||
} from '@ghostfolio/common/permissions';
|
} from '@ghostfolio/common/permissions';
|
||||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@ -20,19 +23,23 @@ import {
|
|||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { Provider } from '@prisma/client';
|
import { Provider, Role } from '@prisma/client';
|
||||||
import { User as UserModel } from '@prisma/client';
|
import { User as UserModel } from '@prisma/client';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { UserItem } from './interfaces/user-item.interface';
|
import { UserItem } from './interfaces/user-item.interface';
|
||||||
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
|
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
|
||||||
|
import { UserSettings } from './interfaces/user-settings.interface';
|
||||||
|
import { UpdateUserSettingDto } from './update-user-setting.dto';
|
||||||
import { UpdateUserSettingsDto } from './update-user-settings.dto';
|
import { UpdateUserSettingsDto } from './update-user-settings.dto';
|
||||||
import { UserService } from './user.service';
|
import { UserService } from './user.service';
|
||||||
|
|
||||||
@Controller('user')
|
@Controller('user')
|
||||||
export class UserController {
|
export class UserController {
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly configurationService: ConfigurationService,
|
||||||
private jwtService: JwtService,
|
private jwtService: JwtService,
|
||||||
|
private readonly propertyService: PropertyService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||||
private readonly userService: UserService
|
private readonly userService: UserService
|
||||||
) {}
|
) {}
|
||||||
@ -41,10 +48,7 @@ export class UserController {
|
|||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async deleteUser(@Param('id') id: string): Promise<UserModel> {
|
public async deleteUser(@Param('id') id: string): Promise<UserModel> {
|
||||||
if (
|
if (
|
||||||
!hasPermission(
|
!hasPermission(this.request.user.permissions, permissions.deleteUser) ||
|
||||||
getPermissions(this.request.user.role),
|
|
||||||
permissions.deleteUser
|
|
||||||
) ||
|
|
||||||
id === this.request.user.id
|
id === this.request.user.id
|
||||||
) {
|
) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
@ -66,6 +70,19 @@ export class UserController {
|
|||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
public async signupUser(): Promise<UserItem> {
|
public async signupUser(): Promise<UserItem> {
|
||||||
|
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
|
||||||
|
const isReadOnlyMode = (await this.propertyService.getByKey(
|
||||||
|
PROPERTY_IS_READ_ONLY_MODE
|
||||||
|
)) as boolean;
|
||||||
|
|
||||||
|
if (isReadOnlyMode) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { accessToken, id } = await this.userService.createUser({
|
const { accessToken, id } = await this.userService.createUser({
|
||||||
provider: Provider.ANONYMOUS
|
provider: Provider.ANONYMOUS
|
||||||
});
|
});
|
||||||
@ -78,12 +95,38 @@ export class UserController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Put('setting')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async updateUserSetting(@Body() data: UpdateUserSettingDto) {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.updateUserSettings
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userSettings: UserSettings = {
|
||||||
|
...(<UserSettings>this.request.user.Settings.settings),
|
||||||
|
...data
|
||||||
|
};
|
||||||
|
|
||||||
|
return await this.userService.updateUserSetting({
|
||||||
|
userSettings,
|
||||||
|
userId: this.request.user.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Put('settings')
|
@Put('settings')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async updateUserSettings(@Body() data: UpdateUserSettingsDto) {
|
public async updateUserSettings(@Body() data: UpdateUserSettingsDto) {
|
||||||
if (
|
if (
|
||||||
!hasPermission(
|
!hasPermission(
|
||||||
getPermissions(this.request.user.role),
|
this.request.user.permissions,
|
||||||
permissions.updateUserSettings
|
permissions.updateUserSettings
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
@ -99,10 +142,7 @@ export class UserController {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (
|
if (
|
||||||
hasPermission(
|
hasPermission(this.request.user.permissions, permissions.updateViewMode)
|
||||||
getPermissions(this.request.user.role),
|
|
||||||
permissions.updateViewMode
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
userSettings.viewMode = data.viewMode;
|
userSettings.viewMode = data.viewMode;
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
|
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
|
||||||
@ -11,9 +13,12 @@ import { UserService } from './user.service';
|
|||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
secret: process.env.JWT_SECRET_KEY,
|
secret: process.env.JWT_SECRET_KEY,
|
||||||
signOptions: { expiresIn: '30 days' }
|
signOptions: { expiresIn: '30 days' }
|
||||||
})
|
}),
|
||||||
|
PropertyModule,
|
||||||
|
SubscriptionModule
|
||||||
],
|
],
|
||||||
controllers: [UserController],
|
controllers: [UserController],
|
||||||
providers: [ConfigurationService, PrismaService, UserService]
|
providers: [ConfigurationService, PrismaService, UserService],
|
||||||
|
exports: [UserService]
|
||||||
})
|
})
|
||||||
export class UserModule {}
|
export class UserModule {}
|
||||||
|
@ -1,24 +1,36 @@
|
|||||||
|
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { locale } from '@ghostfolio/common/config';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
|
import {
|
||||||
|
PROPERTY_IS_READ_ONLY_MODE,
|
||||||
|
baseCurrency,
|
||||||
|
locale
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces';
|
import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces';
|
||||||
import { getPermissions, permissions } from '@ghostfolio/common/permissions';
|
import {
|
||||||
|
getPermissions,
|
||||||
|
hasRole,
|
||||||
|
permissions
|
||||||
|
} from '@ghostfolio/common/permissions';
|
||||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Currency, Prisma, Provider, User, ViewMode } from '@prisma/client';
|
import { Prisma, Provider, Role, User, ViewMode } from '@prisma/client';
|
||||||
import { isBefore } from 'date-fns';
|
|
||||||
|
|
||||||
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
|
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
|
||||||
|
import { UserSettings } from './interfaces/user-settings.interface';
|
||||||
|
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserService {
|
export class UserService {
|
||||||
public static DEFAULT_CURRENCY = Currency.USD;
|
public static DEFAULT_CURRENCY = 'USD';
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private prisma: PrismaService
|
private readonly prismaService: PrismaService,
|
||||||
|
private readonly propertyService: PropertyService,
|
||||||
|
private readonly subscriptionService: SubscriptionService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async getUser({
|
public async getUser({
|
||||||
@ -29,7 +41,7 @@ export class UserService {
|
|||||||
Settings,
|
Settings,
|
||||||
subscription
|
subscription
|
||||||
}: UserWithSettings): Promise<IUser> {
|
}: UserWithSettings): Promise<IUser> {
|
||||||
const access = await this.prisma.access.findMany({
|
const access = await this.prismaService.access.findMany({
|
||||||
include: {
|
include: {
|
||||||
User: true
|
User: true
|
||||||
},
|
},
|
||||||
@ -50,6 +62,7 @@ export class UserService {
|
|||||||
}),
|
}),
|
||||||
accounts: Account,
|
accounts: Account,
|
||||||
settings: {
|
settings: {
|
||||||
|
...(<UserSettings>Settings.settings),
|
||||||
locale,
|
locale,
|
||||||
baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY,
|
baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY,
|
||||||
viewMode: Settings?.viewMode ?? ViewMode.DEFAULT
|
viewMode: Settings?.viewMode ?? ViewMode.DEFAULT
|
||||||
@ -57,22 +70,46 @@ export class UserService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public isRestrictedView(aUser: UserWithSettings) {
|
||||||
|
return (aUser.Settings.settings as UserSettings)?.isRestrictedView ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
public async user(
|
public async user(
|
||||||
userWhereUniqueInput: Prisma.UserWhereUniqueInput
|
userWhereUniqueInput: Prisma.UserWhereUniqueInput
|
||||||
): Promise<UserWithSettings | null> {
|
): Promise<UserWithSettings | null> {
|
||||||
const userFromDatabase = await this.prisma.user.findUnique({
|
const userFromDatabase = await this.prismaService.user.findUnique({
|
||||||
include: { Account: true, Settings: true, Subscription: true },
|
include: { Account: true, Settings: true, Subscription: true },
|
||||||
where: userWhereUniqueInput
|
where: userWhereUniqueInput
|
||||||
});
|
});
|
||||||
|
|
||||||
const user: UserWithSettings = userFromDatabase;
|
const user: UserWithSettings = userFromDatabase;
|
||||||
|
|
||||||
const currentPermissions = getPermissions(userFromDatabase.role);
|
let currentPermissions = getPermissions(userFromDatabase.role);
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
||||||
currentPermissions.push(permissions.accessFearAndGreedIndex);
|
currentPermissions.push(permissions.accessFearAndGreedIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
|
||||||
|
if (hasRole(user, Role.ADMIN)) {
|
||||||
|
currentPermissions.push(permissions.toggleReadOnlyMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isReadOnlyMode = (await this.propertyService.getByKey(
|
||||||
|
PROPERTY_IS_READ_ONLY_MODE
|
||||||
|
)) as boolean;
|
||||||
|
|
||||||
|
if (isReadOnlyMode) {
|
||||||
|
currentPermissions = currentPermissions.filter((permission) => {
|
||||||
|
return !(
|
||||||
|
permission.startsWith('create') ||
|
||||||
|
permission.startsWith('delete') ||
|
||||||
|
permission.startsWith('update')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
user.permissions = currentPermissions;
|
user.permissions = currentPermissions;
|
||||||
|
|
||||||
if (userFromDatabase?.Settings) {
|
if (userFromDatabase?.Settings) {
|
||||||
@ -84,6 +121,7 @@ export class UserService {
|
|||||||
// Set default settings if needed
|
// Set default settings if needed
|
||||||
userFromDatabase.Settings = {
|
userFromDatabase.Settings = {
|
||||||
currency: UserService.DEFAULT_CURRENCY,
|
currency: UserService.DEFAULT_CURRENCY,
|
||||||
|
settings: null,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
userId: userFromDatabase?.id,
|
userId: userFromDatabase?.id,
|
||||||
viewMode: ViewMode.DEFAULT
|
viewMode: ViewMode.DEFAULT
|
||||||
@ -91,24 +129,9 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
if (userFromDatabase?.Subscription?.length > 0) {
|
user.subscription = this.subscriptionService.getSubscription(
|
||||||
const latestSubscription = userFromDatabase.Subscription.reduce(
|
userFromDatabase?.Subscription
|
||||||
(a, b) => {
|
);
|
||||||
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
user.subscription = {
|
|
||||||
expiresAt: latestSubscription.expiresAt,
|
|
||||||
type: isBefore(new Date(), latestSubscription.expiresAt)
|
|
||||||
? SubscriptionType.Premium
|
|
||||||
: SubscriptionType.Basic
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
user.subscription = {
|
|
||||||
type: SubscriptionType.Basic
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.subscription.type === SubscriptionType.Basic) {
|
if (user.subscription.type === SubscriptionType.Basic) {
|
||||||
user.permissions = user.permissions.filter((permission) => {
|
user.permissions = user.permissions.filter((permission) => {
|
||||||
@ -126,10 +149,10 @@ export class UserService {
|
|||||||
take?: number;
|
take?: number;
|
||||||
cursor?: Prisma.UserWhereUniqueInput;
|
cursor?: Prisma.UserWhereUniqueInput;
|
||||||
where?: Prisma.UserWhereInput;
|
where?: Prisma.UserWhereInput;
|
||||||
orderBy?: Prisma.UserOrderByInput;
|
orderBy?: Prisma.UserOrderByWithRelationInput;
|
||||||
}): Promise<User[]> {
|
}): Promise<User[]> {
|
||||||
const { skip, take, cursor, where, orderBy } = params;
|
const { skip, take, cursor, where, orderBy } = params;
|
||||||
return this.prisma.user.findMany({
|
return this.prismaService.user.findMany({
|
||||||
skip,
|
skip,
|
||||||
take,
|
take,
|
||||||
cursor,
|
cursor,
|
||||||
@ -146,14 +169,20 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async createUser(data?: Prisma.UserCreateInput): Promise<User> {
|
public async createUser(data?: Prisma.UserCreateInput): Promise<User> {
|
||||||
let user = await this.prisma.user.create({
|
let user = await this.prismaService.user.create({
|
||||||
data: {
|
data: {
|
||||||
...data,
|
...data,
|
||||||
Account: {
|
Account: {
|
||||||
create: {
|
create: {
|
||||||
|
currency: baseCurrency,
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
name: 'Default Account'
|
name: 'Default Account'
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
Settings: {
|
||||||
|
create: {
|
||||||
|
currency: baseCurrency
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -169,7 +198,7 @@ export class UserService {
|
|||||||
process.env.ACCESS_TOKEN_SALT
|
process.env.ACCESS_TOKEN_SALT
|
||||||
);
|
);
|
||||||
|
|
||||||
user = await this.prisma.user.update({
|
user = await this.prismaService.user.update({
|
||||||
data: { accessToken: hashedAccessToken },
|
data: { accessToken: hashedAccessToken },
|
||||||
where: { id: user.id }
|
where: { id: user.id }
|
||||||
});
|
});
|
||||||
@ -185,46 +214,75 @@ export class UserService {
|
|||||||
data: Prisma.UserUpdateInput;
|
data: Prisma.UserUpdateInput;
|
||||||
}): Promise<User> {
|
}): Promise<User> {
|
||||||
const { where, data } = params;
|
const { where, data } = params;
|
||||||
return this.prisma.user.update({
|
return this.prismaService.user.update({
|
||||||
data,
|
data,
|
||||||
where
|
where
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deleteUser(where: Prisma.UserWhereUniqueInput): Promise<User> {
|
public async deleteUser(where: Prisma.UserWhereUniqueInput): Promise<User> {
|
||||||
await this.prisma.access.deleteMany({
|
await this.prismaService.access.deleteMany({
|
||||||
where: { OR: [{ granteeUserId: where.id }, { userId: where.id }] }
|
where: { OR: [{ granteeUserId: where.id }, { userId: where.id }] }
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.prisma.account.deleteMany({
|
await this.prismaService.account.deleteMany({
|
||||||
where: { userId: where.id }
|
where: { userId: where.id }
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.prisma.analytics.delete({
|
await this.prismaService.analytics.delete({
|
||||||
where: { userId: where.id }
|
where: { userId: where.id }
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.prisma.order.deleteMany({
|
await this.prismaService.order.deleteMany({
|
||||||
where: { userId: where.id }
|
where: { userId: where.id }
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.prisma.settings.delete({
|
await this.prismaService.settings.delete({
|
||||||
where: { userId: where.id }
|
where: { userId: where.id }
|
||||||
});
|
});
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
return this.prisma.user.delete({
|
return this.prismaService.user.delete({
|
||||||
where
|
where
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async updateUserSetting({
|
||||||
|
userId,
|
||||||
|
userSettings
|
||||||
|
}: {
|
||||||
|
userId: string;
|
||||||
|
userSettings: UserSettings;
|
||||||
|
}) {
|
||||||
|
const settings = userSettings as Prisma.JsonObject;
|
||||||
|
|
||||||
|
await this.prismaService.settings.upsert({
|
||||||
|
create: {
|
||||||
|
settings,
|
||||||
|
User: {
|
||||||
|
connect: {
|
||||||
|
id: userId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
settings
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
userId: userId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
public async updateUserSettings({
|
public async updateUserSettings({
|
||||||
currency,
|
currency,
|
||||||
userId,
|
userId,
|
||||||
viewMode
|
viewMode
|
||||||
}: UserSettingsParams) {
|
}: UserSettingsParams) {
|
||||||
await this.prisma.settings.upsert({
|
await this.prismaService.settings.upsert({
|
||||||
create: {
|
create: {
|
||||||
currency,
|
currency,
|
||||||
User: {
|
User: {
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
export const environment = {
|
export const environment = {
|
||||||
production: true
|
production: true,
|
||||||
|
version: `v${require('../../../../package.json').version}`
|
||||||
};
|
};
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
export const environment = {
|
export const environment = {
|
||||||
production: false
|
production: false,
|
||||||
|
version: 'dev'
|
||||||
};
|
};
|
||||||
|
@ -2,6 +2,7 @@ import { Logger, ValidationPipe } from '@nestjs/common';
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
|
||||||
import { AppModule } from './app/app.module';
|
import { AppModule } from './app/app.module';
|
||||||
|
import { environment } from './environments/environment';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
@ -18,8 +19,21 @@ async function bootstrap() {
|
|||||||
|
|
||||||
const port = process.env.PORT || 3333;
|
const port = process.env.PORT || 3333;
|
||||||
await app.listen(port, () => {
|
await app.listen(port, () => {
|
||||||
|
logLogo();
|
||||||
Logger.log(`Listening at http://localhost:${port}`);
|
Logger.log(`Listening at http://localhost:${port}`);
|
||||||
|
Logger.log('');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function logLogo() {
|
||||||
|
Logger.log(' ________ __ ____ ___');
|
||||||
|
Logger.log(' / ____/ /_ ____ _____/ /_/ __/___ / (_)___');
|
||||||
|
Logger.log(' / / __/ __ \\/ __ \\/ ___/ __/ /_/ __ \\/ / / __ \\');
|
||||||
|
Logger.log('/ /_/ / / / / /_/ (__ ) /_/ __/ /_/ / / / /_/ /');
|
||||||
|
Logger.log(
|
||||||
|
`\\____/_/ /_/\\____/____/\\__/_/ \\____/_/_/\\____/ ${environment.version}`
|
||||||
|
);
|
||||||
|
Logger.log('');
|
||||||
|
}
|
||||||
|
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
@ -5,13 +5,9 @@ import { Order } from '../order';
|
|||||||
export interface PortfolioInterface {
|
export interface PortfolioInterface {
|
||||||
get(aDate?: Date): PortfolioItem[];
|
get(aDate?: Date): PortfolioItem[];
|
||||||
|
|
||||||
getCommittedFunds(): number;
|
|
||||||
|
|
||||||
getFees(): number;
|
getFees(): number;
|
||||||
|
|
||||||
getPositions(
|
getPositions(aDate: Date): {
|
||||||
aDate: Date
|
|
||||||
): {
|
|
||||||
[symbol: string]: Position;
|
[symbol: string]: Position;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -0,0 +1,3 @@
|
|||||||
|
export interface RuleSettings {
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
@ -1,15 +1,10 @@
|
|||||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||||
|
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||||
|
|
||||||
import { EvaluationResult } from './evaluation-result.interface';
|
import { EvaluationResult } from './evaluation-result.interface';
|
||||||
|
|
||||||
export interface RuleInterface {
|
export interface RuleInterface<T extends RuleSettings> {
|
||||||
evaluate(
|
evaluate(aRuleSettings: T): EvaluationResult;
|
||||||
aPortfolioPositionMap: {
|
|
||||||
[symbol: string]: PortfolioPosition;
|
getSettings(aUserSettings: UserSettings): T;
|
||||||
},
|
|
||||||
aFees: number,
|
|
||||||
aRuleSettingsMap: {
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
): EvaluationResult;
|
|
||||||
}
|
}
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user