Compare commits
462 Commits
Author | SHA1 | Date | |
---|---|---|---|
1877b31f00 | |||
00895b7bb1 | |||
bff60ddbe0 | |||
d46de0a15e | |||
7b45a8b3fc | |||
693791d113 | |||
1b2d2a9860 | |||
bde8be1385 | |||
74ca058364 | |||
ba3cf82c6e | |||
217bb6aa5a | |||
440dc470fa | |||
165ca94f5b | |||
c418e75139 | |||
76bf839010 | |||
3bdc4c9b4a | |||
005890d785 | |||
256c020e88 | |||
5fa3388609 | |||
be801b481e | |||
a72e98f73c | |||
f5df970685 | |||
edfdc0c346 | |||
fcfe7b1787 | |||
170b8acc65 | |||
a47829082e | |||
48ab5fcf08 | |||
dc8b60eeb1 | |||
ee67432ffc | |||
7755a6b655 | |||
d7f72819de | |||
2a4d7bf14f | |||
d49287922f | |||
ac0f6f40cf | |||
d91f947ab0 | |||
af71274ea9 | |||
0feba4b8d9 | |||
62f85293e2 | |||
6a048cee85 | |||
0d93612d16 | |||
9bf68b0d20 | |||
371f1dc451 | |||
5cb2ec6411 | |||
3723a1d8b8 | |||
4c30e9459d | |||
23d323073d | |||
0ad734262a | |||
0649f9fd2c | |||
d089662dab | |||
8c1c336fc6 | |||
43b4f14ace | |||
3717e38845 | |||
265d4d0450 | |||
726e727c7d | |||
cb664774c0 | |||
b89bf1d5e8 | |||
53ce37a83a | |||
e9ac9057ff | |||
7020fc2a93 | |||
efcd9539dd | |||
61ecc48d0e | |||
e465f1b791 | |||
01b6c14bcc | |||
34b02210df | |||
0034776b34 | |||
b183c45027 | |||
7d68905f1b | |||
0953c072fe | |||
d152187ee8 | |||
3c5affce88 | |||
f27e21f9a0 | |||
337ca328c3 | |||
beb9e2c43f | |||
4d79df90a7 | |||
aa72d9b730 | |||
80e899a5d3 | |||
7c33120546 | |||
7f3c86038f | |||
c1446f8559 | |||
88d5dfe435 | |||
7dc8f80fdf | |||
96f90c7259 | |||
a10d9cb6ba | |||
4547c5da1d | |||
28706d7b26 | |||
492bc5e17b | |||
6c37737051 | |||
8677d20c2c | |||
4d905065ad | |||
5599b41b83 | |||
8d5a60d777 | |||
695acf4f3f | |||
67dbef3b7a | |||
0e94112dc7 | |||
b22edff16b | |||
ffb7cbff50 | |||
25424ad280 | |||
a768902b00 | |||
2c7ece50fe | |||
51a0ede3e4 | |||
531964636b | |||
e461fff1d7 | |||
4f9a5f0340 | |||
8d80e840b8 | |||
833982a9de | |||
c85966e5ed | |||
43f67ba832 | |||
cbea8ac9d3 | |||
d4c939e41d | |||
c1f129501a | |||
377ba75e4c | |||
77b13b88f0 | |||
813e73a0a3 | |||
1d796a9597 | |||
4eedf64a3c | |||
ed4dd79c72 | |||
6f4fd0826c | |||
8e3a144a37 | |||
07b0a2c40a | |||
c5dc3d4272 | |||
73e69273b4 | |||
e0b74ef418 | |||
2b491dc732 | |||
79fc22b5ae | |||
0a83bcd697 | |||
52540d460b | |||
6ff2e0f952 | |||
b3e72383bc | |||
bdfba4d509 | |||
8a411b707d | |||
e21601202e | |||
8f66040df1 | |||
5ad248a643 | |||
fa36c42af4 | |||
d4ddc781e1 | |||
386dd56590 | |||
f28b13604a | |||
d827858d0b | |||
c758ca4bfa | |||
37183a07bd | |||
fb294fc6e2 | |||
8898d02442 | |||
232d30234c | |||
e2234c4966 | |||
272a34195b | |||
8c25294da7 | |||
6f11627006 | |||
215098e418 | |||
781496383b | |||
f0f304c012 | |||
4bf97c104b | |||
0b35a3c7a7 | |||
1586cd3a59 | |||
ae763cbb87 | |||
aa72287d54 | |||
d155ab6f28 | |||
913ca71aa5 | |||
1ffde2a27e | |||
fcf0cea982 | |||
ae1968aadf | |||
3e6333ef95 | |||
c69686651e | |||
93b6011ddc | |||
f567e25f27 | |||
5dc538bafb | |||
b4de06fcf0 | |||
27da0eb26e | |||
8ff80c10e5 | |||
5db5d5e79a | |||
12aac101bd | |||
3a66ccdebe | |||
6a722d1bb7 | |||
7c9407d5dc | |||
8abb517ac6 | |||
dec1d89c5c | |||
24e9ecc3e2 | |||
4a1e05b8cd | |||
39d1a85267 | |||
7cb86de7af | |||
aa078588e8 | |||
fcef0a72d5 | |||
29987d3e2f | |||
6284b4dfe8 | |||
00342ca1f7 | |||
234c4fd511 | |||
669f1fb60c | |||
52df0c62ab | |||
e8e1bb83bf | |||
45510702d0 | |||
1b7e3a1e47 | |||
35f98b9d2d | |||
e980aed9e7 | |||
d993067e9a | |||
3d09bfdb0c | |||
3fbc4f500f | |||
373201a98f | |||
681f88f002 | |||
8a523a981a | |||
81ded53363 | |||
5272407af8 | |||
c48f89d117 | |||
046fdd3ae7 | |||
e69c7a753c | |||
5191415b5a | |||
a704378702 | |||
cf7ce64de7 | |||
8c1b45f35b | |||
6ad1528d01 | |||
4a6fbe4d30 | |||
e31741f0c7 | |||
b26aa7f51d | |||
c0fccd186f | |||
a7baad10d1 | |||
16f1b16e41 | |||
409ddc90ce | |||
95bc84956e | |||
20cefaba19 | |||
379c651ce0 | |||
7804c6879d | |||
de2255f9ba | |||
e4ec5f213e | |||
f3c2fb853d | |||
f5ad1d2d24 | |||
0af37ca1d7 | |||
2992a0da4c | |||
2dcc7e161c | |||
fa627f686f | |||
0567083fc1 | |||
3212efef17 | |||
6077e7c2f9 | |||
96b5dcfaf8 | |||
c4e8e37884 | |||
281d33f825 | |||
5822e4d186 | |||
cb166dcc78 | |||
4e7b7375a9 | |||
b8626c2086 | |||
a59f9fa037 | |||
1666486940 | |||
ac0ad48a65 | |||
6a19eab425 | |||
750c627613 | |||
60b2115e3b | |||
e7956943ba | |||
f66edf8de0 | |||
29028a81f5 | |||
c9878c9050 | |||
73ac4b4197 | |||
016634a77f | |||
ea65dc5034 | |||
84db54babd | |||
653c9c62a8 | |||
74278073b3 | |||
0375b938a2 | |||
32df7620d9 | |||
8492a8fed0 | |||
30e561c06f | |||
7243090c0e | |||
7ae49eb839 | |||
bf816c3b89 | |||
20f9225daa | |||
b6101c6375 | |||
e1022846b9 | |||
9ba79f6721 | |||
0ac97bd112 | |||
827270704a | |||
8634463597 | |||
3905782ad6 | |||
5db984ffef | |||
fb3cd4b689 | |||
3b5a34f6f3 | |||
22b43b5bfc | |||
6c66033eb4 | |||
162fc25e23 | |||
45f385a483 | |||
e9ef911548 | |||
d8d4d8f001 | |||
f47c7313af | |||
31f0056a2d | |||
550e646079 | |||
37ff7acf04 | |||
8236091477 | |||
2a71cb66de | |||
e60fe48fdd | |||
d40bc5070a | |||
fda4e0ea7d | |||
08d696ce33 | |||
46614a7c24 | |||
02b433eb1e | |||
25112a450b | |||
727340748b | |||
8ad6492477 | |||
4af76f6f6d | |||
10940214a5 | |||
d9a6c22e1e | |||
692309988c | |||
42a54263f9 | |||
4fb88859b2 | |||
aa24b5e8c6 | |||
90e18338f6 | |||
ad5ae938ef | |||
c9a8dd4958 | |||
f1ec5e704e | |||
f40f0653c2 | |||
5f7a230fd3 | |||
71feb531e8 | |||
ec3552d7f6 | |||
41875e70d6 | |||
5fa0540936 | |||
5b69dee246 | |||
19b0fe04a6 | |||
19ea4479ff | |||
0b2f6a312c | |||
f79d60014b | |||
5b7409d08e | |||
6230aa87e2 | |||
8b615d2f56 | |||
4100446cac | |||
ad3e6d637c | |||
aa87262954 | |||
01b6bb5b99 | |||
884b7f4de7 | |||
3f8a2b47f9 | |||
e2e4c9be3c | |||
0f7c6ff0fe | |||
703a96f4db | |||
42c0560422 | |||
eb63802d01 | |||
6d9191a46f | |||
6744245d8b | |||
8f64a77a9d | |||
0d5fc7655b | |||
c511ec7e33 | |||
b12349a148 | |||
f7e3a4c727 | |||
5f276469b7 | |||
69e1d92ed3 | |||
ef2849aa6c | |||
c668d7b456 | |||
e23bf62859 | |||
54c5746d21 | |||
7130ac7565 | |||
1851ae137f | |||
6f6ff94979 | |||
7f25066f0f | |||
fc795aaa8c | |||
d0112968e8 | |||
522025ffa0 | |||
27bf662281 | |||
93c27277c6 | |||
5e6adfcef5 | |||
ab691bb27a | |||
8fc5676443 | |||
1fe1e2fe0c | |||
921d38a706 | |||
6161d5e77c | |||
369386f976 | |||
41437636b1 | |||
b21884eb66 | |||
1c5437e1fd | |||
58278ba5e6 | |||
921f3e9807 | |||
75ca125a70 | |||
a1fd4e7a38 | |||
0d5a8eb33e | |||
b088df2fa3 | |||
f45d8f616a | |||
d8300502ce | |||
502d51ad29 | |||
bc33e5f147 | |||
48ba8f936b | |||
05ec4cce05 | |||
d74f283707 | |||
0f8bc7db32 | |||
431500f28a | |||
9672de174e | |||
c6aa06b933 | |||
1f46a6b6f3 | |||
1bed940bc0 | |||
f9eb3cc3c5 | |||
2519c3ffb0 | |||
91013d1d10 | |||
6deefb9c43 | |||
d0744e07df | |||
93e1ee3ba7 | |||
dceaa55a6c | |||
8b4d55925d | |||
754b49e50f | |||
6ccbda8169 | |||
b0fb986208 | |||
0b59fc639d | |||
7ddd6f27b5 | |||
c5d56f4b47 | |||
2f2b712999 | |||
c2fd31f5e5 | |||
f2d70f9070 | |||
f41dd9cd8e | |||
7d238b4935 | |||
da6591fca0 | |||
1f9b9e9998 | |||
49c4ea306d | |||
ccb5c664ef | |||
97e165ff69 | |||
45aefb6a45 | |||
2435535975 | |||
bd3d43bf05 | |||
02dc7c52b1 | |||
ff59fd4196 | |||
4955555ddd | |||
a98c788a26 | |||
9c16af81c7 | |||
2df27100f0 | |||
6cf6538719 | |||
0fd3db3228 | |||
18835149e2 | |||
6c9779fb0d | |||
3e98f097ef | |||
183ac8fa2b | |||
9036f53e7d | |||
f7c04e469a | |||
b5f01c0d15 | |||
5a23cd34ad | |||
6e87f34c6f | |||
6618aa2e9b | |||
0d25a96f7e | |||
4f6d9d3a76 | |||
928f6f0c45 | |||
09e95ddcee | |||
2d003225bc | |||
de93cabd69 | |||
51489cca81 | |||
f7f4c3afb1 | |||
0821086e41 | |||
7a905fde63 | |||
d2882b1119 | |||
3a500598c5 | |||
42274917e0 | |||
8ba50f2729 | |||
f22071f061 | |||
d2312371a6 | |||
ba837c3c30 | |||
d85d83a0f5 | |||
62e8594c57 | |||
509f95ea30 | |||
43d0b55004 | |||
c0f130a077 | |||
90dc34380e | |||
286e41eb21 | |||
4973d0261d | |||
c4a62dfd68 | |||
4d6be0a507 | |||
b259ab7b0c | |||
e1ac5245c7 | |||
d4fea075af | |||
cef7fa79de | |||
ca05397dcd | |||
2a11977001 | |||
fb1a5c93ef | |||
77e9791e03 | |||
efd9e7a5c7 | |||
d9ced885e1 | |||
5fe07cb85f |
@ -11,6 +11,5 @@ POSTGRES_USER=user
|
|||||||
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
|
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
|
||||||
|
|
||||||
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
|
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
|
||||||
ALPHA_VANTAGE_API_KEY=
|
|
||||||
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
|
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
|
||||||
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
|
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
|
||||||
|
11
.github/ISSUE_TEMPLATE/bug_report.md
vendored
11
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -6,7 +6,13 @@ labels: ''
|
|||||||
assignees: ''
|
assignees: ''
|
||||||
---
|
---
|
||||||
|
|
||||||
The Issue tracker is **ONLY** used for reporting bugs. New features should be discussed in our [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) community or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
|
**Important Notice**
|
||||||
|
|
||||||
|
The issue tracker is **ONLY** used for reporting bugs. New features should be discussed in our [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) community or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
|
||||||
|
|
||||||
|
Incomplete or non-reproducible issues may be closed, but we are here to help! If you encounter difficulties reproducing the bug or need assistance, please reach out to our community channels mentioned above.
|
||||||
|
|
||||||
|
Thank you for your understanding and cooperation!
|
||||||
|
|
||||||
**Bug Description**
|
**Bug Description**
|
||||||
|
|
||||||
@ -36,8 +42,9 @@ The Issue tracker is **ONLY** used for reporting bugs. New features should be di
|
|||||||
|
|
||||||
<!-- Please complete the following information -->
|
<!-- Please complete the following information -->
|
||||||
|
|
||||||
- Cloud or Self-hosted
|
|
||||||
- Ghostfolio Version X.Y.Z
|
- Ghostfolio Version X.Y.Z
|
||||||
|
- Cloud or Self-hosted
|
||||||
|
- Experimental Features enabled or disabled
|
||||||
- Browser
|
- Browser
|
||||||
- OS
|
- OS
|
||||||
|
|
||||||
|
9
.github/workflows/build-code.yml
vendored
9
.github/workflows/build-code.yml
vendored
@ -4,6 +4,9 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@ -13,12 +16,12 @@ jobs:
|
|||||||
- 18
|
- 18
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Use Node.js ${{ matrix.node_version }}
|
- name: Use Node.js ${{ matrix.node_version }}
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node_version }}
|
node-version: ${{ matrix.node_version }}
|
||||||
cache: 'yarn'
|
cache: 'yarn'
|
||||||
@ -33,4 +36,4 @@ jobs:
|
|||||||
run: yarn test
|
run: yarn test
|
||||||
|
|
||||||
- name: Build application
|
- name: Build application
|
||||||
run: yarn build:all
|
run: yarn build:production
|
||||||
|
3
.github/workflows/docker-image.yml
vendored
3
.github/workflows/docker-image.yml
vendored
@ -13,7 +13,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Docker metadata
|
- name: Docker metadata
|
||||||
id: meta
|
id: meta
|
||||||
@ -21,6 +21,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
images: ghostfolio/ghostfolio
|
images: ghostfolio/ghostfolio
|
||||||
tags: |
|
tags: |
|
||||||
|
type=semver,pattern={{major}}
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -27,6 +27,7 @@
|
|||||||
/.angular/cache
|
/.angular/cache
|
||||||
.env
|
.env
|
||||||
.env.prod
|
.env.prod
|
||||||
|
.nx/cache
|
||||||
/.sass-cache
|
/.sass-cache
|
||||||
/connect.lock
|
/connect.lock
|
||||||
/coverage
|
/coverage
|
||||||
|
@ -1,2 +1,3 @@
|
|||||||
|
/.nx/cache
|
||||||
/dist
|
/dist
|
||||||
/test/import
|
/test/import
|
||||||
|
@ -9,6 +9,7 @@
|
|||||||
],
|
],
|
||||||
"attributeSort": "ASC",
|
"attributeSort": "ASC",
|
||||||
"endOfLine": "auto",
|
"endOfLine": "auto",
|
||||||
|
"plugins": ["prettier-plugin-organize-attributes"],
|
||||||
"printWidth": 80,
|
"printWidth": 80,
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
|
859
CHANGELOG.md
859
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,17 @@
|
|||||||
# Ghostfolio Development Guide
|
# Ghostfolio Development Guide
|
||||||
|
|
||||||
|
## Experimental Features
|
||||||
|
|
||||||
|
New functionality can be enabled using a feature flag switch from the user settings.
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
Remove permission in `UserService` using `without()`
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
Use `*ngIf="user?.settings?.isExperimentalFeatures"` in HTML template
|
||||||
|
|
||||||
## Git
|
## Git
|
||||||
|
|
||||||
### Rebase
|
### Rebase
|
||||||
@ -8,16 +20,28 @@
|
|||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
|
### Angular
|
||||||
|
|
||||||
|
#### Upgrade (minor versions)
|
||||||
|
|
||||||
|
1. Run `npx npm-check-updates --upgrade --target "minor" --filter "/@angular.*/"`
|
||||||
|
|
||||||
### Nx
|
### Nx
|
||||||
|
|
||||||
#### Upgrade
|
#### Upgrade
|
||||||
|
|
||||||
1. Run `yarn nx migrate latest`
|
1. Run `yarn nx migrate latest`
|
||||||
1. Make sure `package.json` changes make sense and then run `yarn install`
|
1. Make sure `package.json` changes make sense and then run `yarn install`
|
||||||
1. Run `yarn nx migrate --run-migrations`
|
1. Run `yarn nx migrate --run-migrations` (Run `YARN_NODE_LINKER="node-modules" NX_MIGRATE_SKIP_INSTALL=1 yarn nx migrate --run-migrations` due to https://github.com/nrwl/nx/issues/16338)
|
||||||
|
|
||||||
### Prisma
|
### Prisma
|
||||||
|
|
||||||
|
#### Access database via GUI
|
||||||
|
|
||||||
|
Run `yarn database:gui`
|
||||||
|
|
||||||
|
https://www.prisma.io/studio
|
||||||
|
|
||||||
#### Synchronize schema with database for prototyping
|
#### Synchronize schema with database for prototyping
|
||||||
|
|
||||||
Run `yarn database:push`
|
Run `yarn database:push`
|
||||||
|
@ -33,7 +33,7 @@ COPY ./tsconfig.base.json tsconfig.base.json
|
|||||||
COPY ./libs libs
|
COPY ./libs libs
|
||||||
COPY ./apps apps
|
COPY ./apps apps
|
||||||
|
|
||||||
RUN yarn build:all
|
RUN yarn build:production
|
||||||
|
|
||||||
# Prepare the dist image with additional node_modules
|
# Prepare the dist image with additional node_modules
|
||||||
WORKDIR /ghostfolio/dist/apps/api
|
WORKDIR /ghostfolio/dist/apps/api
|
||||||
@ -58,4 +58,4 @@ RUN apt update && apt install -y \
|
|||||||
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
||||||
WORKDIR /ghostfolio/apps/api
|
WORKDIR /ghostfolio/apps/api
|
||||||
EXPOSE ${PORT:-3333}
|
EXPOSE ${PORT:-3333}
|
||||||
CMD [ "yarn", "start:prod" ]
|
CMD [ "yarn", "start:production" ]
|
||||||
|
85
README.md
85
README.md
@ -13,6 +13,8 @@
|
|||||||
[](#contributing)
|
[](#contributing)
|
||||||
[](https://www.gnu.org/licenses/agpl-3.0)
|
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||||
|
|
||||||
|
New: [Ghostfolio 2.0](https://ghostfol.io/en/blog/2023/09/ghostfolio-2)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. The software is designed for personal use in continuous operation.
|
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. The software is designed for personal use in continuous operation.
|
||||||
@ -25,7 +27,7 @@
|
|||||||
|
|
||||||
## Ghostfolio Premium
|
## Ghostfolio Premium
|
||||||
|
|
||||||
Our official **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
|
Our official **[Ghostfolio Premium](https://ghostfol.io/en/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. Revenue is used to cover the costs of the hosting infrastructure and to fund ongoing development.
|
||||||
|
|
||||||
If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the [Self-hosting](#self-hosting) section.
|
If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the [Self-hosting](#self-hosting) section.
|
||||||
|
|
||||||
@ -47,7 +49,7 @@ Ghostfolio is for you if you are...
|
|||||||
|
|
||||||
- ✅ Create, update and delete transactions
|
- ✅ Create, update and delete transactions
|
||||||
- ✅ Multi account management
|
- ✅ Multi account management
|
||||||
- ✅ Portfolio performance for `Today`, `YTD`, `1Y`, `5Y`, `Max`
|
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `YTD`, `1Y`, `5Y`, `Max`
|
||||||
- ✅ Various charts
|
- ✅ Various charts
|
||||||
- ✅ Static analysis to identify potential risks in your portfolio
|
- ✅ Static analysis to identify potential risks in your portfolio
|
||||||
- ✅ Import and export transactions
|
- ✅ Import and export transactions
|
||||||
@ -85,19 +87,22 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
|
|||||||
|
|
||||||
### Supported Environment Variables
|
### Supported Environment Variables
|
||||||
|
|
||||||
| Name | Default Value | Description |
|
| Name | Default Value | Description |
|
||||||
| ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------ | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
|
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
|
||||||
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
| `API_KEY_COINGECKO_DEMO` | | The _CoinGecko_ Demo API key |
|
||||||
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
| `API_KEY_COINGECKO_PRO` | | The _CoinGecko_ Pro API |
|
||||||
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
|
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
||||||
| `PORT` | `3333` | The port where the Ghostfolio application will run on |
|
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
||||||
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
|
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
|
||||||
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
|
| `PORT` | `3333` | The port where the Ghostfolio application will run on |
|
||||||
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
|
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
|
||||||
| `REDIS_HOST` | | The host where _Redis_ is running |
|
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
|
||||||
| `REDIS_PASSWORD` | | The password of _Redis_ |
|
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
|
||||||
| `REDIS_PORT` | | The port where _Redis_ is running |
|
| `REDIS_HOST` | | The host where _Redis_ is running |
|
||||||
|
| `REDIS_PASSWORD` | | The password of _Redis_ |
|
||||||
|
| `REDIS_PORT` | | The port where _Redis_ is running |
|
||||||
|
| `REQUEST_TIMEOUT` | `2000` | The timeout of network requests to data providers in milliseconds |
|
||||||
|
|
||||||
### Run with Docker Compose
|
### Run with Docker Compose
|
||||||
|
|
||||||
@ -113,7 +118,7 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
|
|||||||
Run the following command to start the Docker images from [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio):
|
Run the following command to start the Docker images from [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose --env-file ./.env -f docker/docker-compose.yml up -d
|
docker compose --env-file ./.env -f docker/docker-compose.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
#### b. Build and run environment
|
#### b. Build and run environment
|
||||||
@ -121,8 +126,8 @@ docker-compose --env-file ./.env -f docker/docker-compose.yml up -d
|
|||||||
Run the following commands to build and start the Docker images:
|
Run the following commands to build and start the Docker images:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose --env-file ./.env -f docker/docker-compose.build.yml build
|
docker compose --env-file ./.env -f docker/docker-compose.build.yml build
|
||||||
docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
docker compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Setup
|
#### Setup
|
||||||
@ -133,12 +138,12 @@ docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
|||||||
#### Upgrade Version
|
#### Upgrade Version
|
||||||
|
|
||||||
1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
|
1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
|
||||||
1. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d`
|
1. Run the following command to start the new Docker image: `docker compose --env-file ./.env -f docker/docker-compose.yml up -d`
|
||||||
At each start, the container will automatically apply the database schema migrations if needed.
|
At each start, the container will automatically apply the database schema migrations if needed.
|
||||||
|
|
||||||
### Run with _Unraid_ (Community)
|
### Home Server Systems (Community)
|
||||||
|
|
||||||
Please follow the instructions of the Ghostfolio [Unraid Community App](https://unraid.net/community/apps?q=ghostfolio).
|
Ghostfolio is available for various home server systems, including [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio).
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@ -153,9 +158,9 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
|
|||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
1. Run `yarn install`
|
1. Run `yarn install`
|
||||||
1. Run `yarn build:dev` to build the source code including the assets
|
1. Run `docker compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||||
1. Run `docker-compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
|
||||||
1. Run `yarn database:setup` to initialize the database schema
|
1. Run `yarn database:setup` to initialize the database schema
|
||||||
|
1. Run `git config core.hooksPath ./git-hooks/` to setup git hooks
|
||||||
1. Start the server and the client (see [_Development_](#Development))
|
1. Start the server and the client (see [_Development_](#Development))
|
||||||
1. Open http://localhost:4200/en in your browser
|
1. Open http://localhost:4200/en in your browser
|
||||||
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
||||||
@ -164,7 +169,7 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
|
|||||||
|
|
||||||
#### Debug
|
#### Debug
|
||||||
|
|
||||||
Run `yarn watch:server` and click _Launch Program_ in [Visual Studio Code](https://code.visualstudio.com)
|
Run `yarn watch:server` and click _Debug API_ in [Visual Studio Code](https://code.visualstudio.com)
|
||||||
|
|
||||||
#### Serve
|
#### Serve
|
||||||
|
|
||||||
@ -229,18 +234,18 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| ---------- | ------------------- | -------------------------------------------------- |
|
| ---------- | ------------------- | ----------------------------------------------------------------------------- |
|
||||||
| accountId | string (`optional`) | Id of the account |
|
| accountId | string (`optional`) | Id of the account |
|
||||||
| comment | string (`optional`) | Comment of the activity |
|
| comment | string (`optional`) | Comment of the activity |
|
||||||
| currency | string | `CHF` \| `EUR` \| `USD` etc. |
|
| currency | string | `CHF` \| `EUR` \| `USD` etc. |
|
||||||
| dataSource | string | `MANUAL` (for type `ITEM`) \| `YAHOO` |
|
| dataSource | string | `COINGECKO` \| `MANUAL` (for type `ITEM`) \| `YAHOO` |
|
||||||
| date | string | Date in the format `ISO-8601` |
|
| date | string | Date in the format `ISO-8601` |
|
||||||
| fee | number | Fee of the activity |
|
| fee | number | Fee of the activity |
|
||||||
| quantity | number | Quantity of the activity |
|
| quantity | number | Quantity of the activity |
|
||||||
| symbol | string | Symbol of the activity (suitable for `dataSource`) |
|
| symbol | string | Symbol of the activity (suitable for `dataSource`) |
|
||||||
| type | string | `BUY` \| `DIVIDEND` \| `ITEM` \| `SELL` |
|
| type | string | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `ITEM` \| `LIABILITY` \| `SELL` |
|
||||||
| unitPrice | number | Price per unit of the activity |
|
| unitPrice | number | Price per unit of the activity |
|
||||||
|
|
||||||
#### Response
|
#### Response
|
||||||
|
|
||||||
@ -263,18 +268,20 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
|
|||||||
|
|
||||||
## Community Projects
|
## Community Projects
|
||||||
|
|
||||||
- [ghostfolio-cli](https://github.com/DerAndereJohannes/ghostfolio-cli): Command-line interface to access your portfolio
|
Discover a variety of community projects for Ghostfolio: https://github.com/topics/ghostfolio
|
||||||
|
|
||||||
|
Are you building your own project? Add the `ghostfolio` topic to your _GitHub_ repository to get listed as well. [Learn more →](https://docs.github.com/en/articles/classifying-your-repository-with-topics)
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
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 join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_). We would love to hear from you.
|
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://twitter.com/ghostfolio_) on _X_. We would love to hear from you.
|
||||||
|
|
||||||
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
|
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
© 2021 - 2023 [Ghostfolio](https://ghostfol.io)
|
© 2021 - 2024 [Ghostfolio](https://ghostfol.io)
|
||||||
|
|
||||||
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).
|
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).
|
||||||
|
@ -7,14 +7,15 @@
|
|||||||
"generators": {},
|
"generators": {},
|
||||||
"targets": {
|
"targets": {
|
||||||
"build": {
|
"build": {
|
||||||
"executor": "@nrwl/webpack:webpack",
|
"executor": "@nx/webpack:webpack",
|
||||||
"options": {
|
"options": {
|
||||||
"outputPath": "dist/apps/api",
|
"outputPath": "dist/apps/api",
|
||||||
"main": "apps/api/src/main.ts",
|
"main": "apps/api/src/main.ts",
|
||||||
"tsConfig": "apps/api/tsconfig.app.json",
|
"tsConfig": "apps/api/tsconfig.app.json",
|
||||||
"assets": ["apps/api/src/assets"],
|
"assets": ["apps/api/src/assets"],
|
||||||
"target": "node",
|
"target": "node",
|
||||||
"compiler": "tsc"
|
"compiler": "tsc",
|
||||||
|
"webpackConfig": "apps/api/webpack.config.js"
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
@ -33,13 +34,13 @@
|
|||||||
"outputs": ["{options.outputPath}"]
|
"outputs": ["{options.outputPath}"]
|
||||||
},
|
},
|
||||||
"serve": {
|
"serve": {
|
||||||
"executor": "@nx/node:node",
|
"executor": "@nx/js:node",
|
||||||
"options": {
|
"options": {
|
||||||
"buildTarget": "api:build"
|
"buildTarget": "api:build"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"lint": {
|
"lint": {
|
||||||
"executor": "@nrwl/linter:eslint",
|
"executor": "@nx/eslint:lint",
|
||||||
"options": {
|
"options": {
|
||||||
"lintFilePatterns": ["apps/api/**/*.ts"]
|
"lintFilePatterns": ["apps/api/**/*.ts"]
|
||||||
}
|
}
|
||||||
@ -47,8 +48,7 @@
|
|||||||
"test": {
|
"test": {
|
||||||
"executor": "@nx/jest:jest",
|
"executor": "@nx/jest:jest",
|
||||||
"options": {
|
"options": {
|
||||||
"jestConfig": "apps/api/jest.config.ts",
|
"jestConfig": "apps/api/jest.config.ts"
|
||||||
"passWithNoTests": true
|
|
||||||
},
|
},
|
||||||
"outputs": ["{workspaceRoot}/coverage/apps/api"]
|
"outputs": ["{workspaceRoot}/coverage/apps/api"]
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,8 @@
|
|||||||
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { Access } from '@ghostfolio/common/interfaces';
|
import { Access } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
@ -17,7 +20,6 @@ import { AuthGuard } from '@nestjs/passport';
|
|||||||
import { Access as AccessModel } from '@prisma/client';
|
import { Access as AccessModel } from '@prisma/client';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
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';
|
import { CreateAccessDto } from './create-access.dto';
|
||||||
|
|
||||||
@ -25,11 +27,12 @@ import { CreateAccessDto } from './create-access.dto';
|
|||||||
export class AccessController {
|
export class AccessController {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly accessService: AccessService,
|
private readonly accessService: AccessService,
|
||||||
|
private readonly configurationService: ConfigurationService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async getAllAccesses(): Promise<Access[]> {
|
public async getAllAccesses(): Promise<Access[]> {
|
||||||
const accessesWithGranteeUser = await this.accessService.accesses({
|
const accessesWithGranteeUser = await this.accessService.accesses({
|
||||||
include: {
|
include: {
|
||||||
@ -58,13 +61,15 @@ export class AccessController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.createAccess)
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async createAccess(
|
public async createAccess(
|
||||||
@Body() data: CreateAccessDto
|
@Body() data: CreateAccessDto
|
||||||
): Promise<AccessModel> {
|
): Promise<AccessModel> {
|
||||||
if (
|
if (
|
||||||
!hasPermission(this.request.user.permissions, permissions.createAccess)
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
|
this.request.user.subscription.type === 'Basic'
|
||||||
) {
|
) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
@ -72,25 +77,29 @@ export class AccessController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.accessService.createAccess({
|
try {
|
||||||
alias: data.alias || undefined,
|
return await this.accessService.createAccess({
|
||||||
GranteeUser: data.granteeUserId
|
alias: data.alias || undefined,
|
||||||
? { connect: { id: data.granteeUserId } }
|
GranteeUser: data.granteeUserId
|
||||||
: undefined,
|
? { connect: { id: data.granteeUserId } }
|
||||||
User: { connect: { id: this.request.user.id } }
|
: undefined,
|
||||||
});
|
User: { connect: { id: this.request.user.id } }
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||||
|
StatusCodes.BAD_REQUEST
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@HasPermission(permissions.deleteAccess)
|
||||||
public async deleteAccess(@Param('id') id: string): Promise<AccessModule> {
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
public async deleteAccess(@Param('id') id: string): Promise<AccessModel> {
|
||||||
const access = await this.accessService.access({ id });
|
const access = await this.accessService.access({ id });
|
||||||
|
|
||||||
if (
|
if (!access || access.userId !== this.request.user.id) {
|
||||||
!hasPermission(this.request.user.permissions, permissions.deleteAccess) ||
|
|
||||||
!access ||
|
|
||||||
access.userId !== this.request.user.id
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
StatusCodes.FORBIDDEN
|
StatusCodes.FORBIDDEN
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
@ -7,7 +8,7 @@ import { AccessService } from './access.service';
|
|||||||
@Module({
|
@Module({
|
||||||
controllers: [AccessController],
|
controllers: [AccessController],
|
||||||
exports: [AccessService],
|
exports: [AccessService],
|
||||||
imports: [PrismaModule],
|
imports: [ConfigurationModule, PrismaModule],
|
||||||
providers: [AccessService]
|
providers: [AccessService]
|
||||||
})
|
})
|
||||||
export class AccessModule {}
|
export class AccessModule {}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { IsOptional, IsString } from 'class-validator';
|
import { IsOptional, IsString, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
export class CreateAccessDto {
|
export class CreateAccessDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ -6,6 +6,10 @@ export class CreateAccessDto {
|
|||||||
alias?: string;
|
alias?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsUUID()
|
||||||
granteeUserId?: string;
|
granteeUserId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
type?: 'PUBLIC';
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,48 @@
|
|||||||
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
HttpException,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
UseGuards
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { REQUEST } from '@nestjs/core';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { AccountBalance } from '@prisma/client';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
|
import { AccountBalanceService } from './account-balance.service';
|
||||||
|
|
||||||
|
@Controller('account-balance')
|
||||||
|
export class AccountBalanceController {
|
||||||
|
public constructor(
|
||||||
|
private readonly accountBalanceService: AccountBalanceService,
|
||||||
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@HasPermission(permissions.deleteAccountBalance)
|
||||||
|
@Delete(':id')
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
public async deleteAccountBalance(
|
||||||
|
@Param('id') id: string
|
||||||
|
): Promise<AccountBalance> {
|
||||||
|
const accountBalance = await this.accountBalanceService.accountBalance({
|
||||||
|
id
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!accountBalance || accountBalance.userId !== this.request.user.id) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.accountBalanceService.deleteAccountBalance({
|
||||||
|
id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
14
apps/api/src/app/account-balance/account-balance.module.ts
Normal file
14
apps/api/src/app/account-balance/account-balance.module.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { AccountBalanceController } from './account-balance.controller';
|
||||||
|
import { AccountBalanceService } from './account-balance.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [AccountBalanceController],
|
||||||
|
exports: [AccountBalanceService],
|
||||||
|
imports: [ExchangeRateDataModule, PrismaModule],
|
||||||
|
providers: [AccountBalanceService]
|
||||||
|
})
|
||||||
|
export class AccountBalanceModule {}
|
91
apps/api/src/app/account-balance/account-balance.service.ts
Normal file
91
apps/api/src/app/account-balance/account-balance.service.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
|
import { AccountBalancesResponse, Filter } from '@ghostfolio/common/interfaces';
|
||||||
|
import { UserWithSettings } from '@ghostfolio/common/types';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AccountBalance, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AccountBalanceService {
|
||||||
|
public constructor(
|
||||||
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
|
private readonly prismaService: PrismaService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async accountBalance(
|
||||||
|
accountBalanceWhereInput: Prisma.AccountBalanceWhereInput
|
||||||
|
): Promise<AccountBalance | null> {
|
||||||
|
return this.prismaService.accountBalance.findFirst({
|
||||||
|
include: {
|
||||||
|
Account: true
|
||||||
|
},
|
||||||
|
where: accountBalanceWhereInput
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createAccountBalance(
|
||||||
|
data: Prisma.AccountBalanceCreateInput
|
||||||
|
): Promise<AccountBalance> {
|
||||||
|
return this.prismaService.accountBalance.create({
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteAccountBalance(
|
||||||
|
where: Prisma.AccountBalanceWhereUniqueInput
|
||||||
|
): Promise<AccountBalance> {
|
||||||
|
return this.prismaService.accountBalance.delete({
|
||||||
|
where
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAccountBalances({
|
||||||
|
filters,
|
||||||
|
user,
|
||||||
|
withExcludedAccounts
|
||||||
|
}: {
|
||||||
|
filters?: Filter[];
|
||||||
|
user: UserWithSettings;
|
||||||
|
withExcludedAccounts?: boolean;
|
||||||
|
}): Promise<AccountBalancesResponse> {
|
||||||
|
const where: Prisma.AccountBalanceWhereInput = { userId: user.id };
|
||||||
|
|
||||||
|
const accountFilter = filters?.find(({ type }) => {
|
||||||
|
return type === 'ACCOUNT';
|
||||||
|
});
|
||||||
|
|
||||||
|
if (accountFilter) {
|
||||||
|
where.accountId = accountFilter.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (withExcludedAccounts === false) {
|
||||||
|
where.Account = { isExcluded: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const balances = await this.prismaService.accountBalance.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: {
|
||||||
|
date: 'asc'
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
Account: true,
|
||||||
|
date: true,
|
||||||
|
id: true,
|
||||||
|
value: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
balances: balances.map((balance) => {
|
||||||
|
return {
|
||||||
|
...balance,
|
||||||
|
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
|
balance.value,
|
||||||
|
balance.Account.currency,
|
||||||
|
user.Settings.settings.baseCurrency
|
||||||
|
)
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -1,9 +1,15 @@
|
|||||||
|
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
||||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||||
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||||
import { Accounts } from '@ghostfolio/common/interfaces';
|
import {
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
AccountBalancesResponse,
|
||||||
|
Accounts
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
import type {
|
import type {
|
||||||
AccountWithValue,
|
AccountWithValue,
|
||||||
RequestWithUser
|
RequestWithUser
|
||||||
@ -29,11 +35,13 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
|||||||
|
|
||||||
import { AccountService } from './account.service';
|
import { AccountService } from './account.service';
|
||||||
import { CreateAccountDto } from './create-account.dto';
|
import { CreateAccountDto } from './create-account.dto';
|
||||||
|
import { TransferBalanceDto } from './transfer-balance.dto';
|
||||||
import { UpdateAccountDto } from './update-account.dto';
|
import { UpdateAccountDto } from './update-account.dto';
|
||||||
|
|
||||||
@Controller('account')
|
@Controller('account')
|
||||||
export class AccountController {
|
export class AccountController {
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly accountBalanceService: AccountBalanceService,
|
||||||
private readonly accountService: AccountService,
|
private readonly accountService: AccountService,
|
||||||
private readonly impersonationService: ImpersonationService,
|
private readonly impersonationService: ImpersonationService,
|
||||||
private readonly portfolioService: PortfolioService,
|
private readonly portfolioService: PortfolioService,
|
||||||
@ -41,17 +49,9 @@ export class AccountController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@HasPermission(permissions.deleteAccount)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async deleteAccount(@Param('id') id: string): Promise<AccountModel> {
|
public async deleteAccount(@Param('id') id: string): Promise<AccountModel> {
|
||||||
if (
|
|
||||||
!hasPermission(this.request.user.permissions, permissions.deleteAccount)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const account = await this.accountService.accountWithOrders(
|
const account = await this.accountService.accountWithOrders(
|
||||||
{
|
{
|
||||||
id_userId: {
|
id_userId: {
|
||||||
@ -81,7 +81,7 @@ export class AccountController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
public async getAllAccounts(
|
public async getAllAccounts(
|
||||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId
|
||||||
@ -96,7 +96,7 @@ export class AccountController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
public async getAccountById(
|
public async getAccountById(
|
||||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||||
@ -115,20 +115,24 @@ export class AccountController {
|
|||||||
return accountsWithAggregations.accounts[0];
|
return accountsWithAggregations.accounts[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id/balances')
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
|
public async getAccountBalancesById(
|
||||||
|
@Param('id') id: string
|
||||||
|
): Promise<AccountBalancesResponse> {
|
||||||
|
return this.accountBalanceService.getAccountBalances({
|
||||||
|
filters: [{ id, type: 'ACCOUNT' }],
|
||||||
|
user: this.request.user
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.createAccount)
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async createAccount(
|
public async createAccount(
|
||||||
@Body() data: CreateAccountDto
|
@Body() data: CreateAccountDto
|
||||||
): Promise<AccountModel> {
|
): Promise<AccountModel> {
|
||||||
if (
|
|
||||||
!hasPermission(this.request.user.permissions, permissions.createAccount)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.platformId) {
|
if (data.platformId) {
|
||||||
const platformId = data.platformId;
|
const platformId = data.platformId;
|
||||||
delete data.platformId;
|
delete data.platformId;
|
||||||
@ -154,18 +158,64 @@ export class AccountController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id')
|
@HasPermission(permissions.updateAccount)
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@Post('transfer-balance')
|
||||||
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
if (
|
public async transferAccountBalance(
|
||||||
!hasPermission(this.request.user.permissions, permissions.updateAccount)
|
@Body() { accountIdFrom, accountIdTo, balance }: TransferBalanceDto
|
||||||
) {
|
) {
|
||||||
|
const accountsOfUser = await this.accountService.getAccounts(
|
||||||
|
this.request.user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
const accountFrom = accountsOfUser.find(({ id }) => {
|
||||||
|
return id === accountIdFrom;
|
||||||
|
});
|
||||||
|
|
||||||
|
const accountTo = accountsOfUser.find(({ id }) => {
|
||||||
|
return id === accountIdTo;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!accountFrom || !accountTo) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
StatusCodes.FORBIDDEN
|
StatusCodes.NOT_FOUND
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (accountFrom.id === accountTo.id) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||||
|
StatusCodes.BAD_REQUEST
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accountFrom.balance < balance) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||||
|
StatusCodes.BAD_REQUEST
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.accountService.updateAccountBalance({
|
||||||
|
accountId: accountFrom.id,
|
||||||
|
amount: -balance,
|
||||||
|
currency: accountFrom.currency,
|
||||||
|
userId: this.request.user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.accountService.updateAccountBalance({
|
||||||
|
accountId: accountTo.id,
|
||||||
|
amount: balance,
|
||||||
|
currency: accountFrom.currency,
|
||||||
|
userId: this.request.user.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.updateAccount)
|
||||||
|
@Put(':id')
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {
|
||||||
const originalAccount = await this.accountService.account({
|
const originalAccount = await this.accountService.account({
|
||||||
id_userId: {
|
id_userId: {
|
||||||
id,
|
id,
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
|
import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module';
|
||||||
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
import { AccountBalanceModule } from '@ghostfolio/api/services/account-balance/account-balance.module';
|
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
|
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { Filter } from '@ghostfolio/common/interfaces';
|
import { Filter } from '@ghostfolio/common/interfaces';
|
||||||
@ -109,7 +109,7 @@ export class AccountService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAccounts(aUserId: string) {
|
public async getAccounts(aUserId: string): Promise<Account[]> {
|
||||||
const accounts = await this.accounts({
|
const accounts = await this.accounts({
|
||||||
include: { Order: true, Platform: true },
|
include: { Order: true, Platform: true },
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' },
|
||||||
@ -218,13 +218,13 @@ export class AccountService {
|
|||||||
accountId,
|
accountId,
|
||||||
amount,
|
amount,
|
||||||
currency,
|
currency,
|
||||||
date,
|
date = new Date(),
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
date: Date;
|
date?: Date;
|
||||||
userId: string;
|
userId: string;
|
||||||
}) {
|
}) {
|
||||||
const { balance, currency: currencyOfAccount } = await this.account({
|
const { balance, currency: currencyOfAccount } = await this.account({
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import { AccountType } from '@prisma/client';
|
|
||||||
import { Transform, TransformFnParams } from 'class-transformer';
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
@ -10,9 +9,6 @@ import {
|
|||||||
import { isString } from 'lodash';
|
import { isString } from 'lodash';
|
||||||
|
|
||||||
export class CreateAccountDto {
|
export class CreateAccountDto {
|
||||||
@IsString()
|
|
||||||
accountType: AccountType;
|
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
balance: number;
|
balance: number;
|
||||||
|
|
||||||
|
13
apps/api/src/app/account/transfer-balance.dto.ts
Normal file
13
apps/api/src/app/account/transfer-balance.dto.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { IsNumber, IsPositive, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class TransferBalanceDto {
|
||||||
|
@IsString()
|
||||||
|
accountIdFrom: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
accountIdTo: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@IsPositive()
|
||||||
|
balance: number;
|
||||||
|
}
|
@ -1,4 +1,3 @@
|
|||||||
import { AccountType } from '@prisma/client';
|
|
||||||
import { Transform, TransformFnParams } from 'class-transformer';
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
@ -10,9 +9,6 @@ import {
|
|||||||
import { isString } from 'lodash';
|
import { isString } from 'lodash';
|
||||||
|
|
||||||
export class UpdateAccountDto {
|
export class UpdateAccountDto {
|
||||||
@IsString()
|
|
||||||
accountType: AccountType;
|
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
balance: number;
|
balance: number;
|
||||||
|
|
||||||
|
@ -1,20 +1,26 @@
|
|||||||
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
|
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||||
|
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
||||||
import {
|
import {
|
||||||
DEFAULT_PAGE_SIZE,
|
|
||||||
GATHER_ASSET_PROFILE_PROCESS,
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
|
import {
|
||||||
|
getAssetProfileIdentifier,
|
||||||
|
resetHours
|
||||||
|
} from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
AdminData,
|
AdminData,
|
||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
AdminMarketDataDetails,
|
AdminMarketDataDetails,
|
||||||
EnhancedSymbolProfile,
|
EnhancedSymbolProfile
|
||||||
Filter
|
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
import type {
|
import type {
|
||||||
MarketDataPreset,
|
MarketDataPreset,
|
||||||
RequestWithUser
|
RequestWithUser
|
||||||
@ -26,6 +32,7 @@ import {
|
|||||||
Get,
|
Get,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
|
Logger,
|
||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
@ -37,73 +44,43 @@ import {
|
|||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { DataSource, MarketData, Prisma, SymbolProfile } from '@prisma/client';
|
import { DataSource, MarketData, Prisma, SymbolProfile } from '@prisma/client';
|
||||||
import { isDate } from 'date-fns';
|
import { isDate, parseISO } 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';
|
||||||
import { UpdateAssetProfileDto } from './update-asset-profile.dto';
|
import { UpdateAssetProfileDto } from './update-asset-profile.dto';
|
||||||
|
import { UpdateBulkMarketDataDto } from './update-bulk-market-data.dto';
|
||||||
import { UpdateMarketDataDto } from './update-market-data.dto';
|
import { UpdateMarketDataDto } from './update-market-data.dto';
|
||||||
|
|
||||||
@Controller('admin')
|
@Controller('admin')
|
||||||
export class AdminController {
|
export class AdminController {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly adminService: AdminService,
|
private readonly adminService: AdminService,
|
||||||
|
private readonly apiService: ApiService,
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
|
private readonly manualService: ManualService,
|
||||||
private readonly marketDataService: MarketDataService,
|
private readonly marketDataService: MarketDataService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@HasPermission(permissions.accessAdminControl)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async getAdminData(): Promise<AdminData> {
|
public async getAdminData(): Promise<AdminData> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.adminService.get();
|
return this.adminService.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.accessAdminControl)
|
||||||
@Post('gather')
|
@Post('gather')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async gather7Days(): Promise<void> {
|
public async gather7Days(): Promise<void> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.dataGatheringService.gather7Days();
|
this.dataGatheringService.gather7Days();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.accessAdminControl)
|
||||||
@Post('gather/max')
|
@Post('gather/max')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async gatherMax(): Promise<void> {
|
public async gatherMax(): Promise<void> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||||
|
|
||||||
await this.dataGatheringService.addJobsToQueue(
|
await this.dataGatheringService.addJobsToQueue(
|
||||||
@ -116,7 +93,7 @@ export class AdminController {
|
|||||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||||
opts: {
|
opts: {
|
||||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||||
jobId: `${dataSource}-${symbol}`
|
jobId: getAssetProfileIdentifier({ dataSource, symbol })
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@ -125,21 +102,10 @@ export class AdminController {
|
|||||||
this.dataGatheringService.gatherMax();
|
this.dataGatheringService.gatherMax();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.accessAdminControl)
|
||||||
@Post('gather/profile-data')
|
@Post('gather/profile-data')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async gatherProfileData(): Promise<void> {
|
public async gatherProfileData(): Promise<void> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||||
|
|
||||||
await this.dataGatheringService.addJobsToQueue(
|
await this.dataGatheringService.addJobsToQueue(
|
||||||
@ -152,31 +118,20 @@ export class AdminController {
|
|||||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||||
opts: {
|
opts: {
|
||||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||||
jobId: `${dataSource}-${symbol}`
|
jobId: getAssetProfileIdentifier({ dataSource, symbol })
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.accessAdminControl)
|
||||||
@Post('gather/profile-data/:dataSource/:symbol')
|
@Post('gather/profile-data/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async gatherProfileDataForSymbol(
|
public async gatherProfileDataForSymbol(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.dataGatheringService.addJobToQueue({
|
await this.dataGatheringService.addJobToQueue({
|
||||||
data: {
|
data: {
|
||||||
dataSource,
|
dataSource,
|
||||||
@ -185,54 +140,32 @@ export class AdminController {
|
|||||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||||
opts: {
|
opts: {
|
||||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||||
jobId: `${dataSource}-${symbol}`
|
jobId: getAssetProfileIdentifier({ dataSource, symbol })
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('gather/:dataSource/:symbol')
|
@Post('gather/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
@HasPermission(permissions.accessAdminControl)
|
||||||
public async gatherSymbol(
|
public async gatherSymbol(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.dataGatheringService.gatherSymbol({ dataSource, symbol });
|
this.dataGatheringService.gatherSymbol({ dataSource, symbol });
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.accessAdminControl)
|
||||||
@Post('gather/:dataSource/:symbol/:dateString')
|
@Post('gather/:dataSource/:symbol/:dateString')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async gatherSymbolForDate(
|
public async gatherSymbolForDate(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('dateString') dateString: string,
|
@Param('dateString') dateString: string,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
): Promise<MarketData> {
|
): Promise<MarketData> {
|
||||||
if (
|
const date = parseISO(dateString);
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = new Date(dateString);
|
|
||||||
|
|
||||||
if (!isDate(date)) {
|
if (!isDate(date)) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
@ -249,37 +182,21 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('market-data')
|
@Get('market-data')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@HasPermission(permissions.accessAdminControl)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async getMarketData(
|
public async getMarketData(
|
||||||
@Query('assetSubClasses') filterByAssetSubClasses?: string,
|
@Query('assetSubClasses') filterByAssetSubClasses?: string,
|
||||||
@Query('presetId') presetId?: MarketDataPreset,
|
@Query('presetId') presetId?: MarketDataPreset,
|
||||||
|
@Query('query') filterBySearchQuery?: string,
|
||||||
@Query('skip') skip?: number,
|
@Query('skip') skip?: number,
|
||||||
@Query('sortColumn') sortColumn?: string,
|
@Query('sortColumn') sortColumn?: string,
|
||||||
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
|
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
|
||||||
@Query('take') take?: number
|
@Query('take') take?: number
|
||||||
): Promise<AdminMarketData> {
|
): Promise<AdminMarketData> {
|
||||||
if (
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
!hasPermission(
|
filterByAssetSubClasses,
|
||||||
this.request.user.permissions,
|
filterBySearchQuery
|
||||||
permissions.accessAdminControl
|
});
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
|
|
||||||
|
|
||||||
const filters: Filter[] = [
|
|
||||||
...assetSubClasses.map((assetSubClass) => {
|
|
||||||
return <Filter>{
|
|
||||||
id: assetSubClass,
|
|
||||||
type: 'ASSET_SUB_CLASS'
|
|
||||||
};
|
|
||||||
})
|
|
||||||
];
|
|
||||||
|
|
||||||
return this.adminService.getMarketData({
|
return this.adminService.getMarketData({
|
||||||
filters,
|
filters,
|
||||||
@ -292,47 +209,75 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('market-data/:dataSource/:symbol')
|
@Get('market-data/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@HasPermission(permissions.accessAdminControl)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async getMarketDataBySymbol(
|
public async getMarketDataBySymbol(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
): Promise<AdminMarketDataDetails> {
|
): Promise<AdminMarketDataDetails> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
|
return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.accessAdminControl)
|
||||||
|
@Post('market-data/:dataSource/:symbol/test')
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
public async testMarketData(
|
||||||
|
@Body() data: { scraperConfiguration: string },
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
): Promise<{ price: number }> {
|
||||||
|
try {
|
||||||
|
const scraperConfiguration = JSON.parse(data.scraperConfiguration);
|
||||||
|
const price = await this.manualService.test(scraperConfiguration);
|
||||||
|
|
||||||
|
if (price) {
|
||||||
|
return { price };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Could not parse the current market price');
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error);
|
||||||
|
|
||||||
|
throw new HttpException(error.message, StatusCodes.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.accessAdminControl)
|
||||||
|
@Post('market-data/:dataSource/:symbol')
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
public async updateMarketData(
|
||||||
|
@Body() data: UpdateBulkMarketDataDto,
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
) {
|
||||||
|
const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map(
|
||||||
|
({ date, marketPrice }) => ({
|
||||||
|
dataSource,
|
||||||
|
marketPrice,
|
||||||
|
symbol,
|
||||||
|
date: resetHours(parseISO(date)),
|
||||||
|
state: 'CLOSE'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.marketDataService.updateMany({
|
||||||
|
data: dataBulkUpdate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
|
@HasPermission(permissions.accessAdminControl)
|
||||||
@Put('market-data/:dataSource/:symbol/:dateString')
|
@Put('market-data/:dataSource/:symbol/:dateString')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async update(
|
public async update(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('dateString') dateString: string,
|
@Param('dateString') dateString: string,
|
||||||
@Param('symbol') symbol: string,
|
@Param('symbol') symbol: string,
|
||||||
@Body() data: UpdateMarketDataDto
|
@Body() data: UpdateMarketDataDto
|
||||||
) {
|
) {
|
||||||
if (
|
const date = parseISO(dateString);
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = new Date(dateString);
|
|
||||||
|
|
||||||
return this.marketDataService.updateMarketData({
|
return this.marketDataService.updateMarketData({
|
||||||
data: { marketPrice: data.marketPrice, state: 'CLOSE' },
|
data: { marketPrice: data.marketPrice, state: 'CLOSE' },
|
||||||
@ -346,68 +291,39 @@ export class AdminController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.accessAdminControl)
|
||||||
@Post('profile-data/:dataSource/:symbol')
|
@Post('profile-data/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
public async addProfileData(
|
public async addProfileData(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
): Promise<SymbolProfile | never> {
|
): Promise<SymbolProfile | never> {
|
||||||
if (
|
return this.adminService.addAssetProfile({
|
||||||
!hasPermission(
|
dataSource,
|
||||||
this.request.user.permissions,
|
symbol,
|
||||||
permissions.accessAdminControl
|
currency: this.request.user.Settings.settings.baseCurrency
|
||||||
)
|
});
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.adminService.addAssetProfile({ dataSource, symbol });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('profile-data/:dataSource/:symbol')
|
@Delete('profile-data/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@HasPermission(permissions.accessAdminControl)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async deleteProfileData(
|
public async deleteProfileData(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.adminService.deleteProfileData({ dataSource, symbol });
|
return this.adminService.deleteProfileData({ dataSource, symbol });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.accessAdminControl)
|
||||||
@Patch('profile-data/:dataSource/:symbol')
|
@Patch('profile-data/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async patchAssetProfileData(
|
public async patchAssetProfileData(
|
||||||
@Body() assetProfileData: UpdateAssetProfileDto,
|
@Body() assetProfileData: UpdateAssetProfileDto,
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
): Promise<EnhancedSymbolProfile> {
|
): Promise<EnhancedSymbolProfile> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.adminService.patchAssetProfileData({
|
return this.adminService.patchAssetProfileData({
|
||||||
...assetProfileData,
|
...assetProfileData,
|
||||||
dataSource,
|
dataSource,
|
||||||
@ -415,24 +331,13 @@ export class AdminController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.accessAdminControl)
|
||||||
@Put('settings/:key')
|
@Put('settings/:key')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async updateProperty(
|
public async updateProperty(
|
||||||
@Param('key') key: string,
|
@Param('key') key: string,
|
||||||
@Body() data: PropertyDto
|
@Body() data: PropertyDto
|
||||||
) {
|
) {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await this.adminService.putSetting(key, data.value);
|
return await this.adminService.putSetting(key, data.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||||
|
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
@ -15,6 +16,7 @@ import { QueueModule } from './queue/queue.module';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
ApiModule,
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||||
|
import { environment } from '@ghostfolio/api/environments/environment';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
@ -7,26 +8,33 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
|||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
import {
|
import {
|
||||||
DEFAULT_PAGE_SIZE,
|
DEFAULT_CURRENCY,
|
||||||
PROPERTY_CURRENCIES
|
PROPERTY_CURRENCIES,
|
||||||
|
PROPERTY_IS_READ_ONLY_MODE,
|
||||||
|
PROPERTY_IS_USER_SIGNUP_ENABLED
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
AdminData,
|
AdminData,
|
||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
AdminMarketDataDetails,
|
AdminMarketDataDetails,
|
||||||
|
AdminMarketDataItem,
|
||||||
Filter,
|
Filter,
|
||||||
UniqueAsset
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { MarketDataPreset } from '@ghostfolio/common/types';
|
import { MarketDataPreset } from '@ghostfolio/common/types';
|
||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
import { AssetSubClass, Prisma, Property, SymbolProfile } from '@prisma/client';
|
import {
|
||||||
|
AssetSubClass,
|
||||||
|
DataSource,
|
||||||
|
Prisma,
|
||||||
|
Property,
|
||||||
|
SymbolProfile
|
||||||
|
} from '@prisma/client';
|
||||||
import { differenceInDays } from 'date-fns';
|
import { differenceInDays } from 'date-fns';
|
||||||
import { groupBy } from 'lodash';
|
import { groupBy } from 'lodash';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminService {
|
export class AdminService {
|
||||||
private baseCurrency: string;
|
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
@ -36,15 +44,22 @@ export class AdminService {
|
|||||||
private readonly propertyService: PropertyService,
|
private readonly propertyService: PropertyService,
|
||||||
private readonly subscriptionService: SubscriptionService,
|
private readonly subscriptionService: SubscriptionService,
|
||||||
private readonly symbolProfileService: SymbolProfileService
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
) {
|
) {}
|
||||||
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
|
||||||
}
|
|
||||||
|
|
||||||
public async addAssetProfile({
|
public async addAssetProfile({
|
||||||
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
}: UniqueAsset): Promise<SymbolProfile | never> {
|
}: UniqueAsset & { currency?: string }): Promise<SymbolProfile | never> {
|
||||||
try {
|
try {
|
||||||
|
if (dataSource === 'MANUAL') {
|
||||||
|
return this.symbolProfileService.add({
|
||||||
|
currency,
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const assetProfiles = await this.dataProviderService.getAssetProfiles([
|
const assetProfiles = await this.dataProviderService.getAssetProfiles([
|
||||||
{ dataSource, symbol }
|
{ dataSource, symbol }
|
||||||
]);
|
]);
|
||||||
@ -82,15 +97,23 @@ export class AdminService {
|
|||||||
exchangeRates: this.exchangeRateDataService
|
exchangeRates: this.exchangeRateDataService
|
||||||
.getCurrencies()
|
.getCurrencies()
|
||||||
.filter((currency) => {
|
.filter((currency) => {
|
||||||
return currency !== this.baseCurrency;
|
return currency !== DEFAULT_CURRENCY;
|
||||||
})
|
})
|
||||||
.map((currency) => {
|
.map((currency) => {
|
||||||
|
const label1 = DEFAULT_CURRENCY;
|
||||||
|
const label2 = currency;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label1: this.baseCurrency,
|
label1,
|
||||||
label2: currency,
|
label2,
|
||||||
|
dataSource:
|
||||||
|
DataSource[
|
||||||
|
this.configurationService.get('DATA_SOURCE_EXCHANGE_RATES')
|
||||||
|
],
|
||||||
|
symbol: `${label1}${label2}`,
|
||||||
value: this.exchangeRateDataService.toCurrency(
|
value: this.exchangeRateDataService.toCurrency(
|
||||||
1,
|
1,
|
||||||
this.baseCurrency,
|
DEFAULT_CURRENCY,
|
||||||
currency
|
currency
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
@ -98,7 +121,8 @@ export class AdminService {
|
|||||||
settings: await this.propertyService.get(),
|
settings: await this.propertyService.get(),
|
||||||
transactionCount: await this.prismaService.order.count(),
|
transactionCount: await this.prismaService.order.count(),
|
||||||
userCount: await this.prismaService.user.count(),
|
userCount: await this.prismaService.user.count(),
|
||||||
users: await this.getUsersWithAnalytics()
|
users: await this.getUsersWithAnalytics(),
|
||||||
|
version: environment.version
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -121,17 +145,23 @@ export class AdminService {
|
|||||||
[{ symbol: 'asc' }];
|
[{ symbol: 'asc' }];
|
||||||
const where: Prisma.SymbolProfileWhereInput = {};
|
const where: Prisma.SymbolProfileWhereInput = {};
|
||||||
|
|
||||||
if (
|
if (presetId === 'CURRENCIES') {
|
||||||
|
return this.getMarketDataForCurrencies();
|
||||||
|
} else if (
|
||||||
presetId === 'ETF_WITHOUT_COUNTRIES' ||
|
presetId === 'ETF_WITHOUT_COUNTRIES' ||
|
||||||
presetId === 'ETF_WITHOUT_SECTORS'
|
presetId === 'ETF_WITHOUT_SECTORS'
|
||||||
) {
|
) {
|
||||||
filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }];
|
filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const searchQuery = filters.find(({ type }) => {
|
||||||
|
return type === 'SEARCH_QUERY';
|
||||||
|
})?.id;
|
||||||
|
|
||||||
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
|
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
|
||||||
filters,
|
filters,
|
||||||
(filter) => {
|
({ type }) => {
|
||||||
return filter.type;
|
return type;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -144,6 +174,15 @@ export class AdminService {
|
|||||||
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
|
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (searchQuery) {
|
||||||
|
where.OR = [
|
||||||
|
{ id: { mode: 'insensitive', startsWith: searchQuery } },
|
||||||
|
{ isin: { mode: 'insensitive', startsWith: searchQuery } },
|
||||||
|
{ name: { mode: 'insensitive', startsWith: searchQuery } },
|
||||||
|
{ symbol: { mode: 'insensitive', startsWith: searchQuery } }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
if (sortColumn) {
|
if (sortColumn) {
|
||||||
orderBy = [{ [sortColumn]: sortDirection }];
|
orderBy = [{ [sortColumn]: sortDirection }];
|
||||||
|
|
||||||
@ -170,7 +209,9 @@ export class AdminService {
|
|||||||
assetSubClass: true,
|
assetSubClass: true,
|
||||||
comment: true,
|
comment: true,
|
||||||
countries: true,
|
countries: true,
|
||||||
|
currency: true,
|
||||||
dataSource: true,
|
dataSource: true,
|
||||||
|
name: true,
|
||||||
Order: {
|
Order: {
|
||||||
orderBy: [{ date: 'asc' }],
|
orderBy: [{ date: 'asc' }],
|
||||||
select: { date: true },
|
select: { date: true },
|
||||||
@ -191,7 +232,9 @@ export class AdminService {
|
|||||||
assetSubClass,
|
assetSubClass,
|
||||||
comment,
|
comment,
|
||||||
countries,
|
countries,
|
||||||
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
name,
|
||||||
Order,
|
Order,
|
||||||
sectors,
|
sectors,
|
||||||
symbol
|
symbol
|
||||||
@ -210,8 +253,10 @@ export class AdminService {
|
|||||||
assetClass,
|
assetClass,
|
||||||
assetSubClass,
|
assetSubClass,
|
||||||
comment,
|
comment,
|
||||||
|
currency,
|
||||||
countriesCount,
|
countriesCount,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
name,
|
||||||
symbol,
|
symbol,
|
||||||
marketDataItemCount,
|
marketDataItemCount,
|
||||||
sectorsCount,
|
sectorsCount,
|
||||||
@ -273,15 +318,23 @@ export class AdminService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async patchAssetProfileData({
|
public async patchAssetProfileData({
|
||||||
|
assetClass,
|
||||||
|
assetSubClass,
|
||||||
comment,
|
comment,
|
||||||
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
name,
|
||||||
scraperConfiguration,
|
scraperConfiguration,
|
||||||
symbol,
|
symbol,
|
||||||
symbolMapping
|
symbolMapping
|
||||||
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
||||||
await this.symbolProfileService.updateSymbolProfile({
|
await this.symbolProfileService.updateSymbolProfile({
|
||||||
|
assetClass,
|
||||||
|
assetSubClass,
|
||||||
comment,
|
comment,
|
||||||
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
|
name,
|
||||||
scraperConfiguration,
|
scraperConfiguration,
|
||||||
symbol,
|
symbol,
|
||||||
symbolMapping
|
symbolMapping
|
||||||
@ -306,13 +359,47 @@ export class AdminService {
|
|||||||
response = await this.propertyService.delete({ key });
|
response = await this.propertyService.delete({ key });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === PROPERTY_CURRENCIES) {
|
if (key === PROPERTY_IS_READ_ONLY_MODE && value === 'true') {
|
||||||
|
await this.putSetting(PROPERTY_IS_USER_SIGNUP_ENABLED, 'false');
|
||||||
|
} else if (key === PROPERTY_CURRENCIES) {
|
||||||
await this.exchangeRateDataService.initialize();
|
await this.exchangeRateDataService.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getMarketDataForCurrencies(): Promise<AdminMarketData> {
|
||||||
|
const marketDataItems = await this.prismaService.marketData.groupBy({
|
||||||
|
_count: true,
|
||||||
|
by: ['dataSource', 'symbol']
|
||||||
|
});
|
||||||
|
|
||||||
|
const marketData: AdminMarketDataItem[] = this.exchangeRateDataService
|
||||||
|
.getCurrencyPairs()
|
||||||
|
.map(({ dataSource, symbol }) => {
|
||||||
|
const marketDataItemCount =
|
||||||
|
marketDataItems.find((marketDataItem) => {
|
||||||
|
return (
|
||||||
|
marketDataItem.dataSource === dataSource &&
|
||||||
|
marketDataItem.symbol === symbol
|
||||||
|
);
|
||||||
|
})?._count ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
dataSource,
|
||||||
|
marketDataItemCount,
|
||||||
|
symbol,
|
||||||
|
assetClass: 'CASH',
|
||||||
|
countriesCount: 0,
|
||||||
|
currency: symbol.replace(DEFAULT_CURRENCY, ''),
|
||||||
|
name: symbol,
|
||||||
|
sectorsCount: 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { marketData, count: marketData.length };
|
||||||
|
}
|
||||||
|
|
||||||
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
|
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
|
||||||
let orderBy: any = {
|
let orderBy: any = {
|
||||||
createdAt: 'desc'
|
createdAt: 'desc'
|
||||||
|
@ -1,87 +1,48 @@
|
|||||||
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
HttpException,
|
|
||||||
Inject,
|
|
||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
UseGuards
|
UseGuards
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { JobStatus } from 'bull';
|
import { JobStatus } from 'bull';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
|
||||||
|
|
||||||
import { QueueService } from './queue.service';
|
import { QueueService } from './queue.service';
|
||||||
|
|
||||||
@Controller('admin/queue')
|
@Controller('admin/queue')
|
||||||
export class QueueController {
|
export class QueueController {
|
||||||
public constructor(
|
public constructor(private readonly queueService: QueueService) {}
|
||||||
private readonly queueService: QueueService,
|
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Delete('job')
|
@Delete('job')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@HasPermission(permissions.accessAdminControl)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async deleteJobs(
|
public async deleteJobs(
|
||||||
@Query('status') filterByStatus?: string
|
@Query('status') filterByStatus?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
|
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
|
||||||
return this.queueService.deleteJobs({ status });
|
return this.queueService.deleteJobs({ status });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('job')
|
@Get('job')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@HasPermission(permissions.accessAdminControl)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async getJobs(
|
public async getJobs(
|
||||||
@Query('status') filterByStatus?: string
|
@Query('status') filterByStatus?: string
|
||||||
): Promise<AdminJobs> {
|
): Promise<AdminJobs> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
|
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
|
||||||
return this.queueService.getJobs({ status });
|
return this.queueService.getJobs({ status });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('job/:id')
|
@Delete('job/:id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@HasPermission(permissions.accessAdminControl)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async deleteJob(@Param('id') id: string): Promise<void> {
|
public async deleteJob(@Param('id') id: string): Promise<void> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.queueService.deleteJob(id);
|
return this.queueService.deleteJob(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,27 @@
|
|||||||
import { Prisma } from '@prisma/client';
|
import { AssetClass, AssetSubClass, Prisma } from '@prisma/client';
|
||||||
import { IsObject, IsOptional, IsString } from 'class-validator';
|
import { IsEnum, IsObject, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class UpdateAssetProfileDto {
|
export class UpdateAssetProfileDto {
|
||||||
|
@IsEnum(AssetClass, { each: true })
|
||||||
|
@IsOptional()
|
||||||
|
assetClass?: AssetClass;
|
||||||
|
|
||||||
|
@IsEnum(AssetSubClass, { each: true })
|
||||||
|
@IsOptional()
|
||||||
|
assetSubClass?: AssetSubClass;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
currency?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
name?: string;
|
||||||
|
|
||||||
@IsObject()
|
@IsObject()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
scraperConfiguration?: Prisma.InputJsonObject;
|
scraperConfiguration?: Prisma.InputJsonObject;
|
||||||
|
11
apps/api/src/app/admin/update-bulk-market-data.dto.ts
Normal file
11
apps/api/src/app/admin/update-bulk-market-data.dto.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { ArrayNotEmpty, IsArray, isNotEmptyObject } from 'class-validator';
|
||||||
|
|
||||||
|
import { UpdateMarketDataDto } from './update-market-data.dto';
|
||||||
|
|
||||||
|
export class UpdateBulkMarketDataDto {
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
@IsArray()
|
||||||
|
@Type(() => UpdateMarketDataDto)
|
||||||
|
marketData: UpdateMarketDataDto[];
|
||||||
|
}
|
@ -1,6 +1,10 @@
|
|||||||
import { IsNumber } from 'class-validator';
|
import { IsISO8601, IsNumber, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
export class UpdateMarketDataDto {
|
export class UpdateMarketDataDto {
|
||||||
|
@IsISO8601()
|
||||||
|
@IsOptional()
|
||||||
|
date?: string;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
marketPrice: number;
|
marketPrice: number;
|
||||||
}
|
}
|
||||||
|
@ -7,11 +7,16 @@ import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-
|
|||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
|
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
|
||||||
|
import {
|
||||||
|
DEFAULT_LANGUAGE_CODE,
|
||||||
|
SUPPORTED_LANGUAGE_CODES
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
import { BullModule } from '@nestjs/bull';
|
import { BullModule } from '@nestjs/bull';
|
||||||
import { MiddlewareConsumer, Module, RequestMethod } 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 { StatusCodes } from 'http-status-codes';
|
||||||
|
|
||||||
import { AccessModule } from './access/access.module';
|
import { AccessModule } from './access/access.module';
|
||||||
import { AccountModule } from './account/account.module';
|
import { AccountModule } from './account/account.module';
|
||||||
@ -23,7 +28,6 @@ import { BenchmarkModule } from './benchmark/benchmark.module';
|
|||||||
import { CacheModule } from './cache/cache.module';
|
import { CacheModule } from './cache/cache.module';
|
||||||
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
|
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
|
||||||
import { ExportModule } from './export/export.module';
|
import { ExportModule } from './export/export.module';
|
||||||
import { FrontendMiddleware } from './frontend.middleware';
|
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
import { ImportModule } from './import/import.module';
|
import { ImportModule } from './import/import.module';
|
||||||
import { InfoModule } from './info/info.module';
|
import { InfoModule } from './info/info.module';
|
||||||
@ -32,8 +36,10 @@ import { OrderModule } from './order/order.module';
|
|||||||
import { PlatformModule } from './platform/platform.module';
|
import { PlatformModule } from './platform/platform.module';
|
||||||
import { PortfolioModule } from './portfolio/portfolio.module';
|
import { PortfolioModule } from './portfolio/portfolio.module';
|
||||||
import { RedisCacheModule } from './redis-cache/redis-cache.module';
|
import { RedisCacheModule } from './redis-cache/redis-cache.module';
|
||||||
|
import { SitemapModule } from './sitemap/sitemap.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 { TagModule } from './tag/tag.module';
|
||||||
import { UserModule } from './user/user.module';
|
import { UserModule } from './user/user.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@ -70,31 +76,37 @@ import { UserModule } from './user/user.module';
|
|||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
ServeStaticModule.forRoot({
|
ServeStaticModule.forRoot({
|
||||||
serveStaticOptions: {
|
exclude: ['/api*', '/sitemap.xml'],
|
||||||
/*etag: false // Disable etag header to fix PWA
|
|
||||||
setHeaders: (res, path) => {
|
|
||||||
if (path.includes('ngsw.json')) {
|
|
||||||
// Disable cache (https://stackoverflow.com/questions/22632593/how-to-disable-webpage-caching-in-expressjs-nodejs/39775595)
|
|
||||||
// https://gertjans.home.xs4all.nl/javascript/cache-control.html#no-cache
|
|
||||||
res.set('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
||||||
}
|
|
||||||
}*/
|
|
||||||
},
|
|
||||||
rootPath: join(__dirname, '..', 'client'),
|
rootPath: join(__dirname, '..', 'client'),
|
||||||
exclude: ['/api*']
|
serveStaticOptions: {
|
||||||
|
setHeaders: (res) => {
|
||||||
|
if (res.req?.path === '/') {
|
||||||
|
let languageCode = DEFAULT_LANGUAGE_CODE;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const code = res.req.headers['accept-language']
|
||||||
|
.split(',')[0]
|
||||||
|
.split('-')[0];
|
||||||
|
|
||||||
|
if (SUPPORTED_LANGUAGE_CODES.includes(code)) {
|
||||||
|
languageCode = code;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
res.set('Location', `/${languageCode}`);
|
||||||
|
res.statusCode = StatusCodes.MOVED_PERMANENTLY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
|
SitemapModule,
|
||||||
SubscriptionModule,
|
SubscriptionModule,
|
||||||
SymbolModule,
|
SymbolModule,
|
||||||
|
TagModule,
|
||||||
TwitterBotModule,
|
TwitterBotModule,
|
||||||
UserModule
|
UserModule
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [CronService]
|
providers: [CronService]
|
||||||
})
|
})
|
||||||
export class AppModule {
|
export class AppModule {}
|
||||||
configure(consumer: MiddlewareConsumer) {
|
|
||||||
consumer
|
|
||||||
.apply(FrontendMiddleware)
|
|
||||||
.forRoutes({ path: '*', method: RequestMethod.ALL });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -1,40 +1,18 @@
|
|||||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import {
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
Controller,
|
import { Controller, Delete, Param, UseGuards } from '@nestjs/common';
|
||||||
Delete,
|
|
||||||
HttpException,
|
|
||||||
Inject,
|
|
||||||
Param,
|
|
||||||
UseGuards
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { REQUEST } from '@nestjs/core';
|
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
|
||||||
|
|
||||||
@Controller('auth-device')
|
@Controller('auth-device')
|
||||||
export class AuthDeviceController {
|
export class AuthDeviceController {
|
||||||
public constructor(
|
public constructor(private readonly authDeviceService: AuthDeviceService) {}
|
||||||
private readonly authDeviceService: AuthDeviceService,
|
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@HasPermission(permissions.deleteAuthDevice)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async deleteAuthDevice(@Param('id') id: string): Promise<void> {
|
public async deleteAuthDevice(@Param('id') id: string): Promise<void> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.deleteAuthDevice
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.authDeviceService.deleteAuthDevice({ id });
|
await this.authDeviceService.deleteAuthDevice({ id });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
||||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||||
import { OAuthResponse } from '@ghostfolio/common/interfaces';
|
import { OAuthResponse } from '@ghostfolio/common/interfaces';
|
||||||
@ -41,9 +42,8 @@ export class AuthController {
|
|||||||
@Param('accessToken') accessToken: string
|
@Param('accessToken') accessToken: string
|
||||||
): Promise<OAuthResponse> {
|
): Promise<OAuthResponse> {
|
||||||
try {
|
try {
|
||||||
const authToken = await this.authService.validateAnonymousLogin(
|
const authToken =
|
||||||
accessToken
|
await this.authService.validateAnonymousLogin(accessToken);
|
||||||
);
|
|
||||||
return { authToken };
|
return { authToken };
|
||||||
} catch {
|
} catch {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
@ -119,13 +119,13 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('webauthn/generate-registration-options')
|
@Get('webauthn/generate-registration-options')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async generateRegistrationOptions() {
|
public async generateRegistrationOptions() {
|
||||||
return this.webAuthService.generateRegistrationOptions();
|
return this.webAuthService.generateRegistrationOptions();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('webauthn/verify-attestation')
|
@Post('webauthn/verify-attestation')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async verifyAttestation(
|
public async verifyAttestation(
|
||||||
@Body() body: { deviceName: string; credential: AttestationCredentialJSON }
|
@Body() body: { deviceName: string; credential: AttestationCredentialJSON }
|
||||||
) {
|
) {
|
||||||
|
@ -55,7 +55,7 @@ export class AuthService {
|
|||||||
const isUserSignupEnabled =
|
const isUserSignupEnabled =
|
||||||
await this.propertyService.isUserSignupEnabled();
|
await this.propertyService.isUserSignupEnabled();
|
||||||
|
|
||||||
if (!isUserSignupEnabled) {
|
if (!isUserSignupEnabled || true) {
|
||||||
throw new Error('Sign up forbidden');
|
throw new Error('Sign up forbidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -64,7 +64,7 @@ export class WebAuthService {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const options = generateRegistrationOptions(opts);
|
const options = await generateRegistrationOptions(opts);
|
||||||
|
|
||||||
await this.userService.updateUser({
|
await this.userService.updateUser({
|
||||||
data: {
|
data: {
|
||||||
@ -88,10 +88,16 @@ export class WebAuthService {
|
|||||||
let verification: VerifiedRegistrationResponse;
|
let verification: VerifiedRegistrationResponse;
|
||||||
try {
|
try {
|
||||||
const opts: VerifyRegistrationResponseOpts = {
|
const opts: VerifyRegistrationResponseOpts = {
|
||||||
credential,
|
|
||||||
expectedChallenge,
|
expectedChallenge,
|
||||||
expectedOrigin: this.expectedOrigin,
|
expectedOrigin: this.expectedOrigin,
|
||||||
expectedRPID: this.rpID
|
expectedRPID: this.rpID,
|
||||||
|
response: {
|
||||||
|
clientExtensionResults: credential.clientExtensionResults,
|
||||||
|
id: credential.id,
|
||||||
|
rawId: credential.rawId,
|
||||||
|
response: credential.response,
|
||||||
|
type: 'public-key'
|
||||||
|
}
|
||||||
};
|
};
|
||||||
verification = await verifyRegistrationResponse(opts);
|
verification = await verifyRegistrationResponse(opts);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -117,8 +123,8 @@ export class WebAuthService {
|
|||||||
*/
|
*/
|
||||||
existingDevice = await this.deviceService.createAuthDevice({
|
existingDevice = await this.deviceService.createAuthDevice({
|
||||||
counter,
|
counter,
|
||||||
credentialPublicKey,
|
credentialId: Buffer.from(credentialID),
|
||||||
credentialId: credentialID,
|
credentialPublicKey: Buffer.from(credentialPublicKey),
|
||||||
User: { connect: { id: user.id } }
|
User: { connect: { id: user.id } }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -152,7 +158,7 @@ export class WebAuthService {
|
|||||||
userVerification: 'preferred'
|
userVerification: 'preferred'
|
||||||
};
|
};
|
||||||
|
|
||||||
const options = generateAuthenticationOptions(opts);
|
const options = await generateAuthenticationOptions(opts);
|
||||||
|
|
||||||
await this.userService.updateUser({
|
await this.userService.updateUser({
|
||||||
data: {
|
data: {
|
||||||
@ -181,7 +187,6 @@ export class WebAuthService {
|
|||||||
let verification: VerifiedAuthenticationResponse;
|
let verification: VerifiedAuthenticationResponse;
|
||||||
try {
|
try {
|
||||||
const opts: VerifyAuthenticationResponseOpts = {
|
const opts: VerifyAuthenticationResponseOpts = {
|
||||||
credential,
|
|
||||||
authenticator: {
|
authenticator: {
|
||||||
credentialID: device.credentialId,
|
credentialID: device.credentialId,
|
||||||
credentialPublicKey: device.credentialPublicKey,
|
credentialPublicKey: device.credentialPublicKey,
|
||||||
@ -189,9 +194,16 @@ export class WebAuthService {
|
|||||||
},
|
},
|
||||||
expectedChallenge: `${user.authChallenge}`,
|
expectedChallenge: `${user.authChallenge}`,
|
||||||
expectedOrigin: this.expectedOrigin,
|
expectedOrigin: this.expectedOrigin,
|
||||||
expectedRPID: this.rpID
|
expectedRPID: this.rpID,
|
||||||
|
response: {
|
||||||
|
clientExtensionResults: credential.clientExtensionResults,
|
||||||
|
id: credential.id,
|
||||||
|
rawId: credential.rawId,
|
||||||
|
response: credential.response,
|
||||||
|
type: 'public-key'
|
||||||
|
}
|
||||||
};
|
};
|
||||||
verification = verifyAuthenticationResponse(opts);
|
verification = await verifyAuthenticationResponse(opts);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'WebAuthService');
|
Logger.error(error, 'WebAuthService');
|
||||||
throw new InternalServerErrorException({ error: error.message });
|
throw new InternalServerErrorException({ error: error.message });
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
import type {
|
import type {
|
||||||
@ -5,11 +7,12 @@ import type {
|
|||||||
BenchmarkResponse,
|
BenchmarkResponse,
|
||||||
UniqueAsset
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
@ -32,47 +35,10 @@ export class BenchmarkController {
|
|||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@HasPermission(permissions.accessAdminControl)
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
|
||||||
public async getBenchmark(): Promise<BenchmarkResponse> {
|
|
||||||
return {
|
|
||||||
benchmarks: await this.benchmarkService.getBenchmarks()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(':dataSource/:symbol/:startDateString')
|
|
||||||
@UseGuards(AuthGuard('jwt'))
|
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
|
||||||
public async getBenchmarkMarketDataBySymbol(
|
|
||||||
@Param('dataSource') dataSource: DataSource,
|
|
||||||
@Param('startDateString') startDateString: string,
|
|
||||||
@Param('symbol') symbol: string
|
|
||||||
): Promise<BenchmarkMarketDataDetails> {
|
|
||||||
const startDate = new Date(startDateString);
|
|
||||||
|
|
||||||
return this.benchmarkService.getMarketDataBySymbol({
|
|
||||||
dataSource,
|
|
||||||
startDate,
|
|
||||||
symbol
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) {
|
public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const benchmark = await this.benchmarkService.addBenchmark({
|
const benchmark = await this.benchmarkService.addBenchmark({
|
||||||
dataSource,
|
dataSource,
|
||||||
@ -94,4 +60,61 @@ export class BenchmarkController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Delete(':dataSource/:symbol')
|
||||||
|
@HasPermission(permissions.accessAdminControl)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
public async deleteBenchmark(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const benchmark = await this.benchmarkService.deleteBenchmark({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!benchmark) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||||
|
StatusCodes.NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return benchmark;
|
||||||
|
} catch {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||||
|
StatusCodes.INTERNAL_SERVER_ERROR
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
|
public async getBenchmark(): Promise<BenchmarkResponse> {
|
||||||
|
return {
|
||||||
|
benchmarks: await this.benchmarkService.getBenchmarks()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':dataSource/:symbol/:startDateString')
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
|
public async getBenchmarkMarketDataBySymbol(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('startDateString') startDateString: string,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
): Promise<BenchmarkMarketDataDetails> {
|
||||||
|
const startDate = new Date(startDateString);
|
||||||
|
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||||
|
|
||||||
|
return this.benchmarkService.getMarketDataBySymbol({
|
||||||
|
dataSource,
|
||||||
|
startDate,
|
||||||
|
symbol,
|
||||||
|
userCurrency
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@ import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.mo
|
|||||||
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
|
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
@ -17,6 +18,7 @@ import { BenchmarkService } from './benchmark.service';
|
|||||||
imports: [
|
imports: [
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
|
ExchangeRateDataModule,
|
||||||
MarketDataModule,
|
MarketDataModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
PropertyModule,
|
PropertyModule,
|
||||||
|
@ -11,6 +11,7 @@ describe('BenchmarkService', () => {
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
|
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
@ -9,18 +10,24 @@ import {
|
|||||||
MAX_CHART_ITEMS,
|
MAX_CHART_ITEMS,
|
||||||
PROPERTY_BENCHMARKS
|
PROPERTY_BENCHMARKS
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
|
||||||
import {
|
import {
|
||||||
|
DATE_FORMAT,
|
||||||
|
calculateBenchmarkTrend,
|
||||||
|
parseDate
|
||||||
|
} from '@ghostfolio/common/helper';
|
||||||
|
import {
|
||||||
|
Benchmark,
|
||||||
BenchmarkMarketDataDetails,
|
BenchmarkMarketDataDetails,
|
||||||
BenchmarkProperty,
|
BenchmarkProperty,
|
||||||
BenchmarkResponse,
|
BenchmarkResponse,
|
||||||
UniqueAsset
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { BenchmarkTrend } from '@ghostfolio/common/types';
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { SymbolProfile } from '@prisma/client';
|
import { SymbolProfile } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
import { format } from 'date-fns';
|
import { format, isSameDay, subDays } from 'date-fns';
|
||||||
import { uniqBy } from 'lodash';
|
import { isNumber, last, uniqBy } from 'lodash';
|
||||||
import ms from 'ms';
|
import ms from 'ms';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -29,6 +36,7 @@ export class BenchmarkService {
|
|||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly marketDataService: MarketDataService,
|
private readonly marketDataService: MarketDataService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly propertyService: PropertyService,
|
private readonly propertyService: PropertyService,
|
||||||
@ -45,9 +53,34 @@ export class BenchmarkService {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getBenchmarks({ useCache = true } = {}): Promise<
|
public async getBenchmarkTrends({ dataSource, symbol }: UniqueAsset) {
|
||||||
BenchmarkResponse['benchmarks']
|
const historicalData = await this.marketDataService.marketDataItems({
|
||||||
> {
|
orderBy: {
|
||||||
|
date: 'desc'
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
date: { gte: subDays(new Date(), 400) }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const fiftyDayAverage = calculateBenchmarkTrend({
|
||||||
|
historicalData,
|
||||||
|
days: 50
|
||||||
|
});
|
||||||
|
const twoHundredDayAverage = calculateBenchmarkTrend({
|
||||||
|
historicalData,
|
||||||
|
days: 200
|
||||||
|
});
|
||||||
|
|
||||||
|
return { trend50d: fiftyDayAverage, trend200d: twoHundredDayAverage };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getBenchmarks({
|
||||||
|
enableSharing = false,
|
||||||
|
useCache = true
|
||||||
|
} = {}): Promise<BenchmarkResponse['benchmarks']> {
|
||||||
let benchmarks: BenchmarkResponse['benchmarks'];
|
let benchmarks: BenchmarkResponse['benchmarks'];
|
||||||
|
|
||||||
if (useCache) {
|
if (useCache) {
|
||||||
@ -62,9 +95,16 @@ export class BenchmarkService {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles();
|
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles({
|
||||||
|
enableSharing
|
||||||
|
});
|
||||||
|
|
||||||
const promises: Promise<number>[] = [];
|
const promisesAllTimeHighs: Promise<{ date: Date; marketPrice: number }>[] =
|
||||||
|
[];
|
||||||
|
const promisesBenchmarkTrends: Promise<{
|
||||||
|
trend50d: BenchmarkTrend;
|
||||||
|
trend200d: BenchmarkTrend;
|
||||||
|
}>[] = [];
|
||||||
|
|
||||||
const quotes = await this.dataProviderService.getQuotes({
|
const quotes = await this.dataProviderService.getQuotes({
|
||||||
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
||||||
@ -73,10 +113,18 @@ export class BenchmarkService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
|
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
|
||||||
promises.push(this.marketDataService.getMax({ dataSource, symbol }));
|
promisesAllTimeHighs.push(
|
||||||
|
this.marketDataService.getMax({ dataSource, symbol })
|
||||||
|
);
|
||||||
|
promisesBenchmarkTrends.push(
|
||||||
|
this.getBenchmarkTrends({ dataSource, symbol })
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const allTimeHighs = await Promise.all(promises);
|
const [allTimeHighs, benchmarkTrends] = await Promise.all([
|
||||||
|
Promise.all(promisesAllTimeHighs),
|
||||||
|
Promise.all(promisesBenchmarkTrends)
|
||||||
|
]);
|
||||||
let storeInCache = true;
|
let storeInCache = true;
|
||||||
|
|
||||||
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
|
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
|
||||||
@ -85,9 +133,9 @@ export class BenchmarkService {
|
|||||||
|
|
||||||
let performancePercentFromAllTimeHigh = 0;
|
let performancePercentFromAllTimeHigh = 0;
|
||||||
|
|
||||||
if (allTimeHigh && marketPrice) {
|
if (allTimeHigh?.marketPrice && marketPrice) {
|
||||||
performancePercentFromAllTimeHigh = this.calculateChangeInPercentage(
|
performancePercentFromAllTimeHigh = this.calculateChangeInPercentage(
|
||||||
allTimeHigh,
|
allTimeHigh.marketPrice,
|
||||||
marketPrice
|
marketPrice
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@ -101,9 +149,12 @@ export class BenchmarkService {
|
|||||||
name: benchmarkAssetProfiles[index].name,
|
name: benchmarkAssetProfiles[index].name,
|
||||||
performances: {
|
performances: {
|
||||||
allTimeHigh: {
|
allTimeHigh: {
|
||||||
|
date: allTimeHigh?.date,
|
||||||
performancePercent: performancePercentFromAllTimeHigh
|
performancePercent: performancePercentFromAllTimeHigh
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
trend50d: benchmarkTrends[index].trend50d,
|
||||||
|
trend200d: benchmarkTrends[index].trend200d
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -118,14 +169,24 @@ export class BenchmarkService {
|
|||||||
return benchmarks;
|
return benchmarks;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getBenchmarkAssetProfiles(): Promise<Partial<SymbolProfile>[]> {
|
public async getBenchmarkAssetProfiles({
|
||||||
|
enableSharing = false
|
||||||
|
} = {}): Promise<Partial<SymbolProfile>[]> {
|
||||||
const symbolProfileIds: string[] = (
|
const symbolProfileIds: string[] = (
|
||||||
((await this.propertyService.getByKey(
|
((await this.propertyService.getByKey(
|
||||||
PROPERTY_BENCHMARKS
|
PROPERTY_BENCHMARKS
|
||||||
)) as BenchmarkProperty[]) ?? []
|
)) as BenchmarkProperty[]) ?? []
|
||||||
).map(({ symbolProfileId }) => {
|
)
|
||||||
return symbolProfileId;
|
.filter((benchmark) => {
|
||||||
});
|
if (enableSharing) {
|
||||||
|
return benchmark.enableSharing;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map(({ symbolProfileId }) => {
|
||||||
|
return symbolProfileId;
|
||||||
|
});
|
||||||
|
|
||||||
const assetProfiles =
|
const assetProfiles =
|
||||||
await this.symbolProfileService.getSymbolProfilesByIds(symbolProfileIds);
|
await this.symbolProfileService.getSymbolProfilesByIds(symbolProfileIds);
|
||||||
@ -145,8 +206,14 @@ export class BenchmarkService {
|
|||||||
public async getMarketDataBySymbol({
|
public async getMarketDataBySymbol({
|
||||||
dataSource,
|
dataSource,
|
||||||
startDate,
|
startDate,
|
||||||
symbol
|
symbol,
|
||||||
}: { startDate: Date } & UniqueAsset): Promise<BenchmarkMarketDataDetails> {
|
userCurrency
|
||||||
|
}: {
|
||||||
|
startDate: Date;
|
||||||
|
userCurrency: string;
|
||||||
|
} & UniqueAsset): Promise<BenchmarkMarketDataDetails> {
|
||||||
|
const marketData: { date: string; value: number }[] = [];
|
||||||
|
|
||||||
const [currentSymbolItem, marketDataItems] = await Promise.all([
|
const [currentSymbolItem, marketDataItems] = await Promise.all([
|
||||||
this.symbolService.get({
|
this.symbolService.get({
|
||||||
dataGatheringItem: {
|
dataGatheringItem: {
|
||||||
@ -168,44 +235,96 @@ export class BenchmarkService {
|
|||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const exchangeRates =
|
||||||
|
await this.exchangeRateDataService.getExchangeRatesByCurrency({
|
||||||
|
startDate,
|
||||||
|
currencies: [currentSymbolItem.currency],
|
||||||
|
targetCurrency: userCurrency
|
||||||
|
});
|
||||||
|
|
||||||
|
const exchangeRateAtStartDate =
|
||||||
|
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
|
||||||
|
format(startDate, DATE_FORMAT)
|
||||||
|
];
|
||||||
|
|
||||||
|
const marketPriceAtStartDate = marketDataItems?.find(({ date }) => {
|
||||||
|
return isSameDay(date, startDate);
|
||||||
|
})?.marketPrice;
|
||||||
|
|
||||||
|
if (!marketPriceAtStartDate) {
|
||||||
|
Logger.error(
|
||||||
|
`No historical market data has been found for ${symbol} (${dataSource}) at ${format(
|
||||||
|
startDate,
|
||||||
|
DATE_FORMAT
|
||||||
|
)}`,
|
||||||
|
'BenchmarkService'
|
||||||
|
);
|
||||||
|
|
||||||
|
return { marketData };
|
||||||
|
}
|
||||||
|
|
||||||
const step = Math.round(
|
const step = Math.round(
|
||||||
marketDataItems.length / Math.min(marketDataItems.length, MAX_CHART_ITEMS)
|
marketDataItems.length / Math.min(marketDataItems.length, MAX_CHART_ITEMS)
|
||||||
);
|
);
|
||||||
|
|
||||||
const marketPriceAtStartDate = marketDataItems?.[0]?.marketPrice ?? 0;
|
let i = 0;
|
||||||
const response = {
|
|
||||||
marketData: [
|
|
||||||
...marketDataItems
|
|
||||||
.filter((marketDataItem, index) => {
|
|
||||||
return index % step === 0;
|
|
||||||
})
|
|
||||||
.map((marketDataItem) => {
|
|
||||||
return {
|
|
||||||
date: format(marketDataItem.date, DATE_FORMAT),
|
|
||||||
value:
|
|
||||||
marketPriceAtStartDate === 0
|
|
||||||
? 0
|
|
||||||
: this.calculateChangeInPercentage(
|
|
||||||
marketPriceAtStartDate,
|
|
||||||
marketDataItem.marketPrice
|
|
||||||
) * 100
|
|
||||||
};
|
|
||||||
})
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
if (currentSymbolItem?.marketPrice) {
|
for (let marketDataItem of marketDataItems) {
|
||||||
response.marketData.push({
|
if (i % step !== 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exchangeRate =
|
||||||
|
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
|
||||||
|
format(marketDataItem.date, DATE_FORMAT)
|
||||||
|
];
|
||||||
|
|
||||||
|
const exchangeRateFactor =
|
||||||
|
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate)
|
||||||
|
? exchangeRate / exchangeRateAtStartDate
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
marketData.push({
|
||||||
|
date: format(marketDataItem.date, DATE_FORMAT),
|
||||||
|
value:
|
||||||
|
marketPriceAtStartDate === 0
|
||||||
|
? 0
|
||||||
|
: this.calculateChangeInPercentage(
|
||||||
|
marketPriceAtStartDate,
|
||||||
|
marketDataItem.marketPrice * exchangeRateFactor
|
||||||
|
) * 100
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const includesToday = isSameDay(
|
||||||
|
parseDate(last(marketData).date),
|
||||||
|
new Date()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (currentSymbolItem?.marketPrice && !includesToday) {
|
||||||
|
const exchangeRate =
|
||||||
|
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
|
||||||
|
format(new Date(), DATE_FORMAT)
|
||||||
|
];
|
||||||
|
|
||||||
|
const exchangeRateFactor =
|
||||||
|
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate)
|
||||||
|
? exchangeRate / exchangeRateAtStartDate
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
marketData.push({
|
||||||
date: format(new Date(), DATE_FORMAT),
|
date: format(new Date(), DATE_FORMAT),
|
||||||
value:
|
value:
|
||||||
this.calculateChangeInPercentage(
|
this.calculateChangeInPercentage(
|
||||||
marketPriceAtStartDate,
|
marketPriceAtStartDate,
|
||||||
currentSymbolItem.marketPrice
|
currentSymbolItem.marketPrice * exchangeRateFactor
|
||||||
) * 100
|
) * 100
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return {
|
||||||
|
marketData
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async addBenchmark({
|
public async addBenchmark({
|
||||||
@ -245,7 +364,52 @@ export class BenchmarkService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private getMarketCondition(aPerformanceInPercent: number) {
|
public async deleteBenchmark({
|
||||||
return aPerformanceInPercent <= -0.2 ? 'BEAR_MARKET' : 'NEUTRAL_MARKET';
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}: UniqueAsset): Promise<Partial<SymbolProfile>> {
|
||||||
|
const assetProfile = await this.prismaService.symbolProfile.findFirst({
|
||||||
|
where: {
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!assetProfile) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let benchmarks =
|
||||||
|
((await this.propertyService.getByKey(
|
||||||
|
PROPERTY_BENCHMARKS
|
||||||
|
)) as BenchmarkProperty[]) ?? [];
|
||||||
|
|
||||||
|
benchmarks = benchmarks.filter(({ symbolProfileId }) => {
|
||||||
|
return symbolProfileId !== assetProfile.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.propertyService.put({
|
||||||
|
key: PROPERTY_BENCHMARKS,
|
||||||
|
value: JSON.stringify(benchmarks)
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
id: assetProfile.id,
|
||||||
|
name: assetProfile.name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMarketCondition(
|
||||||
|
aPerformanceInPercent: number
|
||||||
|
): Benchmark['marketCondition'] {
|
||||||
|
if (aPerformanceInPercent === 0) {
|
||||||
|
return 'ALL_TIME_HIGH';
|
||||||
|
} else if (aPerformanceInPercent <= -0.2) {
|
||||||
|
return 'BEAR_MARKET';
|
||||||
|
} else {
|
||||||
|
return 'NEUTRAL_MARKET';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
35
apps/api/src/app/cache/cache.controller.ts
vendored
35
apps/api/src/app/cache/cache.controller.ts
vendored
@ -1,39 +1,18 @@
|
|||||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import {
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
Controller,
|
import { Controller, Post, UseGuards } from '@nestjs/common';
|
||||||
HttpException,
|
|
||||||
Inject,
|
|
||||||
Post,
|
|
||||||
UseGuards
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { REQUEST } from '@nestjs/core';
|
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
|
||||||
|
|
||||||
@Controller('cache')
|
@Controller('cache')
|
||||||
export class CacheController {
|
export class CacheController {
|
||||||
public constructor(
|
public constructor(private readonly redisCacheService: RedisCacheService) {}
|
||||||
private readonly redisCacheService: RedisCacheService,
|
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
|
||||||
) {}
|
|
||||||
|
|
||||||
|
@HasPermission(permissions.accessAdminControl)
|
||||||
@Post('flush')
|
@Post('flush')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async flushCache(): Promise<void> {
|
public async flushCache(): Promise<void> {
|
||||||
if (
|
|
||||||
!hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.redisCacheService.reset();
|
return this.redisCacheService.reset();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
@ -7,6 +8,7 @@ import {
|
|||||||
UseGuards
|
UseGuards
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { parseISO } from 'date-fns';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { ExchangeRateService } from './exchange-rate.service';
|
import { ExchangeRateService } from './exchange-rate.service';
|
||||||
@ -18,12 +20,12 @@ export class ExchangeRateController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get(':symbol/:dateString')
|
@Get(':symbol/:dateString')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async getExchangeRate(
|
public async getExchangeRate(
|
||||||
@Param('dateString') dateString: string,
|
@Param('dateString') dateString: string,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
): Promise<IDataProviderHistoricalResponse> {
|
): Promise<IDataProviderHistoricalResponse> {
|
||||||
const date = new Date(dateString);
|
const date = parseISO(dateString);
|
||||||
|
|
||||||
const exchangeRate = await this.exchangeRateService.getExchangeRate({
|
const exchangeRate = await this.exchangeRateService.getExchangeRate({
|
||||||
date,
|
date,
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import { Export } from '@ghostfolio/common/interfaces';
|
import { Export } from '@ghostfolio/common/interfaces';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import { Controller, Get, Inject, Query, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Inject, Query, UseGuards } from '@nestjs/common';
|
||||||
@ -14,12 +15,13 @@ export class ExportController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async export(
|
public async export(
|
||||||
@Query('activityIds') activityIds?: string[]
|
@Query('activityIds') activityIds?: string[]
|
||||||
): Promise<Export> {
|
): Promise<Export> {
|
||||||
return this.exportService.export({
|
return this.exportService.export({
|
||||||
activityIds,
|
activityIds,
|
||||||
|
userCurrency: this.request.user.Settings.settings.baseCurrency,
|
||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -13,9 +13,11 @@ export class ExportService {
|
|||||||
|
|
||||||
public async export({
|
public async export({
|
||||||
activityIds,
|
activityIds,
|
||||||
|
userCurrency,
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
activityIds?: string[];
|
activityIds?: string[];
|
||||||
|
userCurrency: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
}): Promise<Export> {
|
}): Promise<Export> {
|
||||||
const accounts = (
|
const accounts = (
|
||||||
@ -26,18 +28,8 @@ export class ExportService {
|
|||||||
where: { userId }
|
where: { userId }
|
||||||
})
|
})
|
||||||
).map(
|
).map(
|
||||||
({
|
({ balance, comment, currency, id, isExcluded, name, platformId }) => {
|
||||||
accountType,
|
|
||||||
balance,
|
|
||||||
comment,
|
|
||||||
currency,
|
|
||||||
id,
|
|
||||||
isExcluded,
|
|
||||||
name,
|
|
||||||
platformId
|
|
||||||
}) => {
|
|
||||||
return {
|
return {
|
||||||
accountType,
|
|
||||||
balance,
|
balance,
|
||||||
comment,
|
comment,
|
||||||
currency,
|
currency,
|
||||||
@ -49,10 +41,13 @@ export class ExportService {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
let activities = await this.orderService.orders({
|
let { activities } = await this.orderService.getOrders({
|
||||||
include: { SymbolProfile: true },
|
userCurrency,
|
||||||
orderBy: { date: 'desc' },
|
userId,
|
||||||
where: { userId }
|
includeDrafts: true,
|
||||||
|
sortColumn: 'date',
|
||||||
|
sortDirection: 'asc',
|
||||||
|
withExcludedAccounts: true
|
||||||
});
|
});
|
||||||
|
|
||||||
if (activityIds) {
|
if (activityIds) {
|
||||||
@ -87,7 +82,13 @@ export class ExportService {
|
|||||||
currency: SymbolProfile.currency,
|
currency: SymbolProfile.currency,
|
||||||
dataSource: SymbolProfile.dataSource,
|
dataSource: SymbolProfile.dataSource,
|
||||||
date: date.toISOString(),
|
date: date.toISOString(),
|
||||||
symbol: type === 'ITEM' ? SymbolProfile.name : SymbolProfile.symbol
|
symbol:
|
||||||
|
type === 'FEE' ||
|
||||||
|
type === 'INTEREST' ||
|
||||||
|
type === 'ITEM' ||
|
||||||
|
type === 'LIABILITY'
|
||||||
|
? SymbolProfile.name
|
||||||
|
: SymbolProfile.symbol
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -1,237 +0,0 @@
|
|||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
import { environment } from '@ghostfolio/api/environments/environment';
|
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
|
||||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
|
||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
|
||||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
|
||||||
import { format } from 'date-fns';
|
|
||||||
import { NextFunction, Request, Response } from 'express';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class FrontendMiddleware implements NestMiddleware {
|
|
||||||
public indexHtmlDe = '';
|
|
||||||
public indexHtmlEn = '';
|
|
||||||
public indexHtmlEs = '';
|
|
||||||
public indexHtmlFr = '';
|
|
||||||
public indexHtmlIt = '';
|
|
||||||
public indexHtmlNl = '';
|
|
||||||
public indexHtmlPt = '';
|
|
||||||
|
|
||||||
private static readonly DEFAULT_DESCRIPTION =
|
|
||||||
'Ghostfolio is a personal finance dashboard to keep track of your assets like stocks, ETFs or cryptocurrencies across multiple platforms.';
|
|
||||||
|
|
||||||
public constructor(
|
|
||||||
private readonly configurationService: ConfigurationService
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
this.indexHtmlDe = fs.readFileSync(
|
|
||||||
this.getPathOfIndexHtmlFile('de'),
|
|
||||||
'utf8'
|
|
||||||
);
|
|
||||||
this.indexHtmlEn = fs.readFileSync(
|
|
||||||
this.getPathOfIndexHtmlFile(DEFAULT_LANGUAGE_CODE),
|
|
||||||
'utf8'
|
|
||||||
);
|
|
||||||
this.indexHtmlEs = fs.readFileSync(
|
|
||||||
this.getPathOfIndexHtmlFile('es'),
|
|
||||||
'utf8'
|
|
||||||
);
|
|
||||||
this.indexHtmlFr = fs.readFileSync(
|
|
||||||
this.getPathOfIndexHtmlFile('fr'),
|
|
||||||
'utf8'
|
|
||||||
);
|
|
||||||
this.indexHtmlIt = fs.readFileSync(
|
|
||||||
this.getPathOfIndexHtmlFile('it'),
|
|
||||||
'utf8'
|
|
||||||
);
|
|
||||||
this.indexHtmlNl = fs.readFileSync(
|
|
||||||
this.getPathOfIndexHtmlFile('nl'),
|
|
||||||
'utf8'
|
|
||||||
);
|
|
||||||
this.indexHtmlPt = fs.readFileSync(
|
|
||||||
this.getPathOfIndexHtmlFile('pt'),
|
|
||||||
'utf8'
|
|
||||||
);
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
public use(request: Request, response: Response, next: NextFunction) {
|
|
||||||
const currentDate = format(new Date(), DATE_FORMAT);
|
|
||||||
let featureGraphicPath = 'assets/cover.png';
|
|
||||||
let title = 'Ghostfolio – Open Source Wealth Management Software';
|
|
||||||
|
|
||||||
if (request.path.startsWith('/en/blog/2022/08/500-stars-on-github')) {
|
|
||||||
featureGraphicPath = 'assets/images/blog/500-stars-on-github.jpg';
|
|
||||||
title = `500 Stars - ${title}`;
|
|
||||||
} else if (request.path.startsWith('/en/blog/2022/10/hacktoberfest-2022')) {
|
|
||||||
featureGraphicPath = 'assets/images/blog/hacktoberfest-2022.png';
|
|
||||||
title = `Hacktoberfest 2022 - ${title}`;
|
|
||||||
} else if (request.path.startsWith('/en/blog/2022/11/black-friday-2022')) {
|
|
||||||
featureGraphicPath = 'assets/images/blog/black-friday-2022.jpg';
|
|
||||||
title = `Black Friday 2022 - ${title}`;
|
|
||||||
} else if (
|
|
||||||
request.path.startsWith(
|
|
||||||
'/en/blog/2022/12/the-importance-of-tracking-your-personal-finances'
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
featureGraphicPath = 'assets/images/blog/20221226.jpg';
|
|
||||||
title = `The importance of tracking your personal finances - ${title}`;
|
|
||||||
} else if (
|
|
||||||
request.path.startsWith(
|
|
||||||
'/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt'
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
featureGraphicPath = 'assets/images/blog/ghostfolio-x-sackgeld.png';
|
|
||||||
title = `Ghostfolio auf Sackgeld.com vorgestellt - ${title}`;
|
|
||||||
} else if (
|
|
||||||
request.path.startsWith('/en/blog/2023/02/ghostfolio-meets-umbrel')
|
|
||||||
) {
|
|
||||||
featureGraphicPath = 'assets/images/blog/ghostfolio-x-umbrel.png';
|
|
||||||
title = `Ghostfolio meets Umbrel - ${title}`;
|
|
||||||
} else if (
|
|
||||||
request.path.startsWith(
|
|
||||||
'/en/blog/2023/03/ghostfolio-reaches-1000-stars-on-github'
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
featureGraphicPath = 'assets/images/blog/1000-stars-on-github.jpg';
|
|
||||||
title = `Ghostfolio reaches 1’000 Stars on GitHub - ${title}`;
|
|
||||||
} else if (
|
|
||||||
request.path.startsWith(
|
|
||||||
'/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio'
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
featureGraphicPath = 'assets/images/blog/20230520.jpg';
|
|
||||||
title = `Unlock your Financial Potential with Ghostfolio - ${title}`;
|
|
||||||
} else if (
|
|
||||||
request.path.startsWith('/en/blog/2023/07/exploring-the-path-to-fire')
|
|
||||||
) {
|
|
||||||
featureGraphicPath = 'assets/images/blog/20230701.jpg';
|
|
||||||
title = `Exploring the Path to FIRE - ${title}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
request.path.startsWith('/api/') ||
|
|
||||||
this.isFileRequest(request.url) ||
|
|
||||||
!environment.production
|
|
||||||
) {
|
|
||||||
// Skip
|
|
||||||
next();
|
|
||||||
} else if (request.path === '/de' || request.path.startsWith('/de/')) {
|
|
||||||
response.send(
|
|
||||||
this.interpolate(this.indexHtmlDe, {
|
|
||||||
currentDate,
|
|
||||||
featureGraphicPath,
|
|
||||||
title,
|
|
||||||
description:
|
|
||||||
'Mit dem Finanz-Dashboard Ghostfolio können Sie Ihr Vermögen in Form von Aktien, ETFs oder Kryptowährungen verteilt über mehrere Finanzinstitute überwachen.',
|
|
||||||
languageCode: 'de',
|
|
||||||
path: request.path,
|
|
||||||
rootUrl: this.configurationService.get('ROOT_URL')
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} else if (request.path === '/es' || request.path.startsWith('/es/')) {
|
|
||||||
response.send(
|
|
||||||
this.interpolate(this.indexHtmlEs, {
|
|
||||||
currentDate,
|
|
||||||
featureGraphicPath,
|
|
||||||
title,
|
|
||||||
description:
|
|
||||||
'Ghostfolio es un dashboard de finanzas personales para hacer un seguimiento de tus activos como acciones, ETFs o criptodivisas a través de múltiples plataformas.',
|
|
||||||
languageCode: 'es',
|
|
||||||
path: request.path,
|
|
||||||
rootUrl: this.configurationService.get('ROOT_URL')
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} else if (request.path === '/fr' || request.path.startsWith('/fr/')) {
|
|
||||||
response.send(
|
|
||||||
this.interpolate(this.indexHtmlFr, {
|
|
||||||
currentDate,
|
|
||||||
featureGraphicPath,
|
|
||||||
title,
|
|
||||||
description:
|
|
||||||
'Ghostfolio est un dashboard de finances personnelles qui permet de suivre vos actifs comme les actions, les ETF ou les crypto-monnaies sur plusieurs plateformes.',
|
|
||||||
languageCode: 'fr',
|
|
||||||
path: request.path,
|
|
||||||
rootUrl: this.configurationService.get('ROOT_URL')
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} else if (request.path === '/it' || request.path.startsWith('/it/')) {
|
|
||||||
response.send(
|
|
||||||
this.interpolate(this.indexHtmlIt, {
|
|
||||||
currentDate,
|
|
||||||
featureGraphicPath,
|
|
||||||
title,
|
|
||||||
description:
|
|
||||||
'Ghostfolio è un dashboard di finanza personale per tenere traccia delle vostre attività come azioni, ETF o criptovalute su più piattaforme.',
|
|
||||||
languageCode: 'it',
|
|
||||||
path: request.path,
|
|
||||||
rootUrl: this.configurationService.get('ROOT_URL')
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} else if (request.path === '/nl' || request.path.startsWith('/nl/')) {
|
|
||||||
response.send(
|
|
||||||
this.interpolate(this.indexHtmlNl, {
|
|
||||||
currentDate,
|
|
||||||
featureGraphicPath,
|
|
||||||
title,
|
|
||||||
description:
|
|
||||||
'Ghostfolio is een persoonlijk financieel dashboard om uw activa zoals aandelen, ETF’s of cryptocurrencies over meerdere platforms bij te houden.',
|
|
||||||
languageCode: 'nl',
|
|
||||||
path: request.path,
|
|
||||||
rootUrl: this.configurationService.get('ROOT_URL')
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} else if (request.path === '/pt' || request.path.startsWith('/pt/')) {
|
|
||||||
response.send(
|
|
||||||
this.interpolate(this.indexHtmlPt, {
|
|
||||||
currentDate,
|
|
||||||
featureGraphicPath,
|
|
||||||
title,
|
|
||||||
description:
|
|
||||||
'Ghostfolio é um dashboard de finanças pessoais para acompanhar os seus activos como acções, ETFs ou criptomoedas em múltiplas plataformas.',
|
|
||||||
languageCode: 'pt',
|
|
||||||
path: request.path,
|
|
||||||
rootUrl: this.configurationService.get('ROOT_URL')
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
response.send(
|
|
||||||
this.interpolate(this.indexHtmlEn, {
|
|
||||||
currentDate,
|
|
||||||
featureGraphicPath,
|
|
||||||
title,
|
|
||||||
description: FrontendMiddleware.DEFAULT_DESCRIPTION,
|
|
||||||
languageCode: DEFAULT_LANGUAGE_CODE,
|
|
||||||
path: request.path,
|
|
||||||
rootUrl: this.configurationService.get('ROOT_URL')
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private getPathOfIndexHtmlFile(aLocale: string) {
|
|
||||||
return path.join(__dirname, '..', 'client', aLocale, 'index.html');
|
|
||||||
}
|
|
||||||
|
|
||||||
private interpolate(template: string, context: any) {
|
|
||||||
return template.replace(/[$]{([^}]+)}/g, (_, objectPath) => {
|
|
||||||
const properties = objectPath.split('.');
|
|
||||||
return properties.reduce(
|
|
||||||
(previous, current) => previous?.[current],
|
|
||||||
context
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private isFileRequest(filename: string) {
|
|
||||||
if (filename === '/assets/LICENSE') {
|
|
||||||
return true;
|
|
||||||
} else if (filename.includes('auth/ey')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return filename.split('.').pop() !== filename;
|
|
||||||
}
|
|
||||||
}
|
|
@ -18,6 +18,19 @@ export class HealthController {
|
|||||||
@Get()
|
@Get()
|
||||||
public async getHealth() {}
|
public async getHealth() {}
|
||||||
|
|
||||||
|
@Get('data-enhancer/:name')
|
||||||
|
public async getHealthOfDataEnhancer(@Param('name') name: string) {
|
||||||
|
const hasResponse =
|
||||||
|
await this.healthService.hasResponseFromDataEnhancer(name);
|
||||||
|
|
||||||
|
if (hasResponse !== true) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE),
|
||||||
|
StatusCodes.SERVICE_UNAVAILABLE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Get('data-provider/:dataSource')
|
@Get('data-provider/:dataSource')
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
public async getHealthOfDataProvider(
|
public async getHealthOfDataProvider(
|
||||||
@ -30,9 +43,8 @@ export class HealthController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasResponse = await this.healthService.hasResponseFromDataProvider(
|
const hasResponse =
|
||||||
dataSource
|
await this.healthService.hasResponseFromDataProvider(dataSource);
|
||||||
);
|
|
||||||
|
|
||||||
if (hasResponse !== true) {
|
if (hasResponse !== true) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
|
import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
@ -7,7 +8,7 @@ import { HealthService } from './health.service';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
imports: [ConfigurationModule, DataProviderModule],
|
imports: [ConfigurationModule, DataEnhancerModule, DataProviderModule],
|
||||||
providers: [HealthService]
|
providers: [HealthService]
|
||||||
})
|
})
|
||||||
export class HealthModule {}
|
export class HealthModule {}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { DataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.service';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
@ -5,9 +6,14 @@ import { DataSource } from '@prisma/client';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class HealthService {
|
export class HealthService {
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly dataEnhancerService: DataEnhancerService,
|
||||||
private readonly dataProviderService: DataProviderService
|
private readonly dataProviderService: DataProviderService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
public async hasResponseFromDataEnhancer(aName: string) {
|
||||||
|
return this.dataEnhancerService.enhance(aName);
|
||||||
|
}
|
||||||
|
|
||||||
public async hasResponseFromDataProvider(aDataSource: DataSource) {
|
public async hasResponseFromDataProvider(aDataSource: DataSource) {
|
||||||
return this.dataProviderService.checkQuote(aDataSource);
|
return this.dataProviderService.checkQuote(aDataSource);
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
@ -34,7 +36,8 @@ export class ImportController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
@HasPermission(permissions.createOrder)
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async import(
|
public async import(
|
||||||
@ -42,11 +45,7 @@ export class ImportController {
|
|||||||
@Query('dryRun') isDryRun?: boolean
|
@Query('dryRun') isDryRun?: boolean
|
||||||
): Promise<ImportResponse> {
|
): Promise<ImportResponse> {
|
||||||
if (
|
if (
|
||||||
!hasPermission(
|
!hasPermission(this.request.user.permissions, permissions.createAccount)
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.createAccount
|
|
||||||
) ||
|
|
||||||
!hasPermission(this.request.user.permissions, permissions.createOrder)
|
|
||||||
) {
|
) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
@ -92,7 +91,7 @@ export class ImportController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('dividends/:dataSource/:symbol')
|
@Get('dividends/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async gatherDividends(
|
public async gatherDividends(
|
||||||
|
@ -8,10 +8,16 @@ import {
|
|||||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
|
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
|
||||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
import {
|
||||||
|
DATE_FORMAT,
|
||||||
|
getAssetProfileIdentifier,
|
||||||
|
parseDate
|
||||||
|
} from '@ghostfolio/common/helper';
|
||||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
import {
|
import {
|
||||||
AccountWithPlatform,
|
AccountWithPlatform,
|
||||||
@ -20,13 +26,16 @@ import {
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
|
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
import { endOfToday, isAfter, isSameDay, parseISO } from 'date-fns';
|
import { endOfToday, format, isAfter, isSameSecond, parseISO } from 'date-fns';
|
||||||
|
import { uniqBy } from 'lodash';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ImportService {
|
export class ImportService {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly accountService: AccountService,
|
private readonly accountService: AccountService,
|
||||||
|
private readonly configurationService: ConfigurationService,
|
||||||
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly orderService: OrderService,
|
private readonly orderService: OrderService,
|
||||||
@ -74,11 +83,13 @@ export class ImportService {
|
|||||||
|
|
||||||
const value = new Big(quantity).mul(marketPrice).toNumber();
|
const value = new Big(quantity).mul(marketPrice).toNumber();
|
||||||
|
|
||||||
|
const date = parseDate(dateString);
|
||||||
const isDuplicate = orders.some((activity) => {
|
const isDuplicate = orders.some((activity) => {
|
||||||
return (
|
return (
|
||||||
|
activity.accountId === Account?.id &&
|
||||||
activity.SymbolProfile.currency === assetProfile.currency &&
|
activity.SymbolProfile.currency === assetProfile.currency &&
|
||||||
activity.SymbolProfile.dataSource === assetProfile.dataSource &&
|
activity.SymbolProfile.dataSource === assetProfile.dataSource &&
|
||||||
isSameDay(activity.date, parseDate(dateString)) &&
|
isSameSecond(activity.date, date) &&
|
||||||
activity.quantity === quantity &&
|
activity.quantity === quantity &&
|
||||||
activity.SymbolProfile.symbol === assetProfile.symbol &&
|
activity.SymbolProfile.symbol === assetProfile.symbol &&
|
||||||
activity.type === 'DIVIDEND' &&
|
activity.type === 'DIVIDEND' &&
|
||||||
@ -92,6 +103,7 @@ export class ImportService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
Account,
|
Account,
|
||||||
|
date,
|
||||||
error,
|
error,
|
||||||
quantity,
|
quantity,
|
||||||
value,
|
value,
|
||||||
@ -99,7 +111,6 @@ export class ImportService {
|
|||||||
accountUserId: undefined,
|
accountUserId: undefined,
|
||||||
comment: undefined,
|
comment: undefined,
|
||||||
createdAt: undefined,
|
createdAt: undefined,
|
||||||
date: parseDate(dateString),
|
|
||||||
fee: 0,
|
fee: 0,
|
||||||
feeInBaseCurrency: 0,
|
feeInBaseCurrency: 0,
|
||||||
id: assetProfile.id,
|
id: assetProfile.id,
|
||||||
@ -220,12 +231,12 @@ export class ImportService {
|
|||||||
|
|
||||||
const assetProfiles = await this.validateActivities({
|
const assetProfiles = await this.validateActivities({
|
||||||
activitiesDto,
|
activitiesDto,
|
||||||
maxActivitiesToImport,
|
maxActivitiesToImport
|
||||||
userId
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({
|
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({
|
||||||
activitiesDto,
|
activitiesDto,
|
||||||
|
userCurrency,
|
||||||
userId
|
userId
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -243,17 +254,50 @@ export class ImportService {
|
|||||||
|
|
||||||
const activities: Activity[] = [];
|
const activities: Activity[] = [];
|
||||||
|
|
||||||
for (const {
|
for (let [
|
||||||
accountId,
|
index,
|
||||||
comment,
|
{
|
||||||
date,
|
accountId,
|
||||||
error,
|
comment,
|
||||||
fee,
|
date,
|
||||||
quantity,
|
error,
|
||||||
SymbolProfile: assetProfile,
|
fee,
|
||||||
type,
|
quantity,
|
||||||
unitPrice
|
SymbolProfile,
|
||||||
} of activitiesExtendedWithErrors) {
|
type,
|
||||||
|
unitPrice
|
||||||
|
}
|
||||||
|
] of activitiesExtendedWithErrors.entries()) {
|
||||||
|
const assetProfile = assetProfiles[
|
||||||
|
getAssetProfileIdentifier({
|
||||||
|
dataSource: SymbolProfile.dataSource,
|
||||||
|
symbol: SymbolProfile.symbol
|
||||||
|
})
|
||||||
|
] ?? {
|
||||||
|
currency: SymbolProfile.currency,
|
||||||
|
dataSource: SymbolProfile.dataSource,
|
||||||
|
symbol: SymbolProfile.symbol
|
||||||
|
};
|
||||||
|
const {
|
||||||
|
assetClass,
|
||||||
|
assetSubClass,
|
||||||
|
countries,
|
||||||
|
createdAt,
|
||||||
|
currency,
|
||||||
|
dataSource,
|
||||||
|
figi,
|
||||||
|
figiComposite,
|
||||||
|
figiShareClass,
|
||||||
|
id,
|
||||||
|
isin,
|
||||||
|
name,
|
||||||
|
scraperConfiguration,
|
||||||
|
sectors,
|
||||||
|
symbol,
|
||||||
|
symbolMapping,
|
||||||
|
url,
|
||||||
|
updatedAt
|
||||||
|
} = assetProfile;
|
||||||
const validatedAccount = accounts.find(({ id }) => {
|
const validatedAccount = accounts.find(({ id }) => {
|
||||||
return id === accountId;
|
return id === accountId;
|
||||||
});
|
});
|
||||||
@ -264,6 +308,35 @@ export class ImportService {
|
|||||||
Account?: { id: string; name: string };
|
Account?: { id: string; name: string };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (SymbolProfile.currency !== assetProfile.currency) {
|
||||||
|
// Convert the unit price and fee to the asset currency if the imported
|
||||||
|
// activity is in a different currency
|
||||||
|
unitPrice = await this.exchangeRateDataService.toCurrencyAtDate(
|
||||||
|
unitPrice,
|
||||||
|
SymbolProfile.currency,
|
||||||
|
assetProfile.currency,
|
||||||
|
date
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!unitPrice) {
|
||||||
|
throw new Error(
|
||||||
|
`activities.${index} historical exchange rate at ${format(
|
||||||
|
date,
|
||||||
|
DATE_FORMAT
|
||||||
|
)} is not available from "${SymbolProfile.currency}" to "${
|
||||||
|
assetProfile.currency
|
||||||
|
}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fee = await this.exchangeRateDataService.toCurrencyAtDate(
|
||||||
|
fee,
|
||||||
|
SymbolProfile.currency,
|
||||||
|
assetProfile.currency,
|
||||||
|
date
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (isDryRun) {
|
if (isDryRun) {
|
||||||
order = {
|
order = {
|
||||||
comment,
|
comment,
|
||||||
@ -279,23 +352,25 @@ export class ImportService {
|
|||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
isDraft: isAfter(date, endOfToday()),
|
isDraft: isAfter(date, endOfToday()),
|
||||||
SymbolProfile: {
|
SymbolProfile: {
|
||||||
assetClass: assetProfile.assetClass,
|
assetClass,
|
||||||
assetSubClass: assetProfile.assetSubClass,
|
assetSubClass,
|
||||||
comment: assetProfile.comment,
|
countries,
|
||||||
countries: assetProfile.countries,
|
createdAt,
|
||||||
createdAt: assetProfile.createdAt,
|
currency,
|
||||||
currency: assetProfile.currency,
|
dataSource,
|
||||||
dataSource: assetProfile.dataSource,
|
figi,
|
||||||
id: assetProfile.id,
|
figiComposite,
|
||||||
isin: assetProfile.isin,
|
figiShareClass,
|
||||||
name: assetProfile.name,
|
id,
|
||||||
scraperConfiguration: assetProfile.scraperConfiguration,
|
isin,
|
||||||
sectors: assetProfile.sectors,
|
name,
|
||||||
symbol: assetProfile.currency,
|
scraperConfiguration,
|
||||||
symbolMapping: assetProfile.symbolMapping,
|
sectors,
|
||||||
updatedAt: assetProfile.updatedAt,
|
symbol,
|
||||||
url: assetProfile.url,
|
symbolMapping,
|
||||||
...assetProfiles[assetProfile.symbol]
|
updatedAt,
|
||||||
|
url,
|
||||||
|
comment: assetProfile.comment
|
||||||
},
|
},
|
||||||
Account: validatedAccount,
|
Account: validatedAccount,
|
||||||
symbolProfileId: undefined,
|
symbolProfileId: undefined,
|
||||||
@ -318,14 +393,14 @@ export class ImportService {
|
|||||||
SymbolProfile: {
|
SymbolProfile: {
|
||||||
connectOrCreate: {
|
connectOrCreate: {
|
||||||
create: {
|
create: {
|
||||||
currency: assetProfile.currency,
|
currency,
|
||||||
dataSource: assetProfile.dataSource,
|
dataSource,
|
||||||
symbol: assetProfile.symbol
|
symbol
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
dataSource_symbol: {
|
dataSource_symbol: {
|
||||||
dataSource: assetProfile.dataSource,
|
dataSource,
|
||||||
symbol: assetProfile.symbol
|
symbol
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -337,38 +412,66 @@ export class ImportService {
|
|||||||
|
|
||||||
const value = new Big(quantity).mul(unitPrice).toNumber();
|
const value = new Big(quantity).mul(unitPrice).toNumber();
|
||||||
|
|
||||||
//@ts-ignore
|
|
||||||
activities.push({
|
activities.push({
|
||||||
...order,
|
...order,
|
||||||
error,
|
error,
|
||||||
value,
|
value,
|
||||||
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
fee,
|
fee,
|
||||||
assetProfile.currency,
|
currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
),
|
),
|
||||||
|
// @ts-ignore
|
||||||
|
SymbolProfile: assetProfile,
|
||||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
value,
|
value,
|
||||||
assetProfile.currency,
|
currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
activities.sort((activity1, activity2) => {
|
||||||
|
return Number(activity1.date) - Number(activity2.date);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isDryRun) {
|
||||||
|
// Gather symbol data in the background, if not dry run
|
||||||
|
const uniqueActivities = uniqBy(activities, ({ SymbolProfile }) => {
|
||||||
|
return getAssetProfileIdentifier({
|
||||||
|
dataSource: SymbolProfile.dataSource,
|
||||||
|
symbol: SymbolProfile.symbol
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.dataGatheringService.gatherSymbols(
|
||||||
|
uniqueActivities.map(({ date, SymbolProfile }) => {
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
dataSource: SymbolProfile.dataSource,
|
||||||
|
symbol: SymbolProfile.symbol
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return activities;
|
return activities;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async extendActivitiesWithErrors({
|
private async extendActivitiesWithErrors({
|
||||||
activitiesDto,
|
activitiesDto,
|
||||||
|
userCurrency,
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
activitiesDto: Partial<CreateOrderDto>[];
|
activitiesDto: Partial<CreateOrderDto>[];
|
||||||
|
userCurrency: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
}): Promise<Partial<Activity>[]> {
|
}): Promise<Partial<Activity>[]> {
|
||||||
const existingActivities = await this.orderService.orders({
|
let { activities: existingActivities } = await this.orderService.getOrders({
|
||||||
include: { SymbolProfile: true },
|
userCurrency,
|
||||||
orderBy: { date: 'desc' },
|
userId,
|
||||||
where: { userId }
|
includeDrafts: true,
|
||||||
|
withExcludedAccounts: true
|
||||||
});
|
});
|
||||||
|
|
||||||
return activitiesDto.map(
|
return activitiesDto.map(
|
||||||
@ -384,12 +487,13 @@ export class ImportService {
|
|||||||
type,
|
type,
|
||||||
unitPrice
|
unitPrice
|
||||||
}) => {
|
}) => {
|
||||||
const date = parseISO(<string>(<unknown>dateString));
|
const date = parseISO(dateString);
|
||||||
const isDuplicate = existingActivities.some((activity) => {
|
const isDuplicate = existingActivities.some((activity) => {
|
||||||
return (
|
return (
|
||||||
|
activity.accountId === accountId &&
|
||||||
activity.SymbolProfile.currency === currency &&
|
activity.SymbolProfile.currency === currency &&
|
||||||
activity.SymbolProfile.dataSource === dataSource &&
|
activity.SymbolProfile.dataSource === dataSource &&
|
||||||
isSameDay(activity.date, date) &&
|
isSameSecond(activity.date, date) &&
|
||||||
activity.fee === fee &&
|
activity.fee === fee &&
|
||||||
activity.quantity === quantity &&
|
activity.quantity === quantity &&
|
||||||
activity.SymbolProfile.symbol === symbol &&
|
activity.SymbolProfile.symbol === symbol &&
|
||||||
@ -420,6 +524,9 @@ export class ImportService {
|
|||||||
comment: null,
|
comment: null,
|
||||||
countries: null,
|
countries: null,
|
||||||
createdAt: undefined,
|
createdAt: undefined,
|
||||||
|
figi: null,
|
||||||
|
figiComposite: null,
|
||||||
|
figiShareClass: null,
|
||||||
id: undefined,
|
id: undefined,
|
||||||
isin: null,
|
isin: null,
|
||||||
name: null,
|
name: null,
|
||||||
@ -446,46 +553,62 @@ export class ImportService {
|
|||||||
|
|
||||||
private async validateActivities({
|
private async validateActivities({
|
||||||
activitiesDto,
|
activitiesDto,
|
||||||
maxActivitiesToImport,
|
maxActivitiesToImport
|
||||||
userId
|
|
||||||
}: {
|
}: {
|
||||||
activitiesDto: Partial<CreateOrderDto>[];
|
activitiesDto: Partial<CreateOrderDto>[];
|
||||||
maxActivitiesToImport: number;
|
maxActivitiesToImport: number;
|
||||||
userId: string;
|
|
||||||
}) {
|
}) {
|
||||||
if (activitiesDto?.length > maxActivitiesToImport) {
|
if (activitiesDto?.length > maxActivitiesToImport) {
|
||||||
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
|
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const assetProfiles: {
|
const assetProfiles: {
|
||||||
[symbol: string]: Partial<SymbolProfile>;
|
[assetProfileIdentifier: string]: Partial<SymbolProfile>;
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
|
const uniqueActivitiesDto = uniqBy(
|
||||||
|
activitiesDto,
|
||||||
|
({ dataSource, symbol }) => {
|
||||||
|
return getAssetProfileIdentifier({ dataSource, symbol });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
for (const [
|
for (const [
|
||||||
index,
|
index,
|
||||||
{ currency, dataSource, symbol }
|
{ currency, dataSource, symbol }
|
||||||
] of activitiesDto.entries()) {
|
] of uniqueActivitiesDto.entries()) {
|
||||||
if (dataSource !== 'MANUAL') {
|
if (!this.configurationService.get('DATA_SOURCES').includes(dataSource)) {
|
||||||
const assetProfile = (
|
throw new Error(
|
||||||
await this.dataProviderService.getAssetProfiles([
|
`activities.${index}.dataSource ("${dataSource}") is not valid`
|
||||||
{ dataSource, symbol }
|
);
|
||||||
])
|
|
||||||
)?.[symbol];
|
|
||||||
|
|
||||||
if (assetProfile === undefined) {
|
|
||||||
throw new Error(
|
|
||||||
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (assetProfile.currency !== currency) {
|
|
||||||
throw new Error(
|
|
||||||
`activities.${index}.currency ("${currency}") does not match with "${assetProfile.currency}"`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
assetProfiles[symbol] = assetProfile;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const assetProfile = (
|
||||||
|
await this.dataProviderService.getAssetProfiles([
|
||||||
|
{ dataSource, symbol }
|
||||||
|
])
|
||||||
|
)?.[symbol];
|
||||||
|
|
||||||
|
if (!assetProfile?.name) {
|
||||||
|
throw new Error(
|
||||||
|
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
assetProfile.currency !== currency &&
|
||||||
|
!this.exchangeRateDataService.hasCurrencyPair(
|
||||||
|
currency,
|
||||||
|
assetProfile.currency
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`activities.${index}.currency ("${currency}") does not match with "${assetProfile.currency}" and no exchange rate is available from "${currency}" to "${assetProfile.currency}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] =
|
||||||
|
assetProfile;
|
||||||
}
|
}
|
||||||
|
|
||||||
return assetProfiles;
|
return assetProfiles;
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
|
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
|
||||||
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
|
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
@ -28,11 +29,11 @@ import { InfoService } from './info.service';
|
|||||||
signOptions: { expiresIn: '30 days' }
|
signOptions: { expiresIn: '30 days' }
|
||||||
}),
|
}),
|
||||||
PlatformModule,
|
PlatformModule,
|
||||||
PrismaModule,
|
|
||||||
PropertyModule,
|
PropertyModule,
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
SymbolProfileModule,
|
SymbolProfileModule,
|
||||||
TagModule
|
TagModule,
|
||||||
|
UserModule
|
||||||
],
|
],
|
||||||
providers: [InfoService]
|
providers: [InfoService]
|
||||||
})
|
})
|
||||||
|
@ -1,19 +1,19 @@
|
|||||||
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
|
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
|
||||||
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
|
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
|
||||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_CURRENCY,
|
||||||
PROPERTY_BETTER_UPTIME_MONITOR_ID,
|
PROPERTY_BETTER_UPTIME_MONITOR_ID,
|
||||||
PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
|
PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
|
||||||
PROPERTY_DEMO_USER_ID,
|
PROPERTY_DEMO_USER_ID,
|
||||||
PROPERTY_IS_READ_ONLY_MODE,
|
PROPERTY_IS_READ_ONLY_MODE,
|
||||||
PROPERTY_SLACK_COMMUNITY_USERS,
|
PROPERTY_SLACK_COMMUNITY_USERS,
|
||||||
PROPERTY_STRIPE_CONFIG,
|
PROPERTY_STRIPE_CONFIG,
|
||||||
PROPERTY_SYSTEM_MESSAGE,
|
|
||||||
ghostfolioFearAndGreedIndexDataSource
|
ghostfolioFearAndGreedIndexDataSource
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
@ -30,9 +30,9 @@ import { permissions } from '@ghostfolio/common/permissions';
|
|||||||
import { SubscriptionOffer } from '@ghostfolio/common/types';
|
import { SubscriptionOffer } from '@ghostfolio/common/types';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import * as bent from 'bent';
|
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
import { format, subDays } from 'date-fns';
|
import { format, subDays } from 'date-fns';
|
||||||
|
import got from 'got';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class InfoService {
|
export class InfoService {
|
||||||
@ -44,23 +44,18 @@ export class InfoService {
|
|||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly platformService: PlatformService,
|
private readonly platformService: PlatformService,
|
||||||
private readonly prismaService: PrismaService,
|
|
||||||
private readonly propertyService: PropertyService,
|
private readonly propertyService: PropertyService,
|
||||||
private readonly redisCacheService: RedisCacheService,
|
private readonly redisCacheService: RedisCacheService,
|
||||||
private readonly tagService: TagService
|
private readonly tagService: TagService,
|
||||||
|
private readonly userService: UserService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async get(): Promise<InfoItem> {
|
public async get(): Promise<InfoItem> {
|
||||||
const info: Partial<InfoItem> = {};
|
const info: Partial<InfoItem> = {};
|
||||||
let isReadOnlyMode: boolean;
|
let isReadOnlyMode: boolean;
|
||||||
const platforms = (
|
const platforms = await this.platformService.getPlatforms({
|
||||||
await this.platformService.getPlatforms({
|
orderBy: { name: 'asc' }
|
||||||
orderBy: { name: 'asc' }
|
|
||||||
})
|
|
||||||
).map(({ id, name }) => {
|
|
||||||
return { id, name };
|
|
||||||
});
|
});
|
||||||
let systemMessage: string;
|
|
||||||
|
|
||||||
const globalPermissions: string[] = [];
|
const globalPermissions: string[] = [];
|
||||||
|
|
||||||
@ -106,10 +101,6 @@ export class InfoService {
|
|||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_SYSTEM_MESSAGE')) {
|
if (this.configurationService.get('ENABLE_FEATURE_SYSTEM_MESSAGE')) {
|
||||||
globalPermissions.push(permissions.enableSystemMessage);
|
globalPermissions.push(permissions.enableSystemMessage);
|
||||||
|
|
||||||
systemMessage = (await this.propertyService.getByKey(
|
|
||||||
PROPERTY_SYSTEM_MESSAGE
|
|
||||||
)) as string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isUserSignupEnabled =
|
const isUserSignupEnabled =
|
||||||
@ -137,20 +128,14 @@ export class InfoService {
|
|||||||
platforms,
|
platforms,
|
||||||
statistics,
|
statistics,
|
||||||
subscriptions,
|
subscriptions,
|
||||||
systemMessage,
|
|
||||||
tags,
|
tags,
|
||||||
baseCurrency: this.configurationService.get('BASE_CURRENCY'),
|
baseCurrency: DEFAULT_CURRENCY,
|
||||||
currencies: this.exchangeRateDataService.getCurrencies()
|
currencies: this.exchangeRateDataService.getCurrencies()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async countActiveUsers(aDays: number) {
|
private async countActiveUsers(aDays: number) {
|
||||||
return await this.prismaService.user.count({
|
return this.userService.count({
|
||||||
orderBy: {
|
|
||||||
Analytics: {
|
|
||||||
updatedAt: 'desc'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
where: {
|
where: {
|
||||||
AND: [
|
AND: [
|
||||||
{
|
{
|
||||||
@ -172,20 +157,24 @@ export class InfoService {
|
|||||||
|
|
||||||
private async countDockerHubPulls(): Promise<number> {
|
private async countDockerHubPulls(): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const get = bent(
|
const abortController = new AbortController();
|
||||||
`https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`,
|
|
||||||
'GET',
|
setTimeout(() => {
|
||||||
'json',
|
abortController.abort();
|
||||||
200,
|
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||||
{
|
|
||||||
'User-Agent': 'request'
|
const { pull_count } = await got(
|
||||||
}
|
`https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`,
|
||||||
);
|
{
|
||||||
|
headers: { 'User-Agent': 'request' },
|
||||||
|
// @ts-ignore
|
||||||
|
signal: abortController.signal
|
||||||
|
}
|
||||||
|
).json<any>();
|
||||||
|
|
||||||
const { pull_count } = await get();
|
|
||||||
return pull_count;
|
return pull_count;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'InfoService');
|
Logger.error(error, 'InfoService - DockerHub');
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@ -193,24 +182,26 @@ export class InfoService {
|
|||||||
|
|
||||||
private async countGitHubContributors(): Promise<number> {
|
private async countGitHubContributors(): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const get = bent(
|
const abortController = new AbortController();
|
||||||
'https://github.com/ghostfolio/ghostfolio',
|
|
||||||
'GET',
|
|
||||||
'string',
|
|
||||||
200,
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
|
|
||||||
const html = await get();
|
setTimeout(() => {
|
||||||
const $ = cheerio.load(html);
|
abortController.abort();
|
||||||
|
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||||
|
|
||||||
return extractNumberFromString(
|
const { body } = await got('https://github.com/ghostfolio/ghostfolio', {
|
||||||
$(
|
// @ts-ignore
|
||||||
|
signal: abortController.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
const $ = cheerio.load(body);
|
||||||
|
|
||||||
|
return extractNumberFromString({
|
||||||
|
value: $(
|
||||||
`a[href="/ghostfolio/ghostfolio/graphs/contributors"] .Counter`
|
`a[href="/ghostfolio/ghostfolio/graphs/contributors"] .Counter`
|
||||||
).text()
|
).text()
|
||||||
);
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'InfoService');
|
Logger.error(error, 'InfoService - GitHub');
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@ -218,30 +209,31 @@ export class InfoService {
|
|||||||
|
|
||||||
private async countGitHubStargazers(): Promise<number> {
|
private async countGitHubStargazers(): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const get = bent(
|
const abortController = new AbortController();
|
||||||
`https://api.github.com/repos/ghostfolio/ghostfolio`,
|
|
||||||
'GET',
|
setTimeout(() => {
|
||||||
'json',
|
abortController.abort();
|
||||||
200,
|
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||||
{
|
|
||||||
'User-Agent': 'request'
|
const { stargazers_count } = await got(
|
||||||
}
|
`https://api.github.com/repos/ghostfolio/ghostfolio`,
|
||||||
);
|
{
|
||||||
|
headers: { 'User-Agent': 'request' },
|
||||||
|
// @ts-ignore
|
||||||
|
signal: abortController.signal
|
||||||
|
}
|
||||||
|
).json<any>();
|
||||||
|
|
||||||
const { stargazers_count } = await get();
|
|
||||||
return stargazers_count;
|
return stargazers_count;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'InfoService');
|
Logger.error(error, 'InfoService - GitHub');
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async countNewUsers(aDays: number) {
|
private async countNewUsers(aDays: number) {
|
||||||
return await this.prismaService.user.count({
|
return this.userService.count({
|
||||||
orderBy: {
|
|
||||||
createdAt: 'desc'
|
|
||||||
},
|
|
||||||
where: {
|
where: {
|
||||||
AND: [
|
AND: [
|
||||||
{
|
{
|
||||||
@ -332,11 +324,10 @@ export class InfoService {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stripeConfig = (await this.prismaService.property.findUnique({
|
return (
|
||||||
where: { key: PROPERTY_STRIPE_CONFIG }
|
((await this.propertyService.getByKey(PROPERTY_STRIPE_CONFIG)) as any) ??
|
||||||
})) ?? { value: '{}' };
|
{}
|
||||||
|
);
|
||||||
return JSON.parse(stripeConfig.value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getUptime(): Promise<number> {
|
private async getUptime(): Promise<number> {
|
||||||
@ -346,25 +337,31 @@ export class InfoService {
|
|||||||
PROPERTY_BETTER_UPTIME_MONITOR_ID
|
PROPERTY_BETTER_UPTIME_MONITOR_ID
|
||||||
)) as string;
|
)) as string;
|
||||||
|
|
||||||
const get = bent(
|
const abortController = new AbortController();
|
||||||
`https://betteruptime.com/api/v2/monitors/${monitorId}/sla?from=${format(
|
|
||||||
|
setTimeout(() => {
|
||||||
|
abortController.abort();
|
||||||
|
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||||
|
|
||||||
|
const { data } = await got(
|
||||||
|
`https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format(
|
||||||
subDays(new Date(), 90),
|
subDays(new Date(), 90),
|
||||||
DATE_FORMAT
|
DATE_FORMAT
|
||||||
)}&to${format(new Date(), DATE_FORMAT)}`,
|
)}&to${format(new Date(), DATE_FORMAT)}`,
|
||||||
'GET',
|
|
||||||
'json',
|
|
||||||
200,
|
|
||||||
{
|
{
|
||||||
Authorization: `Bearer ${this.configurationService.get(
|
headers: {
|
||||||
'BETTER_UPTIME_API_KEY'
|
Authorization: `Bearer ${this.configurationService.get(
|
||||||
)}`
|
'BETTER_UPTIME_API_KEY'
|
||||||
|
)}`
|
||||||
|
},
|
||||||
|
// @ts-ignore
|
||||||
|
signal: abortController.signal
|
||||||
}
|
}
|
||||||
);
|
).json<any>();
|
||||||
|
|
||||||
const { data } = await get();
|
|
||||||
return data.attributes.availability / 100;
|
return data.attributes.availability / 100;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'InfoService');
|
Logger.error(error, 'InfoService - Better Stack');
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,15 @@
|
|||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
import { HttpException, Injectable } from '@nestjs/common';
|
import { HttpException, Injectable } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
import * as bent from 'bent';
|
import got from 'got';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LogoService {
|
export class LogoService {
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly symbolProfileService: SymbolProfileService
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -41,15 +43,19 @@ export class LogoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getBuffer(aUrl: string) {
|
private getBuffer(aUrl: string) {
|
||||||
const get = bent(
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
abortController.abort();
|
||||||
|
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||||
|
|
||||||
|
return got(
|
||||||
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,
|
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,
|
||||||
'GET',
|
|
||||||
'buffer',
|
|
||||||
200,
|
|
||||||
{
|
{
|
||||||
'User-Agent': 'request'
|
headers: { 'User-Agent': 'request' },
|
||||||
|
// @ts-ignore
|
||||||
|
signal: abortController.signal
|
||||||
}
|
}
|
||||||
);
|
).buffer();
|
||||||
return get();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -13,7 +13,8 @@ import {
|
|||||||
IsISO8601,
|
IsISO8601,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString
|
IsString,
|
||||||
|
Min
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { isString } from 'lodash';
|
import { isString } from 'lodash';
|
||||||
|
|
||||||
@ -48,9 +49,11 @@ export class CreateOrderDto {
|
|||||||
date: string;
|
date: string;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
fee: number;
|
fee: number;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
quantity: number;
|
quantity: number;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@ -64,6 +67,7 @@ export class CreateOrderDto {
|
|||||||
type: Type;
|
type: Type;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
|
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
|
@ -2,6 +2,7 @@ import { OrderWithAccount } from '@ghostfolio/common/types';
|
|||||||
|
|
||||||
export interface Activities {
|
export interface Activities {
|
||||||
activities: Activity[];
|
activities: Activity[];
|
||||||
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Activity extends OrderWithAccount {
|
export interface Activity extends OrderWithAccount {
|
||||||
|
@ -1,7 +1,10 @@
|
|||||||
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||||
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
@ -23,7 +26,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 { Order as OrderModel } from '@prisma/client';
|
import { Order as OrderModel, Prisma } from '@prisma/client';
|
||||||
import { parseISO } from 'date-fns';
|
import { parseISO } from 'date-fns';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
@ -36,30 +39,23 @@ import { UpdateOrderDto } from './update-order.dto';
|
|||||||
export class OrderController {
|
export class OrderController {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly apiService: ApiService,
|
private readonly apiService: ApiService,
|
||||||
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
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
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Delete()
|
@Delete()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@HasPermission(permissions.deleteOrder)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async deleteOrders(): Promise<number> {
|
public async deleteOrders(): Promise<number> {
|
||||||
if (
|
|
||||||
!hasPermission(this.request.user.permissions, permissions.deleteOrder)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.orderService.deleteOrders({
|
return this.orderService.deleteOrders({
|
||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
|
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
|
||||||
const order = await this.orderService.order({ id });
|
const order = await this.orderService.order({ id });
|
||||||
|
|
||||||
@ -80,14 +76,18 @@ export class OrderController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getAllOrders(
|
public async getAllOrders(
|
||||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||||
@Query('accounts') filterByAccounts?: string,
|
@Query('accounts') filterByAccounts?: string,
|
||||||
@Query('assetClasses') filterByAssetClasses?: string,
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
@Query('tags') filterByTags?: string
|
@Query('skip') skip?: number,
|
||||||
|
@Query('sortColumn') sortColumn?: string,
|
||||||
|
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
|
||||||
|
@Query('tags') filterByTags?: string,
|
||||||
|
@Query('take') take?: number
|
||||||
): Promise<Activities> {
|
): Promise<Activities> {
|
||||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
filterByAccounts,
|
filterByAccounts,
|
||||||
@ -99,31 +99,27 @@ export class OrderController {
|
|||||||
await this.impersonationService.validateImpersonationId(impersonationId);
|
await this.impersonationService.validateImpersonationId(impersonationId);
|
||||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||||
|
|
||||||
const activities = await this.orderService.getOrders({
|
const { activities, count } = await this.orderService.getOrders({
|
||||||
filters,
|
filters,
|
||||||
|
sortColumn,
|
||||||
|
sortDirection,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
includeDrafts: true,
|
includeDrafts: true,
|
||||||
|
skip: isNaN(skip) ? undefined : skip,
|
||||||
|
take: isNaN(take) ? undefined : take,
|
||||||
userId: impersonationUserId || this.request.user.id,
|
userId: impersonationUserId || this.request.user.id,
|
||||||
withExcludedAccounts: true
|
withExcludedAccounts: true
|
||||||
});
|
});
|
||||||
|
|
||||||
return { activities };
|
return { activities, count };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.createOrder)
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> {
|
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> {
|
||||||
if (
|
const order = await this.orderService.createOrder({
|
||||||
!hasPermission(this.request.user.permissions, permissions.createOrder)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.orderService.createOrder({
|
|
||||||
...data,
|
...data,
|
||||||
date: parseISO(data.date),
|
date: parseISO(data.date),
|
||||||
SymbolProfile: {
|
SymbolProfile: {
|
||||||
@ -144,21 +140,32 @@ export class OrderController {
|
|||||||
User: { connect: { id: this.request.user.id } },
|
User: { connect: { id: this.request.user.id } },
|
||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (data.dataSource && !order.isDraft) {
|
||||||
|
// Gather symbol data in the background, if data source is set
|
||||||
|
// (not MANUAL) and not draft
|
||||||
|
this.dataGatheringService.gatherSymbols([
|
||||||
|
{
|
||||||
|
dataSource: data.dataSource,
|
||||||
|
date: order.date,
|
||||||
|
symbol: data.symbol
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.updateOrder)
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
|
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
|
||||||
const originalOrder = await this.orderService.order({
|
const originalOrder = await this.orderService.order({
|
||||||
id
|
id
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (!originalOrder || originalOrder.userId !== this.request.user.id) {
|
||||||
!hasPermission(this.request.user.permissions, permissions.updateOrder) ||
|
|
||||||
!originalOrder ||
|
|
||||||
originalOrder.userId !== this.request.user.id
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
StatusCodes.FORBIDDEN
|
StatusCodes.FORBIDDEN
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
|
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
|
|
||||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
|
@ -7,6 +7,7 @@ import {
|
|||||||
GATHER_ASSET_PROFILE_PROCESS,
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
|
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
||||||
import { Filter } from '@ghostfolio/common/interfaces';
|
import { Filter } from '@ghostfolio/common/interfaces';
|
||||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
@ -24,7 +25,7 @@ import { endOfToday, isAfter } from 'date-fns';
|
|||||||
import { groupBy } from 'lodash';
|
import { groupBy } from 'lodash';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
import { Activity } from './interfaces/activities.interface';
|
import { Activities } from './interfaces/activities.interface';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OrderService {
|
export class OrderService {
|
||||||
@ -36,34 +37,6 @@ export class OrderService {
|
|||||||
private readonly symbolProfileService: SymbolProfileService
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async order(
|
|
||||||
orderWhereUniqueInput: Prisma.OrderWhereUniqueInput
|
|
||||||
): Promise<Order | null> {
|
|
||||||
return this.prismaService.order.findUnique({
|
|
||||||
where: orderWhereUniqueInput
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async orders(params: {
|
|
||||||
include?: Prisma.OrderInclude;
|
|
||||||
skip?: number;
|
|
||||||
take?: number;
|
|
||||||
cursor?: Prisma.OrderWhereUniqueInput;
|
|
||||||
where?: Prisma.OrderWhereInput;
|
|
||||||
orderBy?: Prisma.OrderOrderByWithRelationInput;
|
|
||||||
}): Promise<OrderWithAccount[]> {
|
|
||||||
const { include, skip, take, cursor, where, orderBy } = params;
|
|
||||||
|
|
||||||
return this.prismaService.order.findMany({
|
|
||||||
cursor,
|
|
||||||
include,
|
|
||||||
orderBy,
|
|
||||||
skip,
|
|
||||||
take,
|
|
||||||
where
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async createOrder(
|
public async createOrder(
|
||||||
data: Prisma.OrderCreateInput & {
|
data: Prisma.OrderCreateInput & {
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
@ -96,7 +69,12 @@ export class OrderService {
|
|||||||
const updateAccountBalance = data.updateAccountBalance ?? false;
|
const updateAccountBalance = data.updateAccountBalance ?? false;
|
||||||
const userId = data.userId;
|
const userId = data.userId;
|
||||||
|
|
||||||
if (data.type === 'ITEM' || data.type === 'LIABILITY') {
|
if (
|
||||||
|
data.type === 'FEE' ||
|
||||||
|
data.type === 'INTEREST' ||
|
||||||
|
data.type === 'ITEM' ||
|
||||||
|
data.type === 'LIABILITY'
|
||||||
|
) {
|
||||||
const assetClass = data.assetClass;
|
const assetClass = data.assetClass;
|
||||||
const assetSubClass = data.assetSubClass;
|
const assetSubClass = data.assetSubClass;
|
||||||
currency = data.SymbolProfile.connectOrCreate.create.currency;
|
currency = data.SymbolProfile.connectOrCreate.create.currency;
|
||||||
@ -117,32 +95,21 @@ export class OrderService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.dataGatheringService.addJobToQueue({
|
if (data.SymbolProfile.connectOrCreate.create.dataSource !== 'MANUAL') {
|
||||||
data: {
|
this.dataGatheringService.addJobToQueue({
|
||||||
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
data: {
|
||||||
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
|
||||||
},
|
|
||||||
name: GATHER_ASSET_PROFILE_PROCESS,
|
|
||||||
opts: {
|
|
||||||
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
|
||||||
jobId: `${data.SymbolProfile.connectOrCreate.create.dataSource}-${data.SymbolProfile.connectOrCreate.create.symbol}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const isDraft =
|
|
||||||
data.type === 'LIABILITY'
|
|
||||||
? false
|
|
||||||
: isAfter(data.date as Date, endOfToday());
|
|
||||||
|
|
||||||
if (!isDraft) {
|
|
||||||
// Gather symbol data of order in the background, if not draft
|
|
||||||
this.dataGatheringService.gatherSymbols([
|
|
||||||
{
|
|
||||||
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
||||||
date: <Date>data.date,
|
|
||||||
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
||||||
|
},
|
||||||
|
name: GATHER_ASSET_PROFILE_PROCESS,
|
||||||
|
opts: {
|
||||||
|
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
|
||||||
|
jobId: getAssetProfileIdentifier({
|
||||||
|
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
|
||||||
|
symbol: data.SymbolProfile.connectOrCreate.create.symbol
|
||||||
|
})
|
||||||
}
|
}
|
||||||
]);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
delete data.accountId;
|
delete data.accountId;
|
||||||
@ -162,6 +129,14 @@ export class OrderService {
|
|||||||
|
|
||||||
const orderData: Prisma.OrderCreateInput = data;
|
const orderData: Prisma.OrderCreateInput = data;
|
||||||
|
|
||||||
|
const isDraft =
|
||||||
|
data.type === 'FEE' ||
|
||||||
|
data.type === 'INTEREST' ||
|
||||||
|
data.type === 'ITEM' ||
|
||||||
|
data.type === 'LIABILITY'
|
||||||
|
? false
|
||||||
|
: isAfter(data.date as Date, endOfToday());
|
||||||
|
|
||||||
const order = await this.prismaService.order.create({
|
const order = await this.prismaService.order.create({
|
||||||
data: {
|
data: {
|
||||||
...orderData,
|
...orderData,
|
||||||
@ -204,7 +179,12 @@ export class OrderService {
|
|||||||
where
|
where
|
||||||
});
|
});
|
||||||
|
|
||||||
if (order.type === 'ITEM' || order.type === 'LIABILITY') {
|
if (
|
||||||
|
order.type === 'FEE' ||
|
||||||
|
order.type === 'INTEREST' ||
|
||||||
|
order.type === 'ITEM' ||
|
||||||
|
order.type === 'LIABILITY'
|
||||||
|
) {
|
||||||
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -222,6 +202,10 @@ export class OrderService {
|
|||||||
public async getOrders({
|
public async getOrders({
|
||||||
filters,
|
filters,
|
||||||
includeDrafts = false,
|
includeDrafts = false,
|
||||||
|
skip,
|
||||||
|
sortColumn,
|
||||||
|
sortDirection,
|
||||||
|
take = Number.MAX_SAFE_INTEGER,
|
||||||
types,
|
types,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
userId,
|
userId,
|
||||||
@ -229,11 +213,18 @@ export class OrderService {
|
|||||||
}: {
|
}: {
|
||||||
filters?: Filter[];
|
filters?: Filter[];
|
||||||
includeDrafts?: boolean;
|
includeDrafts?: boolean;
|
||||||
|
skip?: number;
|
||||||
|
sortColumn?: string;
|
||||||
|
sortDirection?: Prisma.SortOrder;
|
||||||
|
take?: number;
|
||||||
types?: TypeOfOrder[];
|
types?: TypeOfOrder[];
|
||||||
userCurrency: string;
|
userCurrency: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
withExcludedAccounts?: boolean;
|
withExcludedAccounts?: boolean;
|
||||||
}): Promise<Activity[]> {
|
}): Promise<Activities> {
|
||||||
|
let orderBy: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput> = [
|
||||||
|
{ date: 'asc' }
|
||||||
|
];
|
||||||
const where: Prisma.OrderWhereInput = { userId };
|
const where: Prisma.OrderWhereInput = { userId };
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -295,6 +286,10 @@ export class OrderService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sortColumn) {
|
||||||
|
orderBy = [{ [sortColumn]: sortDirection }];
|
||||||
|
}
|
||||||
|
|
||||||
if (types) {
|
if (types) {
|
||||||
where.OR = types.map((type) => {
|
where.OR = types.map((type) => {
|
||||||
return {
|
return {
|
||||||
@ -305,8 +300,11 @@ export class OrderService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const [orders, count] = await Promise.all([
|
||||||
await this.orders({
|
this.orders({
|
||||||
|
orderBy,
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
where,
|
where,
|
||||||
include: {
|
include: {
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
@ -318,10 +316,12 @@ export class OrderService {
|
|||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
SymbolProfile: true,
|
SymbolProfile: true,
|
||||||
tags: true
|
tags: true
|
||||||
},
|
}
|
||||||
orderBy: { date: 'asc' }
|
}),
|
||||||
})
|
this.prismaService.order.count({ where })
|
||||||
)
|
]);
|
||||||
|
|
||||||
|
const activities = orders
|
||||||
.filter((order) => {
|
.filter((order) => {
|
||||||
return (
|
return (
|
||||||
withExcludedAccounts ||
|
withExcludedAccounts ||
|
||||||
@ -347,6 +347,16 @@ export class OrderService {
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return { activities, count };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async order(
|
||||||
|
orderWhereUniqueInput: Prisma.OrderWhereUniqueInput
|
||||||
|
): Promise<Order | null> {
|
||||||
|
return this.prismaService.order.findUnique({
|
||||||
|
where: orderWhereUniqueInput
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async updateOrder({
|
public async updateOrder({
|
||||||
@ -375,7 +385,12 @@ export class OrderService {
|
|||||||
|
|
||||||
let isDraft = false;
|
let isDraft = false;
|
||||||
|
|
||||||
if (data.type === 'ITEM' || data.type === 'LIABILITY') {
|
if (
|
||||||
|
data.type === 'FEE' ||
|
||||||
|
data.type === 'INTEREST' ||
|
||||||
|
data.type === 'ITEM' ||
|
||||||
|
data.type === 'LIABILITY'
|
||||||
|
) {
|
||||||
delete data.SymbolProfile.connect;
|
delete data.SymbolProfile.connect;
|
||||||
} else {
|
} else {
|
||||||
delete data.SymbolProfile.update;
|
delete data.SymbolProfile.update;
|
||||||
@ -420,4 +435,24 @@ export class OrderService {
|
|||||||
where
|
where
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async orders(params: {
|
||||||
|
include?: Prisma.OrderInclude;
|
||||||
|
skip?: number;
|
||||||
|
take?: number;
|
||||||
|
cursor?: Prisma.OrderWhereUniqueInput;
|
||||||
|
where?: Prisma.OrderWhereInput;
|
||||||
|
orderBy?: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput>;
|
||||||
|
}): Promise<OrderWithAccount[]> {
|
||||||
|
const { include, skip, take, cursor, where, orderBy } = params;
|
||||||
|
|
||||||
|
return this.prismaService.order.findMany({
|
||||||
|
cursor,
|
||||||
|
include,
|
||||||
|
orderBy,
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
where
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -8,12 +8,12 @@ import {
|
|||||||
import { Transform, TransformFnParams } from 'class-transformer';
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
IsBoolean,
|
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsISO8601,
|
IsISO8601,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString
|
IsString,
|
||||||
|
Min
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { isString } from 'lodash';
|
import { isString } from 'lodash';
|
||||||
|
|
||||||
@ -47,12 +47,14 @@ export class UpdateOrderDto {
|
|||||||
date: string;
|
date: string;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
fee: number;
|
fee: number;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
quantity: number;
|
quantity: number;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@ -66,5 +68,6 @@ export class UpdateOrderDto {
|
|||||||
type: Type;
|
type: Type;
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
}
|
}
|
||||||
|
@ -1,18 +1,17 @@
|
|||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
|
||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
UseGuards
|
UseGuards
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { Platform } from '@prisma/client';
|
import { Platform } from '@prisma/client';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
@ -23,48 +22,30 @@ import { UpdatePlatformDto } from './update-platform.dto';
|
|||||||
|
|
||||||
@Controller('platform')
|
@Controller('platform')
|
||||||
export class PlatformController {
|
export class PlatformController {
|
||||||
public constructor(
|
public constructor(private readonly platformService: PlatformService) {}
|
||||||
private readonly platformService: PlatformService,
|
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async getPlatforms() {
|
public async getPlatforms() {
|
||||||
return this.platformService.getPlatformsWithAccountCount();
|
return this.platformService.getPlatformsWithAccountCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.createPlatform)
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async createPlatform(
|
public async createPlatform(
|
||||||
@Body() data: CreatePlatformDto
|
@Body() data: CreatePlatformDto
|
||||||
): Promise<Platform> {
|
): Promise<Platform> {
|
||||||
if (
|
|
||||||
!hasPermission(this.request.user.permissions, permissions.createPlatform)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return this.platformService.createPlatform(data);
|
return this.platformService.createPlatform(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.updatePlatform)
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async updatePlatform(
|
public async updatePlatform(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() data: UpdatePlatformDto
|
@Body() data: UpdatePlatformDto
|
||||||
) {
|
) {
|
||||||
if (
|
|
||||||
!hasPermission(this.request.user.permissions, permissions.updatePlatform)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const originalPlatform = await this.platformService.getPlatform({
|
const originalPlatform = await this.platformService.getPlatform({
|
||||||
id
|
id
|
||||||
});
|
});
|
||||||
@ -87,17 +68,9 @@ export class PlatformController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@HasPermission(permissions.deletePlatform)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async deletePlatform(@Param('id') id: string) {
|
public async deletePlatform(@Param('id') id: string) {
|
||||||
if (
|
|
||||||
!hasPermission(this.request.user.permissions, permissions.deletePlatform)
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const originalPlatform = await this.platformService.getPlatform({
|
const originalPlatform = await this.platformService.getPlatform({
|
||||||
id
|
id
|
||||||
});
|
});
|
||||||
|
@ -6,6 +6,18 @@ import { Platform, Prisma } from '@prisma/client';
|
|||||||
export class PlatformService {
|
export class PlatformService {
|
||||||
public constructor(private readonly prismaService: PrismaService) {}
|
public constructor(private readonly prismaService: PrismaService) {}
|
||||||
|
|
||||||
|
public async createPlatform(data: Prisma.PlatformCreateInput) {
|
||||||
|
return this.prismaService.platform.create({
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deletePlatform(
|
||||||
|
where: Prisma.PlatformWhereUniqueInput
|
||||||
|
): Promise<Platform> {
|
||||||
|
return this.prismaService.platform.delete({ where });
|
||||||
|
}
|
||||||
|
|
||||||
public async getPlatform(
|
public async getPlatform(
|
||||||
platformWhereUniqueInput: Prisma.PlatformWhereUniqueInput
|
platformWhereUniqueInput: Prisma.PlatformWhereUniqueInput
|
||||||
): Promise<Platform> {
|
): Promise<Platform> {
|
||||||
@ -56,12 +68,6 @@ export class PlatformService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createPlatform(data: Prisma.PlatformCreateInput) {
|
|
||||||
return this.prismaService.platform.create({
|
|
||||||
data
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async updatePlatform({
|
public async updatePlatform({
|
||||||
data,
|
data,
|
||||||
where
|
where
|
||||||
@ -74,10 +80,4 @@ export class PlatformService {
|
|||||||
where
|
where
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deletePlatform(
|
|
||||||
where: Prisma.PlatformWhereUniqueInput
|
|
||||||
): Promise<Platform> {
|
|
||||||
return this.prismaService.platform.delete({ where });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -33,6 +33,15 @@ function mockGetValue(symbol: string, date: Date) {
|
|||||||
|
|
||||||
return { marketPrice: 0 };
|
return { marketPrice: 0 };
|
||||||
|
|
||||||
|
case 'GOOGL':
|
||||||
|
if (isSameDay(parseDate('2023-01-03'), date)) {
|
||||||
|
return { marketPrice: 89.12 };
|
||||||
|
} else if (isSameDay(parseDate('2023-07-10'), date)) {
|
||||||
|
return { marketPrice: 116.45 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { marketPrice: 0 };
|
||||||
|
|
||||||
case 'NOVN.SW':
|
case 'NOVN.SW':
|
||||||
if (isSameDay(parseDate('2022-04-11'), date)) {
|
if (isSameDay(parseDate('2022-04-11'), date)) {
|
||||||
return { marketPrice: 87.8 };
|
return { marketPrice: 87.8 };
|
||||||
@ -61,10 +70,9 @@ export const CurrentRateServiceMock = {
|
|||||||
for (const dataGatheringItem of dataGatheringItems) {
|
for (const dataGatheringItem of dataGatheringItems) {
|
||||||
values.push({
|
values.push({
|
||||||
date,
|
date,
|
||||||
marketPriceInBaseCurrency: mockGetValue(
|
dataSource: dataGatheringItem.dataSource,
|
||||||
dataGatheringItem.symbol,
|
marketPrice: mockGetValue(dataGatheringItem.symbol, date)
|
||||||
date
|
.marketPrice,
|
||||||
).marketPrice,
|
|
||||||
symbol: dataGatheringItem.symbol
|
symbol: dataGatheringItem.symbol
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -74,10 +82,9 @@ export const CurrentRateServiceMock = {
|
|||||||
for (const dataGatheringItem of dataGatheringItems) {
|
for (const dataGatheringItem of dataGatheringItems) {
|
||||||
values.push({
|
values.push({
|
||||||
date,
|
date,
|
||||||
marketPriceInBaseCurrency: mockGetValue(
|
dataSource: dataGatheringItem.dataSource,
|
||||||
dataGatheringItem.symbol,
|
marketPrice: mockGetValue(dataGatheringItem.symbol, date)
|
||||||
date
|
.marketPrice,
|
||||||
).marketPrice,
|
|
||||||
symbol: dataGatheringItem.symbol
|
symbol: dataGatheringItem.symbol
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
import { DataSource, MarketData } from '@prisma/client';
|
import { DataSource, MarketData } from '@prisma/client';
|
||||||
|
|
||||||
import { CurrentRateService } from './current-rate.service';
|
import { CurrentRateService } from './current-rate.service';
|
||||||
@ -25,30 +25,30 @@ jest.mock('@ghostfolio/api/services/market-data/market-data.service', () => {
|
|||||||
getRange: ({
|
getRange: ({
|
||||||
dateRangeEnd,
|
dateRangeEnd,
|
||||||
dateRangeStart,
|
dateRangeStart,
|
||||||
symbols
|
uniqueAssets
|
||||||
}: {
|
}: {
|
||||||
dateRangeEnd: Date;
|
dateRangeEnd: Date;
|
||||||
dateRangeStart: Date;
|
dateRangeStart: Date;
|
||||||
symbols: string[];
|
uniqueAssets: UniqueAsset[];
|
||||||
}) => {
|
}) => {
|
||||||
return Promise.resolve<MarketData[]>([
|
return Promise.resolve<MarketData[]>([
|
||||||
{
|
{
|
||||||
createdAt: dateRangeStart,
|
createdAt: dateRangeStart,
|
||||||
dataSource: DataSource.YAHOO,
|
dataSource: uniqueAssets[0].dataSource,
|
||||||
date: dateRangeStart,
|
date: dateRangeStart,
|
||||||
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
|
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
|
||||||
marketPrice: 1841.823902,
|
marketPrice: 1841.823902,
|
||||||
state: 'CLOSE',
|
state: 'CLOSE',
|
||||||
symbol: symbols[0]
|
symbol: uniqueAssets[0].symbol
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
createdAt: dateRangeEnd,
|
createdAt: dateRangeEnd,
|
||||||
dataSource: DataSource.YAHOO,
|
dataSource: uniqueAssets[0].dataSource,
|
||||||
date: dateRangeEnd,
|
date: dateRangeEnd,
|
||||||
id: '082d6893-df27-4c91-8a5d-092e84315b56',
|
id: '082d6893-df27-4c91-8a5d-092e84315b56',
|
||||||
marketPrice: 1847.839966,
|
marketPrice: 1847.839966,
|
||||||
state: 'CLOSE',
|
state: 'CLOSE',
|
||||||
symbol: symbols[0]
|
symbol: uniqueAssets[0].symbol
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@ -66,7 +66,8 @@ jest.mock(
|
|||||||
initialize: () => Promise.resolve(),
|
initialize: () => Promise.resolve(),
|
||||||
toCurrency: (value: number) => {
|
toCurrency: (value: number) => {
|
||||||
return 1 * value;
|
return 1 * value;
|
||||||
}
|
},
|
||||||
|
getExchangeRates: () => Promise.resolve()
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
@ -86,7 +87,6 @@ jest.mock('@ghostfolio/api/services/property/property.service', () => {
|
|||||||
describe('CurrentRateService', () => {
|
describe('CurrentRateService', () => {
|
||||||
let currentRateService: CurrentRateService;
|
let currentRateService: CurrentRateService;
|
||||||
let dataProviderService: DataProviderService;
|
let dataProviderService: DataProviderService;
|
||||||
let exchangeRateDataService: ExchangeRateDataService;
|
|
||||||
let marketDataService: MarketDataService;
|
let marketDataService: MarketDataService;
|
||||||
let propertyService: PropertyService;
|
let propertyService: PropertyService;
|
||||||
|
|
||||||
@ -101,20 +101,11 @@ describe('CurrentRateService', () => {
|
|||||||
propertyService,
|
propertyService,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
exchangeRateDataService = new ExchangeRateDataService(
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
marketDataService = new MarketDataService(null);
|
|
||||||
|
|
||||||
await exchangeRateDataService.initialize();
|
marketDataService = new MarketDataService(null);
|
||||||
|
|
||||||
currentRateService = new CurrentRateService(
|
currentRateService = new CurrentRateService(
|
||||||
dataProviderService,
|
dataProviderService,
|
||||||
exchangeRateDataService,
|
|
||||||
marketDataService
|
marketDataService
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@ -122,21 +113,20 @@ describe('CurrentRateService', () => {
|
|||||||
it('getValues', async () => {
|
it('getValues', async () => {
|
||||||
expect(
|
expect(
|
||||||
await currentRateService.getValues({
|
await currentRateService.getValues({
|
||||||
currencies: { AMZN: 'USD' },
|
|
||||||
dataGatheringItems: [{ dataSource: DataSource.YAHOO, symbol: 'AMZN' }],
|
dataGatheringItems: [{ dataSource: DataSource.YAHOO, symbol: 'AMZN' }],
|
||||||
dateQuery: {
|
dateQuery: {
|
||||||
lt: new Date(Date.UTC(2020, 0, 2, 0, 0, 0)),
|
lt: new Date(Date.UTC(2020, 0, 2, 0, 0, 0)),
|
||||||
gte: new Date(Date.UTC(2020, 0, 1, 0, 0, 0))
|
gte: new Date(Date.UTC(2020, 0, 1, 0, 0, 0))
|
||||||
},
|
}
|
||||||
userCurrency: 'CHF'
|
|
||||||
})
|
})
|
||||||
).toMatchObject<GetValuesObject>({
|
).toMatchObject<GetValuesObject>({
|
||||||
dataProviderInfos: [],
|
dataProviderInfos: [],
|
||||||
errors: [],
|
errors: [],
|
||||||
values: [
|
values: [
|
||||||
{
|
{
|
||||||
|
dataSource: 'YAHOO',
|
||||||
date: undefined,
|
date: undefined,
|
||||||
marketPriceInBaseCurrency: 1841.823902,
|
marketPrice: 1841.823902,
|
||||||
symbol: 'AMZN'
|
symbol: 'AMZN'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@ -1,8 +1,11 @@
|
|||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
import { resetHours } from '@ghostfolio/common/helper';
|
import { resetHours } from '@ghostfolio/common/helper';
|
||||||
import { DataProviderInfo, ResponseError } from '@ghostfolio/common/interfaces';
|
import {
|
||||||
|
DataProviderInfo,
|
||||||
|
ResponseError,
|
||||||
|
UniqueAsset
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { isBefore, isToday } from 'date-fns';
|
import { isBefore, isToday } from 'date-fns';
|
||||||
import { flatten, isEmpty, uniqBy } from 'lodash';
|
import { flatten, isEmpty, uniqBy } from 'lodash';
|
||||||
@ -15,17 +18,15 @@ import { GetValuesParams } from './interfaces/get-values-params.interface';
|
|||||||
export class CurrentRateService {
|
export class CurrentRateService {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
|
||||||
private readonly marketDataService: MarketDataService
|
private readonly marketDataService: MarketDataService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async getValues({
|
public async getValues({
|
||||||
currencies,
|
|
||||||
dataGatheringItems,
|
dataGatheringItems,
|
||||||
dateQuery,
|
dateQuery
|
||||||
userCurrency
|
|
||||||
}: GetValuesParams): Promise<GetValuesObject> {
|
}: GetValuesParams): Promise<GetValuesObject> {
|
||||||
const dataProviderInfos: DataProviderInfo[] = [];
|
const dataProviderInfos: DataProviderInfo[] = [];
|
||||||
|
|
||||||
const includeToday =
|
const includeToday =
|
||||||
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
|
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
|
||||||
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
|
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
|
||||||
@ -41,6 +42,7 @@ export class CurrentRateService {
|
|||||||
.getQuotes({ items: dataGatheringItems })
|
.getQuotes({ items: dataGatheringItems })
|
||||||
.then((dataResultProvider) => {
|
.then((dataResultProvider) => {
|
||||||
const result: GetValueObject[] = [];
|
const result: GetValueObject[] = [];
|
||||||
|
|
||||||
for (const dataGatheringItem of dataGatheringItems) {
|
for (const dataGatheringItem of dataGatheringItems) {
|
||||||
if (
|
if (
|
||||||
dataResultProvider?.[dataGatheringItem.symbol]?.dataProviderInfo
|
dataResultProvider?.[dataGatheringItem.symbol]?.dataProviderInfo
|
||||||
@ -52,14 +54,10 @@ export class CurrentRateService {
|
|||||||
|
|
||||||
if (dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice) {
|
if (dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice) {
|
||||||
result.push({
|
result.push({
|
||||||
|
dataSource: dataGatheringItem.dataSource,
|
||||||
date: today,
|
date: today,
|
||||||
marketPriceInBaseCurrency:
|
marketPrice:
|
||||||
this.exchangeRateDataService.toCurrency(
|
dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice,
|
||||||
dataResultProvider?.[dataGatheringItem.symbol]
|
|
||||||
?.marketPrice,
|
|
||||||
dataResultProvider?.[dataGatheringItem.symbol]?.currency,
|
|
||||||
userCurrency
|
|
||||||
),
|
|
||||||
symbol: dataGatheringItem.symbol
|
symbol: dataGatheringItem.symbol
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@ -75,27 +73,25 @@ export class CurrentRateService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const symbols = dataGatheringItems.map((dataGatheringItem) => {
|
const uniqueAssets: UniqueAsset[] = dataGatheringItems.map(
|
||||||
return dataGatheringItem.symbol;
|
({ dataSource, symbol }) => {
|
||||||
});
|
return { dataSource, symbol };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
promises.push(
|
promises.push(
|
||||||
this.marketDataService
|
this.marketDataService
|
||||||
.getRange({
|
.getRange({
|
||||||
dateQuery,
|
dateQuery,
|
||||||
symbols
|
uniqueAssets
|
||||||
})
|
})
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
return data.map((marketDataItem) => {
|
return data.map(({ dataSource, date, marketPrice, symbol }) => {
|
||||||
return {
|
return {
|
||||||
date: marketDataItem.date,
|
dataSource,
|
||||||
marketPriceInBaseCurrency:
|
date,
|
||||||
this.exchangeRateDataService.toCurrency(
|
marketPrice,
|
||||||
marketDataItem.marketPrice,
|
symbol
|
||||||
currencies[marketDataItem.symbol],
|
|
||||||
userCurrency
|
|
||||||
),
|
|
||||||
symbol: marketDataItem.symbol
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
@ -112,7 +108,7 @@ export class CurrentRateService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!isEmpty(quoteErrors)) {
|
if (!isEmpty(quoteErrors)) {
|
||||||
for (const { symbol } of quoteErrors) {
|
for (const { dataSource, symbol } of quoteErrors) {
|
||||||
try {
|
try {
|
||||||
// If missing quote, fallback to the latest available historical market price
|
// If missing quote, fallback to the latest available historical market price
|
||||||
let value: GetValueObject = response.values.find((currentValue) => {
|
let value: GetValueObject = response.values.find((currentValue) => {
|
||||||
@ -121,9 +117,10 @@ export class CurrentRateService {
|
|||||||
|
|
||||||
if (!value) {
|
if (!value) {
|
||||||
value = {
|
value = {
|
||||||
|
dataSource,
|
||||||
symbol,
|
symbol,
|
||||||
date: today,
|
date: today,
|
||||||
marketPriceInBaseCurrency: 0
|
marketPrice: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
response.values.push(value);
|
response.values.push(value);
|
||||||
@ -131,10 +128,7 @@ export class CurrentRateService {
|
|||||||
|
|
||||||
const [latestValue] = response.values
|
const [latestValue] = response.values
|
||||||
.filter((currentValue) => {
|
.filter((currentValue) => {
|
||||||
return (
|
return currentValue.symbol === symbol && currentValue.marketPrice;
|
||||||
currentValue.symbol === symbol &&
|
|
||||||
currentValue.marketPriceInBaseCurrency
|
|
||||||
);
|
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
if (a.date < b.date) {
|
if (a.date < b.date) {
|
||||||
@ -148,8 +142,7 @@ export class CurrentRateService {
|
|||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
value.marketPriceInBaseCurrency =
|
value.marketPrice = latestValue.marketPrice;
|
||||||
latestValue.marketPriceInBaseCurrency;
|
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,10 +4,15 @@ import Big from 'big.js';
|
|||||||
export interface CurrentPositions extends ResponseError {
|
export interface CurrentPositions extends ResponseError {
|
||||||
positions: TimelinePosition[];
|
positions: TimelinePosition[];
|
||||||
grossPerformance: Big;
|
grossPerformance: Big;
|
||||||
|
grossPerformanceWithCurrencyEffect: Big;
|
||||||
grossPerformancePercentage: Big;
|
grossPerformancePercentage: Big;
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: Big;
|
||||||
netAnnualizedPerformance?: Big;
|
netAnnualizedPerformance?: Big;
|
||||||
|
netAnnualizedPerformanceWithCurrencyEffect?: Big;
|
||||||
netPerformance: Big;
|
netPerformance: Big;
|
||||||
|
netPerformanceWithCurrencyEffect: Big;
|
||||||
netPerformancePercentage: Big;
|
netPerformancePercentage: Big;
|
||||||
|
netPerformancePercentageWithCurrencyEffect: Big;
|
||||||
currentValue: Big;
|
currentValue: Big;
|
||||||
totalInvestment: Big;
|
totalInvestment: Big;
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
export interface GetValueObject {
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
|
export interface GetValueObject extends UniqueAsset {
|
||||||
date: Date;
|
date: Date;
|
||||||
marketPriceInBaseCurrency: number;
|
marketPrice: number;
|
||||||
symbol: string;
|
|
||||||
}
|
}
|
||||||
|
@ -3,8 +3,6 @@ import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfac
|
|||||||
import { DateQuery } from './date-query.interface';
|
import { DateQuery } from './date-query.interface';
|
||||||
|
|
||||||
export interface GetValuesParams {
|
export interface GetValuesParams {
|
||||||
currencies: { [symbol: string]: string };
|
|
||||||
dataGatheringItems: IDataGatheringItem[];
|
dataGatheringItems: IDataGatheringItem[];
|
||||||
dateQuery: DateQuery;
|
dateQuery: DateQuery;
|
||||||
userCurrency: string;
|
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,9 @@
|
|||||||
|
import Big from 'big.js';
|
||||||
|
|
||||||
import { PortfolioOrder } from './portfolio-order.interface';
|
import { PortfolioOrder } from './portfolio-order.interface';
|
||||||
|
|
||||||
export interface PortfolioOrderItem extends PortfolioOrder {
|
export interface PortfolioOrderItem extends PortfolioOrder {
|
||||||
itemType?: '' | 'start' | 'end';
|
itemType?: '' | 'start' | 'end';
|
||||||
|
unitPriceInBaseCurrency?: Big;
|
||||||
|
unitPriceInBaseCurrencyWithCurrencyEffect?: Big;
|
||||||
}
|
}
|
||||||
|
@ -14,6 +14,8 @@ export interface PortfolioPositionDetail {
|
|||||||
firstBuyDate: string;
|
firstBuyDate: string;
|
||||||
grossPerformance: number;
|
grossPerformance: number;
|
||||||
grossPerformancePercent: number;
|
grossPerformancePercent: number;
|
||||||
|
grossPerformancePercentWithCurrencyEffect: number;
|
||||||
|
grossPerformanceWithCurrencyEffect: number;
|
||||||
historicalData: HistoricalDataItem[];
|
historicalData: HistoricalDataItem[];
|
||||||
investment: number;
|
investment: number;
|
||||||
marketPrice: number;
|
marketPrice: number;
|
||||||
@ -21,6 +23,8 @@ export interface PortfolioPositionDetail {
|
|||||||
minPrice: number;
|
minPrice: number;
|
||||||
netPerformance: number;
|
netPerformance: number;
|
||||||
netPerformancePercent: number;
|
netPerformancePercent: number;
|
||||||
|
netPerformancePercentWithCurrencyEffect: number;
|
||||||
|
netPerformanceWithCurrencyEffect: number;
|
||||||
orders: OrderWithAccount[];
|
orders: OrderWithAccount[];
|
||||||
quantity: number;
|
quantity: number;
|
||||||
SymbolProfile: EnhancedSymbolProfile;
|
SymbolProfile: EnhancedSymbolProfile;
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
|
|
||||||
@ -16,15 +17,24 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
|||||||
|
|
||||||
describe('PortfolioCalculator', () => {
|
describe('PortfolioCalculator', () => {
|
||||||
let currentRateService: CurrentRateService;
|
let currentRateService: CurrentRateService;
|
||||||
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
currentRateService = new CurrentRateService(null, null, null);
|
currentRateService = new CurrentRateService(null, null);
|
||||||
|
|
||||||
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('get current positions', () => {
|
describe('get current positions', () => {
|
||||||
it.only('with BALN.SW buy and sell', async () => {
|
it.only('with BALN.SW buy and sell', async () => {
|
||||||
const portfolioCalculator = new PortfolioCalculator({
|
const portfolioCalculator = new PortfolioCalculator({
|
||||||
currentRateService,
|
currentRateService,
|
||||||
|
exchangeRateDataService,
|
||||||
currency: 'CHF',
|
currency: 'CHF',
|
||||||
orders: [
|
orders: [
|
||||||
{
|
{
|
||||||
@ -74,9 +84,17 @@ describe('PortfolioCalculator', () => {
|
|||||||
errors: [],
|
errors: [],
|
||||||
grossPerformance: new Big('-12.6'),
|
grossPerformance: new Big('-12.6'),
|
||||||
grossPerformancePercentage: new Big('-0.0440867739678096571'),
|
grossPerformancePercentage: new Big('-0.0440867739678096571'),
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'-0.0440867739678096571'
|
||||||
|
),
|
||||||
|
grossPerformanceWithCurrencyEffect: new Big('-12.6'),
|
||||||
hasErrors: false,
|
hasErrors: false,
|
||||||
netPerformance: new Big('-15.8'),
|
netPerformance: new Big('-15.8'),
|
||||||
netPerformancePercentage: new Big('-0.0552834149755073478'),
|
netPerformancePercentage: new Big('-0.0552834149755073478'),
|
||||||
|
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'-0.0552834149755073478'
|
||||||
|
),
|
||||||
|
netPerformanceWithCurrencyEffect: new Big('-15.8'),
|
||||||
positions: [
|
positions: [
|
||||||
{
|
{
|
||||||
averagePrice: new Big('0'),
|
averagePrice: new Big('0'),
|
||||||
@ -86,16 +104,29 @@ describe('PortfolioCalculator', () => {
|
|||||||
firstBuyDate: '2021-11-22',
|
firstBuyDate: '2021-11-22',
|
||||||
grossPerformance: new Big('-12.6'),
|
grossPerformance: new Big('-12.6'),
|
||||||
grossPerformancePercentage: new Big('-0.0440867739678096571'),
|
grossPerformancePercentage: new Big('-0.0440867739678096571'),
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'-0.0440867739678096571'
|
||||||
|
),
|
||||||
|
grossPerformanceWithCurrencyEffect: new Big('-12.6'),
|
||||||
investment: new Big('0'),
|
investment: new Big('0'),
|
||||||
|
investmentWithCurrencyEffect: new Big('0'),
|
||||||
netPerformance: new Big('-15.8'),
|
netPerformance: new Big('-15.8'),
|
||||||
netPerformancePercentage: new Big('-0.0552834149755073478'),
|
netPerformancePercentage: new Big('-0.0552834149755073478'),
|
||||||
|
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'-0.0552834149755073478'
|
||||||
|
),
|
||||||
|
netPerformanceWithCurrencyEffect: new Big('-15.8'),
|
||||||
marketPrice: 148.9,
|
marketPrice: 148.9,
|
||||||
|
marketPriceInBaseCurrency: 148.9,
|
||||||
quantity: new Big('0'),
|
quantity: new Big('0'),
|
||||||
symbol: 'BALN.SW',
|
symbol: 'BALN.SW',
|
||||||
|
timeWeightedInvestment: new Big('285.8'),
|
||||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big('285.8'),
|
||||||
transactionCount: 2
|
transactionCount: 2
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
totalInvestment: new Big('0')
|
totalInvestment: new Big('0'),
|
||||||
|
totalInvestmentWithCurrencyEffect: new Big('0')
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(investments).toEqual([
|
expect(investments).toEqual([
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
|
|
||||||
@ -16,15 +17,24 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
|||||||
|
|
||||||
describe('PortfolioCalculator', () => {
|
describe('PortfolioCalculator', () => {
|
||||||
let currentRateService: CurrentRateService;
|
let currentRateService: CurrentRateService;
|
||||||
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
currentRateService = new CurrentRateService(null, null, null);
|
currentRateService = new CurrentRateService(null, null);
|
||||||
|
|
||||||
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('get current positions', () => {
|
describe('get current positions', () => {
|
||||||
it.only('with BALN.SW buy', async () => {
|
it.only('with BALN.SW buy', async () => {
|
||||||
const portfolioCalculator = new PortfolioCalculator({
|
const portfolioCalculator = new PortfolioCalculator({
|
||||||
currentRateService,
|
currentRateService,
|
||||||
|
exchangeRateDataService,
|
||||||
currency: 'CHF',
|
currency: 'CHF',
|
||||||
orders: [
|
orders: [
|
||||||
{
|
{
|
||||||
@ -63,9 +73,17 @@ describe('PortfolioCalculator', () => {
|
|||||||
errors: [],
|
errors: [],
|
||||||
grossPerformance: new Big('24.6'),
|
grossPerformance: new Big('24.6'),
|
||||||
grossPerformancePercentage: new Big('0.09004392386530014641'),
|
grossPerformancePercentage: new Big('0.09004392386530014641'),
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'0.09004392386530014641'
|
||||||
|
),
|
||||||
|
grossPerformanceWithCurrencyEffect: new Big('24.6'),
|
||||||
hasErrors: false,
|
hasErrors: false,
|
||||||
netPerformance: new Big('23.05'),
|
netPerformance: new Big('23.05'),
|
||||||
netPerformancePercentage: new Big('0.08437042459736456808'),
|
netPerformancePercentage: new Big('0.08437042459736456808'),
|
||||||
|
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'0.08437042459736456808'
|
||||||
|
),
|
||||||
|
netPerformanceWithCurrencyEffect: new Big('23.05'),
|
||||||
positions: [
|
positions: [
|
||||||
{
|
{
|
||||||
averagePrice: new Big('136.6'),
|
averagePrice: new Big('136.6'),
|
||||||
@ -75,16 +93,29 @@ describe('PortfolioCalculator', () => {
|
|||||||
firstBuyDate: '2021-11-30',
|
firstBuyDate: '2021-11-30',
|
||||||
grossPerformance: new Big('24.6'),
|
grossPerformance: new Big('24.6'),
|
||||||
grossPerformancePercentage: new Big('0.09004392386530014641'),
|
grossPerformancePercentage: new Big('0.09004392386530014641'),
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'0.09004392386530014641'
|
||||||
|
),
|
||||||
|
grossPerformanceWithCurrencyEffect: new Big('24.6'),
|
||||||
investment: new Big('273.2'),
|
investment: new Big('273.2'),
|
||||||
|
investmentWithCurrencyEffect: new Big('273.2'),
|
||||||
netPerformance: new Big('23.05'),
|
netPerformance: new Big('23.05'),
|
||||||
netPerformancePercentage: new Big('0.08437042459736456808'),
|
netPerformancePercentage: new Big('0.08437042459736456808'),
|
||||||
|
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'0.08437042459736456808'
|
||||||
|
),
|
||||||
|
netPerformanceWithCurrencyEffect: new Big('23.05'),
|
||||||
marketPrice: 148.9,
|
marketPrice: 148.9,
|
||||||
|
marketPriceInBaseCurrency: 148.9,
|
||||||
quantity: new Big('2'),
|
quantity: new Big('2'),
|
||||||
symbol: 'BALN.SW',
|
symbol: 'BALN.SW',
|
||||||
|
timeWeightedInvestment: new Big('273.2'),
|
||||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big('273.2'),
|
||||||
transactionCount: 1
|
transactionCount: 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
totalInvestment: new Big('273.2')
|
totalInvestment: new Big('273.2'),
|
||||||
|
totalInvestmentWithCurrencyEffect: new Big('273.2')
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(investments).toEqual([
|
expect(investments).toEqual([
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
|
|
||||||
@ -14,21 +16,42 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
jest.mock(
|
||||||
|
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
|
||||||
|
() => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
ExchangeRateDataService: jest.fn().mockImplementation(() => {
|
||||||
|
return ExchangeRateDataServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
describe('PortfolioCalculator', () => {
|
describe('PortfolioCalculator', () => {
|
||||||
let currentRateService: CurrentRateService;
|
let currentRateService: CurrentRateService;
|
||||||
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
currentRateService = new CurrentRateService(null, null, null);
|
currentRateService = new CurrentRateService(null, null);
|
||||||
|
|
||||||
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('get current positions', () => {
|
describe('get current positions', () => {
|
||||||
it.only('with BTCUSD buy and sell partially', async () => {
|
it.only('with BTCUSD buy and sell partially', async () => {
|
||||||
const portfolioCalculator = new PortfolioCalculator({
|
const portfolioCalculator = new PortfolioCalculator({
|
||||||
currentRateService,
|
currentRateService,
|
||||||
|
exchangeRateDataService,
|
||||||
currency: 'CHF',
|
currency: 'CHF',
|
||||||
orders: [
|
orders: [
|
||||||
{
|
{
|
||||||
currency: 'CHF',
|
currency: 'USD',
|
||||||
date: '2015-01-01',
|
date: '2015-01-01',
|
||||||
dataSource: 'YAHOO',
|
dataSource: 'YAHOO',
|
||||||
fee: new Big(0),
|
fee: new Big(0),
|
||||||
@ -39,7 +62,7 @@ describe('PortfolioCalculator', () => {
|
|||||||
unitPrice: new Big(320.43)
|
unitPrice: new Big(320.43)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
currency: 'CHF',
|
currency: 'USD',
|
||||||
date: '2017-12-31',
|
date: '2017-12-31',
|
||||||
dataSource: 'YAHOO',
|
dataSource: 'YAHOO',
|
||||||
fee: new Big(0),
|
fee: new Big(0),
|
||||||
@ -70,32 +93,60 @@ describe('PortfolioCalculator', () => {
|
|||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
|
|
||||||
expect(currentPositions).toEqual({
|
expect(currentPositions).toEqual({
|
||||||
currentValue: new Big('13657.2'),
|
currentValue: new Big('13298.425356'),
|
||||||
errors: [],
|
errors: [],
|
||||||
grossPerformance: new Big('27172.74'),
|
grossPerformance: new Big('27172.74'),
|
||||||
grossPerformancePercentage: new Big('42.40043067128546016291'),
|
grossPerformancePercentage: new Big('42.41978276196153750666'),
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'41.6401219622042072686'
|
||||||
|
),
|
||||||
|
grossPerformanceWithCurrencyEffect: new Big('26516.208701400000064086'),
|
||||||
hasErrors: false,
|
hasErrors: false,
|
||||||
netPerformance: new Big('27172.74'),
|
netPerformance: new Big('27172.74'),
|
||||||
netPerformancePercentage: new Big('42.40043067128546016291'),
|
netPerformancePercentage: new Big('42.41978276196153750666'),
|
||||||
|
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'41.6401219622042072686'
|
||||||
|
),
|
||||||
|
netPerformanceWithCurrencyEffect: new Big('26516.208701400000064086'),
|
||||||
positions: [
|
positions: [
|
||||||
{
|
{
|
||||||
averagePrice: new Big('320.43'),
|
averagePrice: new Big('320.43'),
|
||||||
currency: 'CHF',
|
currency: 'USD',
|
||||||
dataSource: 'YAHOO',
|
dataSource: 'YAHOO',
|
||||||
fee: new Big('0'),
|
fee: new Big('0'),
|
||||||
firstBuyDate: '2015-01-01',
|
firstBuyDate: '2015-01-01',
|
||||||
grossPerformance: new Big('27172.74'),
|
grossPerformance: new Big('27172.74'),
|
||||||
grossPerformancePercentage: new Big('42.40043067128546016291'),
|
grossPerformancePercentage: new Big('42.41978276196153750666'),
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'41.6401219622042072686'
|
||||||
|
),
|
||||||
|
grossPerformanceWithCurrencyEffect: new Big(
|
||||||
|
'26516.208701400000064086'
|
||||||
|
),
|
||||||
investment: new Big('320.43'),
|
investment: new Big('320.43'),
|
||||||
netPerformance: new Big('27172.74'),
|
investmentWithCurrencyEffect: new Big('318.542667299999967957'),
|
||||||
netPerformancePercentage: new Big('42.40043067128546016291'),
|
|
||||||
marketPrice: 13657.2,
|
marketPrice: 13657.2,
|
||||||
|
marketPriceInBaseCurrency: 13298.425356,
|
||||||
|
netPerformance: new Big('27172.74'),
|
||||||
|
netPerformancePercentage: new Big('42.41978276196153750666'),
|
||||||
|
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'41.6401219622042072686'
|
||||||
|
),
|
||||||
|
netPerformanceWithCurrencyEffect: new Big(
|
||||||
|
'26516.208701400000064086'
|
||||||
|
),
|
||||||
quantity: new Big('1'),
|
quantity: new Big('1'),
|
||||||
symbol: 'BTCUSD',
|
symbol: 'BTCUSD',
|
||||||
|
tags: undefined,
|
||||||
|
timeWeightedInvestment: new Big('640.56763686131386861314'),
|
||||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big(
|
||||||
|
'636.79469348020066587024'
|
||||||
|
),
|
||||||
transactionCount: 2
|
transactionCount: 2
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
totalInvestment: new Big('320.43')
|
totalInvestment: new Big('320.43'),
|
||||||
|
totalInvestmentWithCurrencyEffect: new Big('318.542667299999967957')
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(investments).toEqual([
|
expect(investments).toEqual([
|
||||||
|
@ -0,0 +1,144 @@
|
|||||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
|
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
|
||||||
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
|
import Big from 'big.js';
|
||||||
|
|
||||||
|
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||||
|
import { PortfolioCalculator } from './portfolio-calculator';
|
||||||
|
|
||||||
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||||
|
return CurrentRateServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock(
|
||||||
|
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
|
||||||
|
() => {
|
||||||
|
return {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
ExchangeRateDataService: jest.fn().mockImplementation(() => {
|
||||||
|
return ExchangeRateDataServiceMock;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('PortfolioCalculator', () => {
|
||||||
|
let currentRateService: CurrentRateService;
|
||||||
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
currentRateService = new CurrentRateService(null, null);
|
||||||
|
|
||||||
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get current positions', () => {
|
||||||
|
it.only('with GOOGL buy', async () => {
|
||||||
|
const portfolioCalculator = new PortfolioCalculator({
|
||||||
|
currentRateService,
|
||||||
|
exchangeRateDataService,
|
||||||
|
currency: 'CHF',
|
||||||
|
orders: [
|
||||||
|
{
|
||||||
|
currency: 'USD',
|
||||||
|
date: '2023-01-03',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
fee: new Big(1),
|
||||||
|
name: 'Alphabet Inc.',
|
||||||
|
quantity: new Big(1),
|
||||||
|
symbol: 'GOOGL',
|
||||||
|
type: 'BUY',
|
||||||
|
unitPrice: new Big(89.12)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
portfolioCalculator.computeTransactionPoints();
|
||||||
|
|
||||||
|
const spy = jest
|
||||||
|
.spyOn(Date, 'now')
|
||||||
|
.mockImplementation(() => parseDate('2023-07-10').getTime());
|
||||||
|
|
||||||
|
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||||
|
parseDate('2023-01-03')
|
||||||
|
);
|
||||||
|
|
||||||
|
const investments = portfolioCalculator.getInvestments();
|
||||||
|
|
||||||
|
const investmentsByMonth =
|
||||||
|
portfolioCalculator.getInvestmentsByGroup('month');
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
|
|
||||||
|
expect(currentPositions).toEqual({
|
||||||
|
currentValue: new Big('103.10483'),
|
||||||
|
errors: [],
|
||||||
|
grossPerformance: new Big('27.33'),
|
||||||
|
grossPerformancePercentage: new Big('0.3066651705565529623'),
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'0.25235044599563974109'
|
||||||
|
),
|
||||||
|
grossPerformanceWithCurrencyEffect: new Big('20.775774'),
|
||||||
|
hasErrors: false,
|
||||||
|
netPerformance: new Big('26.33'),
|
||||||
|
netPerformancePercentage: new Big('0.29544434470377019749'),
|
||||||
|
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'0.24112962014285697628'
|
||||||
|
),
|
||||||
|
netPerformanceWithCurrencyEffect: new Big('19.851974'),
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
averagePrice: new Big('89.12'),
|
||||||
|
currency: 'USD',
|
||||||
|
dataSource: 'YAHOO',
|
||||||
|
fee: new Big('1'),
|
||||||
|
firstBuyDate: '2023-01-03',
|
||||||
|
grossPerformance: new Big('27.33'),
|
||||||
|
grossPerformancePercentage: new Big('0.3066651705565529623'),
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'0.25235044599563974109'
|
||||||
|
),
|
||||||
|
grossPerformanceWithCurrencyEffect: new Big('20.775774'),
|
||||||
|
investment: new Big('89.12'),
|
||||||
|
investmentWithCurrencyEffect: new Big('82.329056'),
|
||||||
|
netPerformance: new Big('26.33'),
|
||||||
|
netPerformancePercentage: new Big('0.29544434470377019749'),
|
||||||
|
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'0.24112962014285697628'
|
||||||
|
),
|
||||||
|
netPerformanceWithCurrencyEffect: new Big('19.851974'),
|
||||||
|
marketPrice: 116.45,
|
||||||
|
marketPriceInBaseCurrency: 103.10483,
|
||||||
|
quantity: new Big('1'),
|
||||||
|
symbol: 'GOOGL',
|
||||||
|
tags: undefined,
|
||||||
|
timeWeightedInvestment: new Big('89.12'),
|
||||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big('82.329056'),
|
||||||
|
transactionCount: 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
totalInvestment: new Big('89.12'),
|
||||||
|
totalInvestmentWithCurrencyEffect: new Big('82.329056')
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(investments).toEqual([
|
||||||
|
{ date: '2023-01-03', investment: new Big('89.12') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(investmentsByMonth).toEqual([
|
||||||
|
{ date: '2023-01-01', investment: new Big('89.12') }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -1,4 +1,5 @@
|
|||||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
|
|
||||||
@ -16,15 +17,24 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
|||||||
|
|
||||||
describe('PortfolioCalculator', () => {
|
describe('PortfolioCalculator', () => {
|
||||||
let currentRateService: CurrentRateService;
|
let currentRateService: CurrentRateService;
|
||||||
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
currentRateService = new CurrentRateService(null, null, null);
|
currentRateService = new CurrentRateService(null, null);
|
||||||
|
|
||||||
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('get current positions', () => {
|
describe('get current positions', () => {
|
||||||
it('with no orders', async () => {
|
it('with no orders', async () => {
|
||||||
const portfolioCalculator = new PortfolioCalculator({
|
const portfolioCalculator = new PortfolioCalculator({
|
||||||
currentRateService,
|
currentRateService,
|
||||||
|
exchangeRateDataService,
|
||||||
currency: 'CHF',
|
currency: 'CHF',
|
||||||
orders: []
|
orders: []
|
||||||
});
|
});
|
||||||
@ -50,9 +60,13 @@ describe('PortfolioCalculator', () => {
|
|||||||
currentValue: new Big(0),
|
currentValue: new Big(0),
|
||||||
grossPerformance: new Big(0),
|
grossPerformance: new Big(0),
|
||||||
grossPerformancePercentage: new Big(0),
|
grossPerformancePercentage: new Big(0),
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: new Big(0),
|
||||||
|
grossPerformanceWithCurrencyEffect: new Big(0),
|
||||||
hasErrors: false,
|
hasErrors: false,
|
||||||
netPerformance: new Big(0),
|
netPerformance: new Big(0),
|
||||||
netPerformancePercentage: new Big(0),
|
netPerformancePercentage: new Big(0),
|
||||||
|
netPerformancePercentageWithCurrencyEffect: new Big(0),
|
||||||
|
netPerformanceWithCurrencyEffect: new Big(0),
|
||||||
positions: [],
|
positions: [],
|
||||||
totalInvestment: new Big(0)
|
totalInvestment: new Big(0)
|
||||||
});
|
});
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
|
|
||||||
@ -16,15 +17,24 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
|||||||
|
|
||||||
describe('PortfolioCalculator', () => {
|
describe('PortfolioCalculator', () => {
|
||||||
let currentRateService: CurrentRateService;
|
let currentRateService: CurrentRateService;
|
||||||
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
currentRateService = new CurrentRateService(null, null, null);
|
currentRateService = new CurrentRateService(null, null);
|
||||||
|
|
||||||
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('get current positions', () => {
|
describe('get current positions', () => {
|
||||||
it.only('with NOVN.SW buy and sell partially', async () => {
|
it.only('with NOVN.SW buy and sell partially', async () => {
|
||||||
const portfolioCalculator = new PortfolioCalculator({
|
const portfolioCalculator = new PortfolioCalculator({
|
||||||
currentRateService,
|
currentRateService,
|
||||||
|
exchangeRateDataService,
|
||||||
currency: 'CHF',
|
currency: 'CHF',
|
||||||
orders: [
|
orders: [
|
||||||
{
|
{
|
||||||
@ -73,10 +83,18 @@ describe('PortfolioCalculator', () => {
|
|||||||
currentValue: new Big('87.8'),
|
currentValue: new Big('87.8'),
|
||||||
errors: [],
|
errors: [],
|
||||||
grossPerformance: new Big('21.93'),
|
grossPerformance: new Big('21.93'),
|
||||||
grossPerformancePercentage: new Big('0.14465699208443271768'),
|
grossPerformancePercentage: new Big('0.15113417083448194384'),
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'0.15113417083448194384'
|
||||||
|
),
|
||||||
|
grossPerformanceWithCurrencyEffect: new Big('21.93'),
|
||||||
hasErrors: false,
|
hasErrors: false,
|
||||||
netPerformance: new Big('17.68'),
|
netPerformance: new Big('17.68'),
|
||||||
netPerformancePercentage: new Big('0.11662269129287598945'),
|
netPerformancePercentage: new Big('0.12184460284330327256'),
|
||||||
|
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'0.12184460284330327256'
|
||||||
|
),
|
||||||
|
netPerformanceWithCurrencyEffect: new Big('17.68'),
|
||||||
positions: [
|
positions: [
|
||||||
{
|
{
|
||||||
averagePrice: new Big('75.80'),
|
averagePrice: new Big('75.80'),
|
||||||
@ -85,17 +103,32 @@ describe('PortfolioCalculator', () => {
|
|||||||
fee: new Big('4.25'),
|
fee: new Big('4.25'),
|
||||||
firstBuyDate: '2022-03-07',
|
firstBuyDate: '2022-03-07',
|
||||||
grossPerformance: new Big('21.93'),
|
grossPerformance: new Big('21.93'),
|
||||||
grossPerformancePercentage: new Big('0.14465699208443271768'),
|
grossPerformancePercentage: new Big('0.15113417083448194384'),
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'0.15113417083448194384'
|
||||||
|
),
|
||||||
|
grossPerformanceWithCurrencyEffect: new Big('21.93'),
|
||||||
investment: new Big('75.80'),
|
investment: new Big('75.80'),
|
||||||
|
investmentWithCurrencyEffect: new Big('75.80'),
|
||||||
netPerformance: new Big('17.68'),
|
netPerformance: new Big('17.68'),
|
||||||
netPerformancePercentage: new Big('0.11662269129287598945'),
|
netPerformancePercentage: new Big('0.12184460284330327256'),
|
||||||
|
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'0.12184460284330327256'
|
||||||
|
),
|
||||||
|
netPerformanceWithCurrencyEffect: new Big('17.68'),
|
||||||
marketPrice: 87.8,
|
marketPrice: 87.8,
|
||||||
|
marketPriceInBaseCurrency: 87.8,
|
||||||
quantity: new Big('1'),
|
quantity: new Big('1'),
|
||||||
symbol: 'NOVN.SW',
|
symbol: 'NOVN.SW',
|
||||||
|
timeWeightedInvestment: new Big('145.10285714285714285714'),
|
||||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big(
|
||||||
|
'145.10285714285714285714'
|
||||||
|
),
|
||||||
transactionCount: 2
|
transactionCount: 2
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
totalInvestment: new Big('75.80')
|
totalInvestment: new Big('75.80'),
|
||||||
|
totalInvestmentWithCurrencyEffect: new Big('75.80')
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(investments).toEqual([
|
expect(investments).toEqual([
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
|
|
||||||
@ -16,15 +17,24 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
|||||||
|
|
||||||
describe('PortfolioCalculator', () => {
|
describe('PortfolioCalculator', () => {
|
||||||
let currentRateService: CurrentRateService;
|
let currentRateService: CurrentRateService;
|
||||||
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
currentRateService = new CurrentRateService(null, null, null);
|
currentRateService = new CurrentRateService(null, null);
|
||||||
|
|
||||||
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('get current positions', () => {
|
describe('get current positions', () => {
|
||||||
it.only('with NOVN.SW buy and sell', async () => {
|
it.only('with NOVN.SW buy and sell', async () => {
|
||||||
const portfolioCalculator = new PortfolioCalculator({
|
const portfolioCalculator = new PortfolioCalculator({
|
||||||
currentRateService,
|
currentRateService,
|
||||||
|
exchangeRateDataService,
|
||||||
currency: 'CHF',
|
currency: 'CHF',
|
||||||
orders: [
|
orders: [
|
||||||
{
|
{
|
||||||
@ -75,18 +85,26 @@ describe('PortfolioCalculator', () => {
|
|||||||
|
|
||||||
expect(chartData[0]).toEqual({
|
expect(chartData[0]).toEqual({
|
||||||
date: '2022-03-07',
|
date: '2022-03-07',
|
||||||
netPerformanceInPercentage: 0,
|
|
||||||
netPerformance: 0,
|
netPerformance: 0,
|
||||||
|
netPerformanceInPercentage: 0,
|
||||||
|
netPerformanceInPercentageWithCurrencyEffect: 0,
|
||||||
|
netPerformanceWithCurrencyEffect: 0,
|
||||||
totalInvestment: 151.6,
|
totalInvestment: 151.6,
|
||||||
value: 151.6
|
totalInvestmentValueWithCurrencyEffect: 151.6,
|
||||||
|
value: 151.6,
|
||||||
|
valueWithCurrencyEffect: 151.6
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(chartData[chartData.length - 1]).toEqual({
|
expect(chartData[chartData.length - 1]).toEqual({
|
||||||
date: '2022-04-11',
|
date: '2022-04-11',
|
||||||
netPerformanceInPercentage: 13.100263852242744,
|
|
||||||
netPerformance: 19.86,
|
netPerformance: 19.86,
|
||||||
|
netPerformanceInPercentage: 13.100263852242744,
|
||||||
|
netPerformanceInPercentageWithCurrencyEffect: 13.100263852242744,
|
||||||
|
netPerformanceWithCurrencyEffect: 19.86,
|
||||||
totalInvestment: 0,
|
totalInvestment: 0,
|
||||||
value: 0
|
totalInvestmentValueWithCurrencyEffect: 0,
|
||||||
|
value: 0,
|
||||||
|
valueWithCurrencyEffect: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(currentPositions).toEqual({
|
expect(currentPositions).toEqual({
|
||||||
@ -94,9 +112,17 @@ describe('PortfolioCalculator', () => {
|
|||||||
errors: [],
|
errors: [],
|
||||||
grossPerformance: new Big('19.86'),
|
grossPerformance: new Big('19.86'),
|
||||||
grossPerformancePercentage: new Big('0.13100263852242744063'),
|
grossPerformancePercentage: new Big('0.13100263852242744063'),
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'0.13100263852242744063'
|
||||||
|
),
|
||||||
|
grossPerformanceWithCurrencyEffect: new Big('19.86'),
|
||||||
hasErrors: false,
|
hasErrors: false,
|
||||||
netPerformance: new Big('19.86'),
|
netPerformance: new Big('19.86'),
|
||||||
netPerformancePercentage: new Big('0.13100263852242744063'),
|
netPerformancePercentage: new Big('0.13100263852242744063'),
|
||||||
|
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'0.13100263852242744063'
|
||||||
|
),
|
||||||
|
netPerformanceWithCurrencyEffect: new Big('19.86'),
|
||||||
positions: [
|
positions: [
|
||||||
{
|
{
|
||||||
averagePrice: new Big('0'),
|
averagePrice: new Big('0'),
|
||||||
@ -106,16 +132,29 @@ describe('PortfolioCalculator', () => {
|
|||||||
firstBuyDate: '2022-03-07',
|
firstBuyDate: '2022-03-07',
|
||||||
grossPerformance: new Big('19.86'),
|
grossPerformance: new Big('19.86'),
|
||||||
grossPerformancePercentage: new Big('0.13100263852242744063'),
|
grossPerformancePercentage: new Big('0.13100263852242744063'),
|
||||||
|
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'0.13100263852242744063'
|
||||||
|
),
|
||||||
|
grossPerformanceWithCurrencyEffect: new Big('19.86'),
|
||||||
investment: new Big('0'),
|
investment: new Big('0'),
|
||||||
|
investmentWithCurrencyEffect: new Big('0'),
|
||||||
netPerformance: new Big('19.86'),
|
netPerformance: new Big('19.86'),
|
||||||
netPerformancePercentage: new Big('0.13100263852242744063'),
|
netPerformancePercentage: new Big('0.13100263852242744063'),
|
||||||
|
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||||
|
'0.13100263852242744063'
|
||||||
|
),
|
||||||
|
netPerformanceWithCurrencyEffect: new Big('19.86'),
|
||||||
marketPrice: 87.8,
|
marketPrice: 87.8,
|
||||||
|
marketPriceInBaseCurrency: 87.8,
|
||||||
quantity: new Big('0'),
|
quantity: new Big('0'),
|
||||||
symbol: 'NOVN.SW',
|
symbol: 'NOVN.SW',
|
||||||
|
timeWeightedInvestment: new Big('151.6'),
|
||||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'),
|
||||||
transactionCount: 2
|
transactionCount: 2
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
totalInvestment: new Big('0')
|
totalInvestment: new Big('0'),
|
||||||
|
totalInvestmentWithCurrencyEffect: new Big('0')
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(investments).toEqual([
|
expect(investments).toEqual([
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
|
|
||||||
import { CurrentRateService } from './current-rate.service';
|
import { CurrentRateService } from './current-rate.service';
|
||||||
@ -5,14 +6,23 @@ import { PortfolioCalculator } from './portfolio-calculator';
|
|||||||
|
|
||||||
describe('PortfolioCalculator', () => {
|
describe('PortfolioCalculator', () => {
|
||||||
let currentRateService: CurrentRateService;
|
let currentRateService: CurrentRateService;
|
||||||
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
currentRateService = new CurrentRateService(null, null, null);
|
currentRateService = new CurrentRateService(null, null);
|
||||||
|
|
||||||
|
exchangeRateDataService = new ExchangeRateDataService(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('annualized performance percentage', () => {
|
describe('annualized performance percentage', () => {
|
||||||
const portfolioCalculator = new PortfolioCalculator({
|
const portfolioCalculator = new PortfolioCalculator({
|
||||||
currentRateService,
|
currentRateService,
|
||||||
|
exchangeRateDataService,
|
||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
orders: []
|
orders: []
|
||||||
});
|
});
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,6 @@
|
|||||||
import { AccessService } from '@ghostfolio/api/app/access/access.service';
|
import { AccessService } from '@ghostfolio/api/app/access/access.service';
|
||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import {
|
import {
|
||||||
hasNotDefinedValuesInObject,
|
hasNotDefinedValuesInObject,
|
||||||
nullifyValuesInObject
|
nullifyValuesInObject
|
||||||
@ -10,7 +11,10 @@ import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interc
|
|||||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
import {
|
||||||
|
DEFAULT_CURRENCY,
|
||||||
|
HEADER_KEY_IMPERSONATION
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
PortfolioDividends,
|
PortfolioDividends,
|
||||||
@ -47,8 +51,6 @@ import { PortfolioService } from './portfolio.service';
|
|||||||
|
|
||||||
@Controller('portfolio')
|
@Controller('portfolio')
|
||||||
export class PortfolioController {
|
export class PortfolioController {
|
||||||
private baseCurrency: string;
|
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly accessService: AccessService,
|
private readonly accessService: AccessService,
|
||||||
private readonly apiService: ApiService,
|
private readonly apiService: ApiService,
|
||||||
@ -57,12 +59,10 @@ export class PortfolioController {
|
|||||||
private readonly portfolioService: PortfolioService,
|
private readonly portfolioService: PortfolioService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||||
private readonly userService: UserService
|
private readonly userService: UserService
|
||||||
) {
|
) {}
|
||||||
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('details')
|
@Get('details')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getDetails(
|
public async getDetails(
|
||||||
@ -155,7 +155,9 @@ export class PortfolioController {
|
|||||||
'cash',
|
'cash',
|
||||||
'committedFunds',
|
'committedFunds',
|
||||||
'currentGrossPerformance',
|
'currentGrossPerformance',
|
||||||
|
'currentGrossPerformanceWithCurrencyEffect',
|
||||||
'currentNetPerformance',
|
'currentNetPerformance',
|
||||||
|
'currentNetPerformanceWithCurrencyEffect',
|
||||||
'currentValue',
|
'currentValue',
|
||||||
'dividend',
|
'dividend',
|
||||||
'emergencyFund',
|
'emergencyFund',
|
||||||
@ -174,8 +176,14 @@ export class PortfolioController {
|
|||||||
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||||
holdings[symbol] = {
|
holdings[symbol] = {
|
||||||
...portfolioPosition,
|
...portfolioPosition,
|
||||||
assetClass: hasDetails ? portfolioPosition.assetClass : undefined,
|
assetClass:
|
||||||
assetSubClass: hasDetails ? portfolioPosition.assetSubClass : undefined,
|
hasDetails || portfolioPosition.assetClass === 'CASH'
|
||||||
|
? portfolioPosition.assetClass
|
||||||
|
: undefined,
|
||||||
|
assetSubClass:
|
||||||
|
hasDetails || portfolioPosition.assetSubClass === 'CASH'
|
||||||
|
? portfolioPosition.assetSubClass
|
||||||
|
: undefined,
|
||||||
countries: hasDetails ? portfolioPosition.countries : [],
|
countries: hasDetails ? portfolioPosition.countries : [],
|
||||||
currency: hasDetails ? portfolioPosition.currency : undefined,
|
currency: hasDetails ? portfolioPosition.currency : undefined,
|
||||||
markets: hasDetails ? portfolioPosition.markets : undefined,
|
markets: hasDetails ? portfolioPosition.markets : undefined,
|
||||||
@ -199,7 +207,7 @@ export class PortfolioController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('dividends')
|
@Get('dividends')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async getDividends(
|
public async getDividends(
|
||||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||||
@Query('accounts') filterByAccounts?: string,
|
@Query('accounts') filterByAccounts?: string,
|
||||||
@ -249,7 +257,7 @@ export class PortfolioController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('investments')
|
@Get('investments')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async getInvestments(
|
public async getInvestments(
|
||||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||||
@Query('accounts') filterByAccounts?: string,
|
@Query('accounts') filterByAccounts?: string,
|
||||||
@ -310,7 +318,7 @@ export class PortfolioController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('performance')
|
@Get('performance')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
@Version('2')
|
@Version('2')
|
||||||
public async getPerformanceV2(
|
public async getPerformanceV2(
|
||||||
@ -318,7 +326,8 @@ export class PortfolioController {
|
|||||||
@Query('accounts') filterByAccounts?: string,
|
@Query('accounts') filterByAccounts?: string,
|
||||||
@Query('assetClasses') filterByAssetClasses?: string,
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
@Query('range') dateRange: DateRange = 'max',
|
@Query('range') dateRange: DateRange = 'max',
|
||||||
@Query('tags') filterByTags?: string
|
@Query('tags') filterByTags?: string,
|
||||||
|
@Query('withExcludedAccounts') withExcludedAccounts = false
|
||||||
): Promise<PortfolioPerformanceResponse> {
|
): Promise<PortfolioPerformanceResponse> {
|
||||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
filterByAccounts,
|
filterByAccounts,
|
||||||
@ -330,6 +339,7 @@ export class PortfolioController {
|
|||||||
dateRange,
|
dateRange,
|
||||||
filters,
|
filters,
|
||||||
impersonationId,
|
impersonationId,
|
||||||
|
withExcludedAccounts,
|
||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -339,16 +349,34 @@ export class PortfolioController {
|
|||||||
this.userService.isRestrictedView(this.request.user)
|
this.userService.isRestrictedView(this.request.user)
|
||||||
) {
|
) {
|
||||||
performanceInformation.chart = performanceInformation.chart.map(
|
performanceInformation.chart = performanceInformation.chart.map(
|
||||||
({ date, netPerformanceInPercentage, totalInvestment, value }) => {
|
({
|
||||||
|
date,
|
||||||
|
netPerformanceInPercentage,
|
||||||
|
netWorth,
|
||||||
|
totalInvestment,
|
||||||
|
value
|
||||||
|
}) => {
|
||||||
return {
|
return {
|
||||||
date,
|
date,
|
||||||
netPerformanceInPercentage,
|
netPerformanceInPercentage,
|
||||||
totalInvestment: new Big(totalInvestment)
|
netWorthInPercentage:
|
||||||
.div(performanceInformation.performance.totalInvestment)
|
performanceInformation.performance.currentNetWorth === 0
|
||||||
.toNumber(),
|
? 0
|
||||||
valueInPercentage: new Big(value)
|
: new Big(netWorth)
|
||||||
.div(performanceInformation.performance.currentValue)
|
.div(performanceInformation.performance.currentNetWorth)
|
||||||
.toNumber()
|
.toNumber(),
|
||||||
|
totalInvestment:
|
||||||
|
performanceInformation.performance.totalInvestment === 0
|
||||||
|
? 0
|
||||||
|
: new Big(totalInvestment)
|
||||||
|
.div(performanceInformation.performance.totalInvestment)
|
||||||
|
.toNumber(),
|
||||||
|
valueInPercentage:
|
||||||
|
performanceInformation.performance.currentValue === 0
|
||||||
|
? 0
|
||||||
|
: new Big(value)
|
||||||
|
.div(performanceInformation.performance.currentValue)
|
||||||
|
.toNumber()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@ -357,7 +385,10 @@ export class PortfolioController {
|
|||||||
performanceInformation.performance,
|
performanceInformation.performance,
|
||||||
[
|
[
|
||||||
'currentGrossPerformance',
|
'currentGrossPerformance',
|
||||||
|
'currentGrossPerformanceWithCurrencyEffect',
|
||||||
'currentNetPerformance',
|
'currentNetPerformance',
|
||||||
|
'currentNetPerformanceWithCurrencyEffect',
|
||||||
|
'currentNetWorth',
|
||||||
'currentValue',
|
'currentValue',
|
||||||
'totalInvestment'
|
'totalInvestment'
|
||||||
]
|
]
|
||||||
@ -379,19 +410,21 @@ export class PortfolioController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('positions')
|
@Get('positions')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async getPositions(
|
public async getPositions(
|
||||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||||
@Query('accounts') filterByAccounts?: string,
|
@Query('accounts') filterByAccounts?: string,
|
||||||
@Query('assetClasses') filterByAssetClasses?: string,
|
@Query('assetClasses') filterByAssetClasses?: string,
|
||||||
|
@Query('query') filterBySearchQuery?: string,
|
||||||
@Query('range') dateRange: DateRange = 'max',
|
@Query('range') dateRange: DateRange = 'max',
|
||||||
@Query('tags') filterByTags?: string
|
@Query('tags') filterByTags?: string
|
||||||
): Promise<PortfolioPositions> {
|
): Promise<PortfolioPositions> {
|
||||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
filterByAccounts,
|
filterByAccounts,
|
||||||
filterByAssetClasses,
|
filterByAssetClasses,
|
||||||
|
filterBySearchQuery,
|
||||||
filterByTags
|
filterByTags
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -442,8 +475,7 @@ export class PortfolioController {
|
|||||||
return this.exchangeRateDataService.toCurrency(
|
return this.exchangeRateDataService.toCurrency(
|
||||||
portfolioPosition.quantity * portfolioPosition.marketPrice,
|
portfolioPosition.quantity * portfolioPosition.marketPrice,
|
||||||
portfolioPosition.currency,
|
portfolioPosition.currency,
|
||||||
this.request.user?.Settings?.settings.baseCurrency ??
|
this.request.user?.Settings?.settings.baseCurrency ?? DEFAULT_CURRENCY
|
||||||
this.baseCurrency
|
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.reduce((a, b) => a + b, 0);
|
.reduce((a, b) => a + b, 0);
|
||||||
@ -473,7 +505,7 @@ export class PortfolioController {
|
|||||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async getPosition(
|
public async getPosition(
|
||||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||||
@Param('dataSource') dataSource,
|
@Param('dataSource') dataSource,
|
||||||
@ -496,7 +528,7 @@ export class PortfolioController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('report')
|
@Get('report')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async getReport(
|
public async getReport(
|
||||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string
|
||||||
): Promise<PortfolioReport> {
|
): Promise<PortfolioReport> {
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { AccessModule } from '@ghostfolio/api/app/access/access.module';
|
import { AccessModule } from '@ghostfolio/api/app/access/access.module';
|
||||||
|
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
|
|
||||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,7 @@
|
|||||||
|
import { Cache } from 'cache-manager';
|
||||||
|
|
||||||
|
import type { RedisStore } from './redis-store.interface';
|
||||||
|
|
||||||
|
export interface RedisCache extends Cache {
|
||||||
|
store: RedisStore;
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
import { Store } from 'cache-manager';
|
||||||
|
import { createClient } from 'redis';
|
||||||
|
|
||||||
|
export interface RedisStore extends Store {
|
||||||
|
getClient: () => ReturnType<typeof createClient>;
|
||||||
|
isCacheableValue: (value: any) => boolean;
|
||||||
|
name: 'redis';
|
||||||
|
}
|
@ -1,7 +1,9 @@
|
|||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { CacheManagerOptions, CacheModule, Module } from '@nestjs/common';
|
import { CacheModule } from '@nestjs/cache-manager';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
import * as redisStore from 'cache-manager-redis-store';
|
import * as redisStore from 'cache-manager-redis-store';
|
||||||
|
import type { RedisClientOptions } from 'redis';
|
||||||
|
|
||||||
import { RedisCacheService } from './redis-cache.service';
|
import { RedisCacheService } from './redis-cache.service';
|
||||||
|
|
||||||
@ -11,7 +13,7 @@ import { RedisCacheService } from './redis-cache.service';
|
|||||||
imports: [ConfigurationModule],
|
imports: [ConfigurationModule],
|
||||||
inject: [ConfigurationService],
|
inject: [ConfigurationService],
|
||||||
useFactory: async (configurationService: ConfigurationService) => {
|
useFactory: async (configurationService: ConfigurationService) => {
|
||||||
return <CacheManagerOptions>{
|
return <RedisClientOptions>{
|
||||||
host: configurationService.get('REDIS_HOST'),
|
host: configurationService.get('REDIS_HOST'),
|
||||||
max: configurationService.get('MAX_ITEM_IN_CACHE'),
|
max: configurationService.get('MAX_ITEM_IN_CACHE'),
|
||||||
password: configurationService.get('REDIS_PASSWORD'),
|
password: configurationService.get('REDIS_PASSWORD'),
|
||||||
|
@ -1,21 +1,30 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
|
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
||||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common';
|
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||||
import { Cache } from 'cache-manager';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
import type { RedisCache } from './interfaces/redis-cache.interface';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RedisCacheService {
|
export class RedisCacheService {
|
||||||
public constructor(
|
public constructor(
|
||||||
@Inject(CACHE_MANAGER) private readonly cache: Cache,
|
@Inject(CACHE_MANAGER) private readonly cache: RedisCache,
|
||||||
private readonly configurationService: ConfigurationService
|
private readonly configurationService: ConfigurationService
|
||||||
) {}
|
) {
|
||||||
|
const client = cache.store.getClient();
|
||||||
|
|
||||||
|
client.on('error', (error) => {
|
||||||
|
Logger.error(error, 'RedisCacheService');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public async get(key: string): Promise<string> {
|
public async get(key: string): Promise<string> {
|
||||||
return await this.cache.get(key);
|
return await this.cache.get(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getQuoteKey({ dataSource, symbol }: UniqueAsset) {
|
public getQuoteKey({ dataSource, symbol }: UniqueAsset) {
|
||||||
return `quote-${dataSource}-${symbol}`;
|
return `quote-${getAssetProfileIdentifier({ dataSource, symbol })}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async remove(key: string) {
|
public async remove(key: string) {
|
||||||
@ -27,8 +36,10 @@ export class RedisCacheService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async set(key: string, value: string, ttlInSeconds?: number) {
|
public async set(key: string, value: string, ttlInSeconds?: number) {
|
||||||
await this.cache.set(key, value, {
|
await this.cache.set(
|
||||||
ttl: ttlInSeconds ?? this.configurationService.get('CACHE_TTL')
|
key,
|
||||||
});
|
value,
|
||||||
|
ttlInSeconds ?? this.configurationService.get('CACHE_TTL')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
36
apps/api/src/app/sitemap/sitemap.controller.ts
Normal file
36
apps/api/src/app/sitemap/sitemap.controller.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DATE_FORMAT,
|
||||||
|
getYesterday,
|
||||||
|
interpolate
|
||||||
|
} from '@ghostfolio/common/helper';
|
||||||
|
import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { Response } from 'express';
|
||||||
|
|
||||||
|
@Controller('sitemap.xml')
|
||||||
|
export class SitemapController {
|
||||||
|
public sitemapXml = '';
|
||||||
|
|
||||||
|
public constructor() {
|
||||||
|
try {
|
||||||
|
this.sitemapXml = fs.readFileSync(
|
||||||
|
path.join(__dirname, 'assets', 'sitemap.xml'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Version(VERSION_NEUTRAL)
|
||||||
|
public async flushCache(@Res() response: Response): Promise<void> {
|
||||||
|
response.setHeader('content-type', 'application/xml');
|
||||||
|
response.send(
|
||||||
|
interpolate(this.sitemapXml, {
|
||||||
|
currentDate: format(getYesterday(), DATE_FORMAT)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
24
apps/api/src/app/sitemap/sitemap.module.ts
Normal file
24
apps/api/src/app/sitemap/sitemap.module.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { SitemapController } from './sitemap.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [SitemapController],
|
||||||
|
imports: [
|
||||||
|
ConfigurationModule,
|
||||||
|
DataGatheringModule,
|
||||||
|
DataProviderModule,
|
||||||
|
ExchangeRateDataModule,
|
||||||
|
PrismaModule,
|
||||||
|
RedisCacheModule,
|
||||||
|
SymbolProfileModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class SitemapModule {}
|
@ -1,3 +1,4 @@
|
|||||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import {
|
import {
|
||||||
@ -37,7 +38,7 @@ export class SubscriptionController {
|
|||||||
|
|
||||||
@Post('redeem-coupon')
|
@Post('redeem-coupon')
|
||||||
@HttpCode(StatusCodes.OK)
|
@HttpCode(StatusCodes.OK)
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async redeemCoupon(@Body() { couponCode }: { couponCode: string }) {
|
public async redeemCoupon(@Body() { couponCode }: { couponCode: string }) {
|
||||||
if (!this.request.user) {
|
if (!this.request.user) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
@ -104,12 +105,12 @@ export class SubscriptionController {
|
|||||||
response.redirect(
|
response.redirect(
|
||||||
`${this.configurationService.get(
|
`${this.configurationService.get(
|
||||||
'ROOT_URL'
|
'ROOT_URL'
|
||||||
)}/${DEFAULT_LANGUAGE_CODE}/account`
|
)}/${DEFAULT_LANGUAGE_CODE}/account/membership`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('stripe/checkout-session')
|
@Post('stripe/checkout-session')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async createCheckoutSession(
|
public async createCheckoutSession(
|
||||||
@Body() { couponId, priceId }: { couponId: string; priceId: string }
|
@Body() { couponId, priceId }: { couponId: string; priceId: string }
|
||||||
) {
|
) {
|
||||||
|
@ -93,9 +93,8 @@ export class SubscriptionService {
|
|||||||
|
|
||||||
public async createSubscriptionViaStripe(aCheckoutSessionId: string) {
|
public async createSubscriptionViaStripe(aCheckoutSessionId: string) {
|
||||||
try {
|
try {
|
||||||
const session = await this.stripe.checkout.sessions.retrieve(
|
const session =
|
||||||
aCheckoutSessionId
|
await this.stripe.checkout.sessions.retrieve(aCheckoutSessionId);
|
||||||
);
|
|
||||||
|
|
||||||
await this.createSubscription({
|
await this.createSubscription({
|
||||||
price: session.amount_total / 100,
|
price: session.amount_total / 100,
|
||||||
@ -112,14 +111,14 @@ export class SubscriptionService {
|
|||||||
aSubscriptions: Subscription[]
|
aSubscriptions: Subscription[]
|
||||||
): UserWithSettings['subscription'] {
|
): UserWithSettings['subscription'] {
|
||||||
if (aSubscriptions.length > 0) {
|
if (aSubscriptions.length > 0) {
|
||||||
const latestSubscription = aSubscriptions.reduce((a, b) => {
|
const { expiresAt, price } = aSubscriptions.reduce((a, b) => {
|
||||||
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
|
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
expiresAt: latestSubscription.expiresAt,
|
expiresAt,
|
||||||
offer: latestSubscription.price === 0 ? 'default' : 'renewal',
|
offer: price ? 'renewal' : 'default',
|
||||||
type: isBefore(new Date(), latestSubscription.expiresAt)
|
type: isBefore(new Date(), expiresAt)
|
||||||
? SubscriptionType.Premium
|
? SubscriptionType.Premium
|
||||||
: SubscriptionType.Basic
|
: SubscriptionType.Basic
|
||||||
};
|
};
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
@ -15,6 +16,7 @@ import {
|
|||||||
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 { DataSource } from '@prisma/client';
|
||||||
|
import { parseISO } from 'date-fns';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
import { isDate, isEmpty } from 'lodash';
|
import { isDate, isEmpty } from 'lodash';
|
||||||
|
|
||||||
@ -33,7 +35,7 @@ export class SymbolController {
|
|||||||
* Must be before /:symbol
|
* Must be before /:symbol
|
||||||
*/
|
*/
|
||||||
@Get('lookup')
|
@Get('lookup')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async lookupSymbol(
|
public async lookupSymbol(
|
||||||
@Query('includeIndices') includeIndices: boolean = false,
|
@Query('includeIndices') includeIndices: boolean = false,
|
||||||
@ -87,13 +89,13 @@ export class SymbolController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':dataSource/:symbol/:dateString')
|
@Get(':dataSource/:symbol/:dateString')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async gatherSymbolForDate(
|
public async gatherSymbolForDate(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('dateString') dateString: string,
|
@Param('dateString') dateString: string,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
): Promise<IDataProviderHistoricalResponse> {
|
): Promise<IDataProviderHistoricalResponse> {
|
||||||
const date = new Date(dateString);
|
const date = parseISO(dateString);
|
||||||
|
|
||||||
if (!isDate(date)) {
|
if (!isDate(date)) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
|
@ -40,7 +40,12 @@ export class SymbolService {
|
|||||||
|
|
||||||
const marketData = await this.marketDataService.getRange({
|
const marketData = await this.marketDataService.getRange({
|
||||||
dateQuery: { gte: subDays(new Date(), days) },
|
dateQuery: { gte: subDays(new Date(), days) },
|
||||||
symbols: [dataGatheringItem.symbol]
|
uniqueAssets: [
|
||||||
|
{
|
||||||
|
dataSource: dataGatheringItem.dataSource,
|
||||||
|
symbol: dataGatheringItem.symbol
|
||||||
|
}
|
||||||
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
historicalData = marketData.map(({ date, marketPrice: value }) => {
|
historicalData = marketData.map(({ date, marketPrice: value }) => {
|
||||||
|
6
apps/api/src/app/tag/create-tag.dto.ts
Normal file
6
apps/api/src/app/tag/create-tag.dto.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateTagDto {
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
}
|
82
apps/api/src/app/tag/tag.controller.ts
Normal file
82
apps/api/src/app/tag/tag.controller.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpException,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
UseGuards
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { Tag } from '@prisma/client';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
|
import { CreateTagDto } from './create-tag.dto';
|
||||||
|
import { TagService } from './tag.service';
|
||||||
|
import { UpdateTagDto } from './update-tag.dto';
|
||||||
|
|
||||||
|
@Controller('tag')
|
||||||
|
export class TagController {
|
||||||
|
public constructor(private readonly tagService: TagService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
public async getTags() {
|
||||||
|
return this.tagService.getTagsWithActivityCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@HasPermission(permissions.createTag)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
public async createTag(@Body() data: CreateTagDto): Promise<Tag> {
|
||||||
|
return this.tagService.createTag(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HasPermission(permissions.updateTag)
|
||||||
|
@Put(':id')
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
public async updateTag(@Param('id') id: string, @Body() data: UpdateTagDto) {
|
||||||
|
const originalTag = await this.tagService.getTag({
|
||||||
|
id
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!originalTag) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.tagService.updateTag({
|
||||||
|
data: {
|
||||||
|
...data
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@HasPermission(permissions.deleteTag)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
|
public async deleteTag(@Param('id') id: string) {
|
||||||
|
const originalTag = await this.tagService.getTag({
|
||||||
|
id
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!originalTag) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.tagService.deleteTag({ id });
|
||||||
|
}
|
||||||
|
}
|
13
apps/api/src/app/tag/tag.module.ts
Normal file
13
apps/api/src/app/tag/tag.module.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { TagController } from './tag.controller';
|
||||||
|
import { TagService } from './tag.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [TagController],
|
||||||
|
exports: [TagService],
|
||||||
|
imports: [PrismaModule],
|
||||||
|
providers: [TagService]
|
||||||
|
})
|
||||||
|
export class TagModule {}
|
79
apps/api/src/app/tag/tag.service.ts
Normal file
79
apps/api/src/app/tag/tag.service.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Prisma, Tag } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TagService {
|
||||||
|
public constructor(private readonly prismaService: PrismaService) {}
|
||||||
|
|
||||||
|
public async createTag(data: Prisma.TagCreateInput) {
|
||||||
|
return this.prismaService.tag.create({
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteTag(where: Prisma.TagWhereUniqueInput): Promise<Tag> {
|
||||||
|
return this.prismaService.tag.delete({ where });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getTag(
|
||||||
|
tagWhereUniqueInput: Prisma.TagWhereUniqueInput
|
||||||
|
): Promise<Tag> {
|
||||||
|
return this.prismaService.tag.findUnique({
|
||||||
|
where: tagWhereUniqueInput
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getTags({
|
||||||
|
cursor,
|
||||||
|
orderBy,
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
where
|
||||||
|
}: {
|
||||||
|
cursor?: Prisma.TagWhereUniqueInput;
|
||||||
|
orderBy?: Prisma.TagOrderByWithRelationInput;
|
||||||
|
skip?: number;
|
||||||
|
take?: number;
|
||||||
|
where?: Prisma.TagWhereInput;
|
||||||
|
} = {}) {
|
||||||
|
return this.prismaService.tag.findMany({
|
||||||
|
cursor,
|
||||||
|
orderBy,
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
where
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getTagsWithActivityCount() {
|
||||||
|
const tagsWithOrderCount = await this.prismaService.tag.findMany({
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { orders: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return tagsWithOrderCount.map(({ _count, id, name }) => {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
activityCount: _count.orders
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateTag({
|
||||||
|
data,
|
||||||
|
where
|
||||||
|
}: {
|
||||||
|
data: Prisma.TagUpdateInput;
|
||||||
|
where: Prisma.TagWhereUniqueInput;
|
||||||
|
}): Promise<Tag> {
|
||||||
|
return this.prismaService.tag.update({
|
||||||
|
data,
|
||||||
|
where
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
9
apps/api/src/app/tag/update-tag.dto.ts
Normal file
9
apps/api/src/app/tag/update-tag.dto.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateTagDto {
|
||||||
|
@IsString()
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
}
|
@ -4,6 +4,7 @@ import type {
|
|||||||
ViewMode
|
ViewMode
|
||||||
} from '@ghostfolio/common/types';
|
} from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsISO8601,
|
IsISO8601,
|
||||||
IsIn,
|
IsIn,
|
||||||
@ -37,6 +38,10 @@ export class UpdateUserSettingDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
emergencyFund?: number;
|
emergencyFund?: number;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsOptional()
|
||||||
|
'filters.tags'?: string[];
|
||||||
|
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
isExperimentalFeatures?: boolean;
|
isExperimentalFeatures?: boolean;
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import { User, UserSettings } from '@ghostfolio/common/interfaces';
|
import { User, UserSettings } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
@ -36,12 +38,10 @@ export class UserController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@HasPermission(permissions.deleteUser)
|
||||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async deleteUser(@Param('id') id: string): Promise<UserModel> {
|
public async deleteUser(@Param('id') id: string): Promise<UserModel> {
|
||||||
if (
|
if (id === this.request.user.id) {
|
||||||
!hasPermission(this.request.user.permissions, permissions.deleteUser) ||
|
|
||||||
id === this.request.user.id
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
StatusCodes.FORBIDDEN
|
StatusCodes.FORBIDDEN
|
||||||
@ -54,7 +54,7 @@ export class UserController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async getUser(
|
public async getUser(
|
||||||
@Headers('accept-language') acceptLanguage: string
|
@Headers('accept-language') acceptLanguage: string
|
||||||
): Promise<User> {
|
): Promise<User> {
|
||||||
@ -92,7 +92,7 @@ export class UserController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Put('setting')
|
@Put('setting')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||||
public async updateUserSetting(@Body() data: UpdateUserSettingDto) {
|
public async updateUserSetting(@Body() data: UpdateUserSettingDto) {
|
||||||
if (
|
if (
|
||||||
size(data) === 1 &&
|
size(data) === 1 &&
|
||||||
|
@ -4,8 +4,17 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con
|
|||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
||||||
import { PROPERTY_IS_READ_ONLY_MODE, locale } from '@ghostfolio/common/config';
|
import {
|
||||||
import { User as IUser, UserSettings } from '@ghostfolio/common/interfaces';
|
DEFAULT_CURRENCY,
|
||||||
|
PROPERTY_IS_READ_ONLY_MODE,
|
||||||
|
PROPERTY_SYSTEM_MESSAGE,
|
||||||
|
locale
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
|
import {
|
||||||
|
User as IUser,
|
||||||
|
SystemMessage,
|
||||||
|
UserSettings
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
import {
|
import {
|
||||||
getPermissions,
|
getPermissions,
|
||||||
hasRole,
|
hasRole,
|
||||||
@ -14,24 +23,23 @@ import {
|
|||||||
import { UserWithSettings } from '@ghostfolio/common/types';
|
import { UserWithSettings } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Prisma, Role, User } from '@prisma/client';
|
import { Prisma, Role, User } from '@prisma/client';
|
||||||
import { sortBy } from 'lodash';
|
import { differenceInDays } from 'date-fns';
|
||||||
|
import { sortBy, without } from 'lodash';
|
||||||
|
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserService {
|
export class UserService {
|
||||||
public static DEFAULT_CURRENCY = 'USD';
|
|
||||||
|
|
||||||
private baseCurrency: string;
|
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly propertyService: PropertyService,
|
private readonly propertyService: PropertyService,
|
||||||
private readonly subscriptionService: SubscriptionService,
|
private readonly subscriptionService: SubscriptionService,
|
||||||
private readonly tagService: TagService
|
private readonly tagService: TagService
|
||||||
) {
|
) {}
|
||||||
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
|
||||||
|
public async count(args?: Prisma.UserCountArgs) {
|
||||||
|
return this.prismaService.user.count(args);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getUser(
|
public async getUser(
|
||||||
@ -45,6 +53,17 @@ export class UserService {
|
|||||||
orderBy: { alias: 'asc' },
|
orderBy: { alias: 'asc' },
|
||||||
where: { GranteeUser: { id } }
|
where: { GranteeUser: { id } }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let systemMessage: SystemMessage;
|
||||||
|
|
||||||
|
const systemMessageProperty = (await this.propertyService.getByKey(
|
||||||
|
PROPERTY_SYSTEM_MESSAGE
|
||||||
|
)) as SystemMessage;
|
||||||
|
|
||||||
|
if (systemMessageProperty?.targetGroups?.includes(subscription?.type)) {
|
||||||
|
systemMessage = systemMessageProperty;
|
||||||
|
}
|
||||||
|
|
||||||
let tags = await this.tagService.getByUser(id);
|
let tags = await this.tagService.getByUser(id);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -58,6 +77,7 @@ export class UserService {
|
|||||||
id,
|
id,
|
||||||
permissions,
|
permissions,
|
||||||
subscription,
|
subscription,
|
||||||
|
systemMessage,
|
||||||
tags,
|
tags,
|
||||||
access: access.map((accessItem) => {
|
access: access.map((accessItem) => {
|
||||||
return {
|
return {
|
||||||
@ -107,7 +127,9 @@ export class UserService {
|
|||||||
updatedAt
|
updatedAt
|
||||||
} = await this.prismaService.user.findUnique({
|
} = await this.prismaService.user.findUnique({
|
||||||
include: {
|
include: {
|
||||||
Account: true,
|
Account: {
|
||||||
|
include: { Platform: true }
|
||||||
|
},
|
||||||
Analytics: true,
|
Analytics: true,
|
||||||
Settings: true,
|
Settings: true,
|
||||||
Subscription: true
|
Subscription: true
|
||||||
@ -144,8 +166,7 @@ export class UserService {
|
|||||||
|
|
||||||
// Set default value for base currency
|
// Set default value for base currency
|
||||||
if (!(user.Settings.settings as UserSettings)?.baseCurrency) {
|
if (!(user.Settings.settings as UserSettings)?.baseCurrency) {
|
||||||
(user.Settings.settings as UserSettings).baseCurrency =
|
(user.Settings.settings as UserSettings).baseCurrency = DEFAULT_CURRENCY;
|
||||||
UserService.DEFAULT_CURRENCY;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set default value for date range
|
// Set default value for date range
|
||||||
@ -161,15 +182,47 @@ export class UserService {
|
|||||||
|
|
||||||
let currentPermissions = getPermissions(user.role);
|
let currentPermissions = getPermissions(user.role);
|
||||||
|
|
||||||
|
if (!(user.Settings.settings as UserSettings).isExperimentalFeatures) {
|
||||||
|
// currentPermissions = without(
|
||||||
|
// currentPermissions,
|
||||||
|
// permissions.xyz
|
||||||
|
// );
|
||||||
|
}
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
user.subscription =
|
user.subscription =
|
||||||
this.subscriptionService.getSubscription(Subscription);
|
this.subscriptionService.getSubscription(Subscription);
|
||||||
|
|
||||||
if (
|
if (user.subscription?.type === 'Basic') {
|
||||||
Analytics?.activityCount % 5 === 0 &&
|
const daysSinceRegistration = differenceInDays(
|
||||||
user.subscription?.type === 'Basic'
|
new Date(),
|
||||||
) {
|
user.createdAt
|
||||||
currentPermissions.push(permissions.enableSubscriptionInterstitial);
|
);
|
||||||
|
let frequency = 15;
|
||||||
|
|
||||||
|
if (daysSinceRegistration > 365) {
|
||||||
|
frequency = 2;
|
||||||
|
} else if (daysSinceRegistration > 180) {
|
||||||
|
frequency = 3;
|
||||||
|
} else if (daysSinceRegistration > 60) {
|
||||||
|
frequency = 5;
|
||||||
|
} else if (daysSinceRegistration > 30) {
|
||||||
|
frequency = 8;
|
||||||
|
} else if (daysSinceRegistration > 15) {
|
||||||
|
frequency = 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Analytics?.activityCount % frequency === 1) {
|
||||||
|
currentPermissions.push(permissions.enableSubscriptionInterstitial);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPermissions = without(
|
||||||
|
currentPermissions,
|
||||||
|
permissions.createAccess
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reset benchmark
|
||||||
|
user.Settings.settings.benchmark = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.subscription?.type === 'Premium') {
|
if (user.subscription?.type === 'Premium') {
|
||||||
@ -201,8 +254,8 @@ export class UserService {
|
|||||||
currentPermissions.push(permissions.impersonateAllUsers);
|
currentPermissions.push(permissions.impersonateAllUsers);
|
||||||
}
|
}
|
||||||
|
|
||||||
user.Account = sortBy(user.Account, (account) => {
|
user.Account = sortBy(user.Account, ({ name }) => {
|
||||||
return account.name;
|
return name.toLowerCase();
|
||||||
});
|
});
|
||||||
user.permissions = currentPermissions.sort();
|
user.permissions = currentPermissions.sort();
|
||||||
|
|
||||||
@ -247,7 +300,7 @@ export class UserService {
|
|||||||
...data,
|
...data,
|
||||||
Account: {
|
Account: {
|
||||||
create: {
|
create: {
|
||||||
currency: this.baseCurrency,
|
currency: DEFAULT_CURRENCY,
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
name: 'Default Account'
|
name: 'Default Account'
|
||||||
}
|
}
|
||||||
@ -255,7 +308,7 @@ export class UserService {
|
|||||||
Settings: {
|
Settings: {
|
||||||
create: {
|
create: {
|
||||||
settings: {
|
settings: {
|
||||||
currency: this.baseCurrency
|
currency: DEFAULT_CURRENCY
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user