Compare commits
2 Commits
main
...
feature/ch
Author | SHA1 | Date | |
---|---|---|---|
|
5799b9e71c | ||
|
188389d26c |
25
.env.dev
25
.env.dev
@ -1,25 +0,0 @@
|
|||||||
COMPOSE_PROJECT_NAME=ghostfolio-development
|
|
||||||
|
|
||||||
# CACHE
|
|
||||||
REDIS_HOST=localhost
|
|
||||||
REDIS_PORT=6379
|
|
||||||
REDIS_PASSWORD=<INSERT_REDIS_PASSWORD>
|
|
||||||
|
|
||||||
# POSTGRES
|
|
||||||
POSTGRES_DB=ghostfolio-db
|
|
||||||
POSTGRES_USER=user
|
|
||||||
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
|
|
||||||
|
|
||||||
# VARIOUS
|
|
||||||
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
|
|
||||||
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
|
|
||||||
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
|
|
||||||
|
|
||||||
# DEVELOPMENT
|
|
||||||
|
|
||||||
# Nx 18 enables using plugins to infer targets by default
|
|
||||||
# This is disabled for existing workspaces to maintain compatibility
|
|
||||||
# For more info, see: https://nx.dev/concepts/inferred-tasks
|
|
||||||
NX_ADD_PLUGINS=false
|
|
||||||
|
|
||||||
NX_NATIVE_COMMAND_RUNNER=false
|
|
@ -1,7 +1,7 @@
|
|||||||
COMPOSE_PROJECT_NAME=ghostfolio
|
COMPOSE_PROJECT_NAME=ghostfolio-development
|
||||||
|
|
||||||
# CACHE
|
# CACHE
|
||||||
REDIS_HOST=redis
|
REDIS_HOST=localhost
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
REDIS_PASSWORD=<INSERT_REDIS_PASSWORD>
|
REDIS_PASSWORD=<INSERT_REDIS_PASSWORD>
|
||||||
|
|
||||||
@ -10,7 +10,6 @@ POSTGRES_DB=ghostfolio-db
|
|||||||
POSTGRES_USER=user
|
POSTGRES_USER=user
|
||||||
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
|
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
|
||||||
|
|
||||||
# VARIOUS
|
|
||||||
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
|
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
|
||||||
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres: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>
|
||||||
|
118
.eslintrc.json
Normal file
118
.eslintrc.json
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
{
|
||||||
|
"root": true,
|
||||||
|
"ignorePatterns": ["**/*"],
|
||||||
|
"plugins": ["@nx"],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
||||||
|
"rules": {
|
||||||
|
"@nx/enforce-module-boundaries": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"enforceBuildableLibDependency": true,
|
||||||
|
"allow": [],
|
||||||
|
"depConstraints": [
|
||||||
|
{
|
||||||
|
"sourceTag": "*",
|
||||||
|
"onlyDependOnLibsWithTags": ["*"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": ["*.ts", "*.tsx"],
|
||||||
|
"extends": ["plugin:@nx/typescript"],
|
||||||
|
"rules": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": ["*.js", "*.jsx"],
|
||||||
|
"extends": ["plugin:@nx/javascript"],
|
||||||
|
"rules": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": ["*.ts"],
|
||||||
|
"plugins": ["eslint-plugin-import", "@typescript-eslint"],
|
||||||
|
"rules": {
|
||||||
|
"@typescript-eslint/consistent-type-definitions": "error",
|
||||||
|
"@typescript-eslint/dot-notation": "off",
|
||||||
|
"@typescript-eslint/explicit-member-accessibility": [
|
||||||
|
"off",
|
||||||
|
{
|
||||||
|
"accessibility": "explicit"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"@typescript-eslint/member-ordering": "error",
|
||||||
|
"@typescript-eslint/naming-convention": "off",
|
||||||
|
"@typescript-eslint/no-empty-function": "off",
|
||||||
|
"@typescript-eslint/no-empty-interface": "error",
|
||||||
|
"@typescript-eslint/no-inferrable-types": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"ignoreParameters": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"@typescript-eslint/no-misused-new": "error",
|
||||||
|
"@typescript-eslint/no-non-null-assertion": "error",
|
||||||
|
"@typescript-eslint/no-shadow": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"hoist": "all"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"@typescript-eslint/no-unused-expressions": "error",
|
||||||
|
"@typescript-eslint/prefer-function-type": "error",
|
||||||
|
"@typescript-eslint/unified-signatures": "error",
|
||||||
|
"arrow-body-style": "off",
|
||||||
|
"constructor-super": "error",
|
||||||
|
"eqeqeq": ["error", "smart"],
|
||||||
|
"guard-for-in": "error",
|
||||||
|
"id-blacklist": "off",
|
||||||
|
"id-match": "off",
|
||||||
|
"import/no-deprecated": "warn",
|
||||||
|
"no-bitwise": "error",
|
||||||
|
"no-caller": "error",
|
||||||
|
"no-console": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"allow": [
|
||||||
|
"log",
|
||||||
|
"warn",
|
||||||
|
"dir",
|
||||||
|
"timeLog",
|
||||||
|
"assert",
|
||||||
|
"clear",
|
||||||
|
"count",
|
||||||
|
"countReset",
|
||||||
|
"group",
|
||||||
|
"groupEnd",
|
||||||
|
"table",
|
||||||
|
"dirxml",
|
||||||
|
"error",
|
||||||
|
"groupCollapsed",
|
||||||
|
"Console",
|
||||||
|
"profile",
|
||||||
|
"profileEnd",
|
||||||
|
"timeStamp",
|
||||||
|
"context"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"no-debugger": "error",
|
||||||
|
"no-empty": "off",
|
||||||
|
"no-eval": "error",
|
||||||
|
"no-fallthrough": "error",
|
||||||
|
"no-new-wrappers": "error",
|
||||||
|
"no-restricted-imports": ["error", "rxjs/Rx"],
|
||||||
|
"no-throw-literal": "error",
|
||||||
|
"no-undef-init": "error",
|
||||||
|
"no-underscore-dangle": "off",
|
||||||
|
"no-var": "error",
|
||||||
|
"prefer-const": "error",
|
||||||
|
"radix": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"extends": [null, "plugin:storybook/recommended"]
|
||||||
|
}
|
11
.github/ISSUE_TEMPLATE/bug_report.md
vendored
11
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -6,13 +6,7 @@ labels: ''
|
|||||||
assignees: ''
|
assignees: ''
|
||||||
---
|
---
|
||||||
|
|
||||||
**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).
|
||||||
|
|
||||||
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**
|
||||||
|
|
||||||
@ -42,9 +36,8 @@ Thank you for your understanding and cooperation!
|
|||||||
|
|
||||||
<!-- Please complete the following information -->
|
<!-- Please complete the following information -->
|
||||||
|
|
||||||
- Ghostfolio Version X.Y.Z
|
|
||||||
- Cloud or Self-hosted
|
- Cloud or Self-hosted
|
||||||
- Experimental Features enabled or disabled
|
- Ghostfolio Version X.Y.Z
|
||||||
- Browser
|
- Browser
|
||||||
- OS
|
- OS
|
||||||
|
|
||||||
|
39
.github/workflows/build-code.yml
vendored
Normal file
39
.github/workflows/build-code.yml
vendored
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
name: Build code
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
node_version:
|
||||||
|
- 18
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Use Node.js ${{ matrix.node_version }}
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node_version }}
|
||||||
|
cache: 'yarn'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: yarn install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
run: yarn format:check
|
||||||
|
|
||||||
|
- name: Execute tests
|
||||||
|
run: yarn test
|
||||||
|
|
||||||
|
- name: Build application
|
||||||
|
run: yarn build:production
|
47
.github/workflows/docker-image.yml
vendored
47
.github/workflows/docker-image.yml
vendored
@ -4,12 +4,9 @@ on:
|
|||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- '*.*.*'
|
- '*.*.*'
|
||||||
branches:
|
|
||||||
- 'main'
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- 'main'
|
- 'main'
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build_and_push:
|
build_and_push:
|
||||||
@ -18,11 +15,14 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Get Meta
|
- name: Docker metadata
|
||||||
id: meta
|
id: meta
|
||||||
run: |
|
uses: docker/metadata-action@v4
|
||||||
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
|
with:
|
||||||
echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT
|
images: ghostfolio/ghostfolio
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{major}}
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@v2
|
||||||
@ -35,35 +35,16 @@ jobs:
|
|||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
registry: gitea.suda.codes
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
||||||
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v3
|
uses: docker/build-push-action@v3
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
# platforms: linux/amd64,linux/arm/v7,linux/arm64
|
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||||
platforms: linux/amd64
|
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: |
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
gitea.suda.codes/sudacode/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}
|
labels: ${{ steps.meta.output.labels }}
|
||||||
gitea.suda.codes/sudacode/${{ steps.meta.outputs.REPO_NAME }}:latest
|
cache-from: type=gha
|
||||||
cache-from: type=local,src=${{ runner.temp }}/.buildx-cache
|
cache-to: type=gha,mode=max
|
||||||
cache-to: type=local,dest=${{ runner.temp }}/.buildx-cache-new,mode=max
|
|
||||||
|
|
||||||
- # Temp fix
|
|
||||||
# https://github.com/docker/build-push-action/issues/252
|
|
||||||
# https://github.com/moby/buildkit/issues/1896
|
|
||||||
name: Move cache
|
|
||||||
run: |
|
|
||||||
rm -rf ${{ runner.temp }}/.buildx-cache
|
|
||||||
mv ${{ runner.temp }}/.buildx-cache-new ${{ runner.temp }}/.buildx-cache
|
|
||||||
|
|
||||||
- name: Invoke deployment hook
|
|
||||||
uses: distributhor/workflow-webhook@v3
|
|
||||||
with:
|
|
||||||
webhook_url: ${{ secrets.WEBHOOK_URL }}
|
|
||||||
webhook_auth: ${{ secrets.WEBHOOK_AUTH }}
|
|
||||||
webhook_secret: ${{ secrets.WEBHOOK_SECRET }}
|
|
||||||
webhook_auth_type: bearer
|
|
||||||
|
7
.gitignore
vendored
7
.gitignore
vendored
@ -1,14 +1,12 @@
|
|||||||
# See http://help.github.com/ignore-files/ for more about ignoring files.
|
# See http://help.github.com/ignore-files/ for more about ignoring files.
|
||||||
|
|
||||||
scripts/*
|
|
||||||
|
|
||||||
# compiled output
|
# compiled output
|
||||||
/out-tsc
|
/out-tsc
|
||||||
/tmp
|
/tmp
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
|
/.yarn
|
||||||
/node_modules
|
/node_modules
|
||||||
npm-debug.log
|
|
||||||
|
|
||||||
# IDEs and editors
|
# IDEs and editors
|
||||||
/.idea
|
/.idea
|
||||||
@ -30,14 +28,15 @@ npm-debug.log
|
|||||||
.env
|
.env
|
||||||
.env.prod
|
.env.prod
|
||||||
.nx/cache
|
.nx/cache
|
||||||
.nx/workspace-data
|
|
||||||
/.sass-cache
|
/.sass-cache
|
||||||
/connect.lock
|
/connect.lock
|
||||||
/coverage
|
/coverage
|
||||||
/dist
|
/dist
|
||||||
/libpeerconnection.log
|
/libpeerconnection.log
|
||||||
|
npm-debug.log
|
||||||
testem.log
|
testem.log
|
||||||
/typings
|
/typings
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
# System Files
|
# System Files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
@ -1,6 +0,0 @@
|
|||||||
# Run linting and stop the commit process if any errors are found
|
|
||||||
# --quiet suppresses warnings (temporary until all warnings are fixed)
|
|
||||||
npm run affected:lint --base=main --head=HEAD --parallel=2 --quiet || exit 1
|
|
||||||
|
|
||||||
# Check formatting on modified and uncommitted files, stop the commit if issues are found
|
|
||||||
npm run format:check --uncommitted || exit 1
|
|
@ -1,5 +1,3 @@
|
|||||||
/.nx/cache
|
/.nx/cache
|
||||||
/.nx/workspace-data
|
|
||||||
/apps/client/src/polyfills.ts
|
|
||||||
/dist
|
/dist
|
||||||
/test/import
|
/test/import
|
||||||
|
21
.prettierrc
21
.prettierrc
@ -9,26 +9,7 @@
|
|||||||
],
|
],
|
||||||
"attributeSort": "ASC",
|
"attributeSort": "ASC",
|
||||||
"endOfLine": "auto",
|
"endOfLine": "auto",
|
||||||
"importOrder": ["^@ghostfolio/(.*)$", "<THIRD_PARTY_MODULES>", "^[./]"],
|
"plugins": ["prettier-plugin-organize-attributes"],
|
||||||
"importOrderSeparation": true,
|
|
||||||
"overrides": [
|
|
||||||
{
|
|
||||||
"files": "*.html",
|
|
||||||
"options": {
|
|
||||||
"parser": "angular"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"files": "*.ts",
|
|
||||||
"options": {
|
|
||||||
"importOrderParserPlugins": ["decorators-legacy", "typescript"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"plugins": [
|
|
||||||
"prettier-plugin-organize-attributes",
|
|
||||||
"@trivago/prettier-plugin-sort-imports"
|
|
||||||
],
|
|
||||||
"printWidth": 80,
|
"printWidth": 80,
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
|
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@ -1,7 +1,4 @@
|
|||||||
{
|
{
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true
|
||||||
"vim.highlightedyank.enable": true,
|
|
||||||
"vim.hlsearch": true,
|
|
||||||
"vim.leader": "<space>",
|
|
||||||
}
|
}
|
||||||
|
1781
CHANGELOG.md
1781
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@ -1,53 +1,5 @@
|
|||||||
# Ghostfolio Development Guide
|
# Ghostfolio Development Guide
|
||||||
|
|
||||||
## Development Environment
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
|
||||||
- [Node.js](https://nodejs.org/en/download) (version 20+)
|
|
||||||
- Create a local copy of this Git repository (clone)
|
|
||||||
- Copy the file `.env.dev` to `.env` and populate it with your data (`cp .env.dev .env`)
|
|
||||||
|
|
||||||
### Setup
|
|
||||||
|
|
||||||
1. Run `npm install`
|
|
||||||
1. Run `docker compose -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
|
||||||
1. Run `npm run database:setup` to initialize the database schema
|
|
||||||
1. Start the [server](#start-server) and the [client](#start-client)
|
|
||||||
1. Open https://localhost:4200/en in your browser
|
|
||||||
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
|
||||||
|
|
||||||
### Start Server
|
|
||||||
|
|
||||||
#### Debug
|
|
||||||
|
|
||||||
Run `npm run watch:server` and click _Debug API_ in [Visual Studio Code](https://code.visualstudio.com)
|
|
||||||
|
|
||||||
#### Serve
|
|
||||||
|
|
||||||
Run `npm run start:server`
|
|
||||||
|
|
||||||
### Start Client
|
|
||||||
|
|
||||||
Run `npm run start:client` and open https://localhost:4200/en in your browser
|
|
||||||
|
|
||||||
### Start _Storybook_
|
|
||||||
|
|
||||||
Run `npm run start:storybook`
|
|
||||||
|
|
||||||
### Migrate Database
|
|
||||||
|
|
||||||
With the following command you can keep your database schema in sync:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run database:push
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Run `npm test`
|
|
||||||
|
|
||||||
## Experimental Features
|
## Experimental Features
|
||||||
|
|
||||||
New functionality can be enabled using a feature flag switch from the user settings.
|
New functionality can be enabled using a feature flag switch from the user settings.
|
||||||
@ -58,7 +10,7 @@ Remove permission in `UserService` using `without()`
|
|||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
Use `@if (user?.settings?.isExperimentalFeatures) {}` in HTML template
|
Use `*ngIf="user?.settings?.isExperimentalFeatures"` in HTML template
|
||||||
|
|
||||||
## Git
|
## Git
|
||||||
|
|
||||||
@ -78,26 +30,26 @@ Use `@if (user?.settings?.isExperimentalFeatures) {}` in HTML template
|
|||||||
|
|
||||||
#### Upgrade
|
#### Upgrade
|
||||||
|
|
||||||
1. Run `npx nx migrate latest`
|
1. Run `yarn nx migrate latest`
|
||||||
1. Make sure `package.json` changes make sense and then run `npm install`
|
1. Make sure `package.json` changes make sense and then run `yarn install`
|
||||||
1. Run `npx 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
|
#### Access database via GUI
|
||||||
|
|
||||||
Run `npm run database:gui`
|
Run `yarn database:gui`
|
||||||
|
|
||||||
https://www.prisma.io/studio
|
https://www.prisma.io/studio
|
||||||
|
|
||||||
#### Synchronize schema with database for prototyping
|
#### Synchronize schema with database for prototyping
|
||||||
|
|
||||||
Run `npm run database:push`
|
Run `yarn database:push`
|
||||||
|
|
||||||
https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push
|
https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push
|
||||||
|
|
||||||
#### Create schema migration
|
#### Create schema migration
|
||||||
|
|
||||||
Run `npm run prisma migrate dev --name added_job_title`
|
Run `yarn prisma migrate dev --name added_job_title`
|
||||||
|
|
||||||
https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate
|
https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate
|
||||||
|
60
Dockerfile
60
Dockerfile
@ -1,67 +1,61 @@
|
|||||||
FROM --platform=$BUILDPLATFORM node:20-slim AS builder
|
FROM --platform=$BUILDPLATFORM node:18-slim as builder
|
||||||
|
|
||||||
# Build application and add additional files
|
# Build application and add additional files
|
||||||
WORKDIR /ghostfolio
|
WORKDIR /ghostfolio
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-suggests \
|
|
||||||
g++ \
|
|
||||||
git \
|
|
||||||
make \
|
|
||||||
openssl \
|
|
||||||
python3 \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Only add basic files without the application itself to avoid rebuilding
|
# Only add basic files without the application itself to avoid rebuilding
|
||||||
# layers when files (package.json etc.) have not changed
|
# layers when files (package.json etc.) have not changed
|
||||||
COPY ./CHANGELOG.md CHANGELOG.md
|
COPY ./CHANGELOG.md CHANGELOG.md
|
||||||
COPY ./LICENSE LICENSE
|
COPY ./LICENSE LICENSE
|
||||||
COPY ./package.json package.json
|
COPY ./package.json package.json
|
||||||
COPY ./package-lock.json package-lock.json
|
COPY ./yarn.lock yarn.lock
|
||||||
|
COPY ./.yarnrc .yarnrc
|
||||||
COPY ./prisma/schema.prisma prisma/schema.prisma
|
COPY ./prisma/schema.prisma prisma/schema.prisma
|
||||||
|
|
||||||
RUN npm install
|
RUN apt update && apt install -y \
|
||||||
|
git \
|
||||||
|
g++ \
|
||||||
|
make \
|
||||||
|
openssl \
|
||||||
|
python3 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
RUN yarn install
|
||||||
|
|
||||||
# See https://github.com/nrwl/nx/issues/6586 for further details
|
# See https://github.com/nrwl/nx/issues/6586 for further details
|
||||||
COPY ./decorate-angular-cli.js decorate-angular-cli.js
|
COPY ./decorate-angular-cli.js decorate-angular-cli.js
|
||||||
RUN node decorate-angular-cli.js
|
RUN node decorate-angular-cli.js
|
||||||
|
|
||||||
COPY ./apps apps
|
|
||||||
COPY ./libs libs
|
|
||||||
COPY ./jest.config.ts jest.config.ts
|
|
||||||
COPY ./jest.preset.js jest.preset.js
|
|
||||||
COPY ./nx.json nx.json
|
COPY ./nx.json nx.json
|
||||||
COPY ./replace.build.mjs replace.build.mjs
|
COPY ./replace.build.js replace.build.js
|
||||||
|
COPY ./jest.preset.js jest.preset.js
|
||||||
|
COPY ./jest.config.ts jest.config.ts
|
||||||
COPY ./tsconfig.base.json tsconfig.base.json
|
COPY ./tsconfig.base.json tsconfig.base.json
|
||||||
|
COPY ./libs libs
|
||||||
|
COPY ./apps apps
|
||||||
|
|
||||||
RUN npm run build:production
|
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
|
||||||
# package.json was generated by the build process, however the original
|
# package.json was generated by the build process, however the original
|
||||||
# package-lock.json needs to be used to ensure the same versions
|
# yarn.lock needs to be used to ensure the same versions
|
||||||
COPY ./package-lock.json /ghostfolio/dist/apps/api/package-lock.json
|
COPY ./yarn.lock /ghostfolio/dist/apps/api/yarn.lock
|
||||||
|
|
||||||
RUN npm install
|
RUN yarn
|
||||||
COPY prisma /ghostfolio/dist/apps/api/prisma
|
COPY prisma /ghostfolio/dist/apps/api/prisma
|
||||||
|
|
||||||
# Overwrite the generated package.json with the original one to ensure having
|
# Overwrite the generated package.json with the original one to ensure having
|
||||||
# all the scripts
|
# all the scripts
|
||||||
COPY package.json /ghostfolio/dist/apps/api
|
COPY package.json /ghostfolio/dist/apps/api
|
||||||
RUN npm run database:generate-typings
|
RUN yarn database:generate-typings
|
||||||
|
|
||||||
# Image to run, copy everything needed from builder
|
# Image to run, copy everything needed from builder
|
||||||
FROM node:20-slim
|
FROM node:18-slim
|
||||||
LABEL org.opencontainers.image.source="https://github.com/ghostfolio/ghostfolio"
|
RUN apt update && apt install -y \
|
||||||
ENV NODE_ENV=production
|
openssl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-suggests \
|
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
||||||
curl \
|
|
||||||
openssl \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
COPY --chown=node:node --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
|
||||||
COPY --chown=node:node ./docker/entrypoint.sh /ghostfolio/entrypoint.sh
|
|
||||||
WORKDIR /ghostfolio/apps/api
|
WORKDIR /ghostfolio/apps/api
|
||||||
EXPOSE ${PORT:-3333}
|
EXPOSE ${PORT:-3333}
|
||||||
USER node
|
CMD [ "yarn", "start:production" ]
|
||||||
CMD [ "/ghostfolio/entrypoint.sh" ]
|
|
||||||
|
204
README.md
204
README.md
@ -7,11 +7,13 @@
|
|||||||
**Open Source Wealth Management Software**
|
**Open Source Wealth Management Software**
|
||||||
|
|
||||||
[**Ghostfol.io**](https://ghostfol.io) | [**Live Demo**](https://ghostfol.io/en/demo) | [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) | [**FAQ**](https://ghostfol.io/en/faq) |
|
[**Ghostfol.io**](https://ghostfol.io) | [**Live Demo**](https://ghostfol.io/en/demo) | [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) | [**FAQ**](https://ghostfol.io/en/faq) |
|
||||||
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**X**](https://x.com/ghostfolio_)
|
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**Twitter**](https://twitter.com/ghostfolio_)
|
||||||
|
|
||||||
[](https://www.buymeacoffee.com/ghostfolio)
|
[](https://www.buymeacoffee.com/ghostfolio)
|
||||||
[](#contributing) [](https://hub.docker.com/r/ghostfolio/ghostfolio)
|
[](#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>
|
||||||
|
|
||||||
@ -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: Time-weighted rate of return (TWR) for `Today`, `WTD`, `MTD`, `YTD`, `1Y`, `5Y`, `Max`
|
- ✅ Portfolio performance 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
|
||||||
@ -71,7 +73,7 @@ The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://
|
|||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
The frontend is built with [Angular](https://angular.dev) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
|
The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
|
||||||
|
|
||||||
## Self-hosting
|
## Self-hosting
|
||||||
|
|
||||||
@ -85,24 +87,19 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
|
|||||||
|
|
||||||
### Supported Environment Variables
|
### Supported Environment Variables
|
||||||
|
|
||||||
| Name | Type | Default Value | Description |
|
| Name | Default Value | Description |
|
||||||
| ------------------------ | --------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens |
|
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
|
||||||
| `API_KEY_COINGECKO_DEMO` | `string` (optional) | | The _CoinGecko_ Demo API key |
|
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
||||||
| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key |
|
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
||||||
| `DATABASE_URL` | `string` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
|
||||||
| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
| `PORT` | `3333` | The port where the Ghostfolio application will run on |
|
||||||
| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) |
|
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
|
||||||
| `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` |
|
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
|
||||||
| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on |
|
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
|
||||||
| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database |
|
| `REDIS_HOST` | | The host where _Redis_ is running |
|
||||||
| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database |
|
| `REDIS_PASSWORD` | | The password of _Redis_ |
|
||||||
| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database |
|
| `REDIS_PORT` | | The port where _Redis_ is running |
|
||||||
| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ |
|
|
||||||
| `REDIS_HOST` | `string` | | The host where _Redis_ is running |
|
|
||||||
| `REDIS_PASSWORD` | `string` | | The password of _Redis_ |
|
|
||||||
| `REDIS_PORT` | `number` | | The port where _Redis_ is running |
|
|
||||||
| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds |
|
|
||||||
|
|
||||||
### Run with Docker Compose
|
### Run with Docker Compose
|
||||||
|
|
||||||
@ -118,7 +115,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 -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
|
||||||
@ -126,8 +123,8 @@ docker compose -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 -f docker/docker-compose.build.yml build
|
docker-compose --env-file ./.env -f docker/docker-compose.build.yml build
|
||||||
docker compose -f docker/docker-compose.build.yml up -d
|
docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Setup
|
#### Setup
|
||||||
@ -137,27 +134,62 @@ docker compose -f docker/docker-compose.build.yml up -d
|
|||||||
|
|
||||||
#### Upgrade Version
|
#### Upgrade Version
|
||||||
|
|
||||||
1. Update the _Ghostfolio_ Docker image
|
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`
|
||||||
- Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
|
At each start, the container will automatically apply the database schema migrations if needed.
|
||||||
- Run the following command if `ghostfolio:latest` is set:
|
|
||||||
```bash
|
|
||||||
docker compose -f docker/docker-compose.yml pull
|
|
||||||
```
|
|
||||||
|
|
||||||
1. Run the following command to start the new Docker image:
|
|
||||||
```bash
|
|
||||||
docker compose -f docker/docker-compose.yml up -d
|
|
||||||
```
|
|
||||||
The container will automatically apply any required database schema migrations during startup.
|
|
||||||
|
|
||||||
### Home Server Systems (Community)
|
### Home Server Systems (Community)
|
||||||
|
|
||||||
Ghostfolio is available for various home server systems, including [CasaOS](https://github.com/bigbeartechworld/big-bear-casaos), [Home Assistant](https://github.com/lildude/ha-addon-ghostfolio), [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).
|
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
|
||||||
|
|
||||||
For detailed information on the environment setup and development process, please refer to [DEVELOPMENT.md](./DEVELOPMENT.md).
|
### Prerequisites
|
||||||
|
|
||||||
|
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||||
|
- [Node.js](https://nodejs.org/en/download) (version 18+)
|
||||||
|
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||||
|
- Create a local copy of this Git repository (clone)
|
||||||
|
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`)
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
1. Run `yarn install`
|
||||||
|
1. Run `docker-compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||||
|
1. Run `yarn database:setup` to initialize the database schema
|
||||||
|
1. Start the server and the client (see [_Development_](#Development))
|
||||||
|
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`)
|
||||||
|
|
||||||
|
### Start Server
|
||||||
|
|
||||||
|
#### Debug
|
||||||
|
|
||||||
|
Run `yarn watch:server` and click _Launch Program_ in [Visual Studio Code](https://code.visualstudio.com)
|
||||||
|
|
||||||
|
#### Serve
|
||||||
|
|
||||||
|
Run `yarn start:server`
|
||||||
|
|
||||||
|
### Start Client
|
||||||
|
|
||||||
|
Run `yarn start:client` and open http://localhost:4200/en in your browser
|
||||||
|
|
||||||
|
### Start _Storybook_
|
||||||
|
|
||||||
|
Run `yarn start:storybook`
|
||||||
|
|
||||||
|
### Migrate Database
|
||||||
|
|
||||||
|
With the following command you can keep your database schema in sync:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn database:push
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Run `yarn test`
|
||||||
|
|
||||||
## Public API
|
## Public API
|
||||||
|
|
||||||
@ -169,36 +201,12 @@ Set the header for each request as follows:
|
|||||||
"Authorization": "Bearer eyJh..."
|
"Authorization": "Bearer eyJh..."
|
||||||
```
|
```
|
||||||
|
|
||||||
You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anonymous` (Body: `{ "accessToken": "<INSERT_SECURITY_TOKEN_OF_ACCOUNT>" }`)
|
You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anonymous` (Body: `{ accessToken: <INSERT_SECURITY_TOKEN_OF_ACCOUNT> }`)
|
||||||
|
|
||||||
Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>` or `curl -s http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>`.
|
Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>` or `curl -s http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TOKEN_OF_ACCOUNT>`.
|
||||||
|
|
||||||
### Health Check (experimental)
|
|
||||||
|
|
||||||
#### Request
|
|
||||||
|
|
||||||
`GET http://localhost:3333/api/v1/health`
|
|
||||||
|
|
||||||
**Info:** No Bearer Token is required for health check
|
|
||||||
|
|
||||||
#### Response
|
|
||||||
|
|
||||||
##### Success
|
|
||||||
|
|
||||||
`200 OK`
|
|
||||||
|
|
||||||
```
|
|
||||||
{
|
|
||||||
"status": "OK"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Import Activities
|
### Import Activities
|
||||||
|
|
||||||
#### Prerequisites
|
|
||||||
|
|
||||||
[Bearer Token](#authorization-bearer-token) for authorization
|
|
||||||
|
|
||||||
#### Request
|
#### Request
|
||||||
|
|
||||||
`POST http://localhost:3333/api/v1/import`
|
`POST http://localhost:3333/api/v1/import`
|
||||||
@ -222,18 +230,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` | `COINGECKO` \| `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` \| `FEE` \| `INTEREST` \| `ITEM` \| `LIABILITY` \| `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
|
||||||
|
|
||||||
@ -254,38 +262,6 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Portfolio (experimental)
|
|
||||||
|
|
||||||
#### Prerequisites
|
|
||||||
|
|
||||||
Grant access of type _Public_ in the _Access_ tab of _My Ghostfolio_.
|
|
||||||
|
|
||||||
#### Request
|
|
||||||
|
|
||||||
`GET http://localhost:3333/api/v1/public/<INSERT_ACCESS_ID>/portfolio`
|
|
||||||
|
|
||||||
**Info:** No Bearer Token is required for authorization
|
|
||||||
|
|
||||||
#### Response
|
|
||||||
|
|
||||||
##### Success
|
|
||||||
|
|
||||||
```
|
|
||||||
{
|
|
||||||
"performance": {
|
|
||||||
"1d": {
|
|
||||||
"relativeChange": 0 // normalized from -1 to 1
|
|
||||||
};
|
|
||||||
"ytd": {
|
|
||||||
"relativeChange": 0 // normalized from -1 to 1
|
|
||||||
},
|
|
||||||
"max": {
|
|
||||||
"relativeChange": 0 // normalized from -1 to 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Community Projects
|
## Community Projects
|
||||||
|
|
||||||
Discover a variety of community projects for Ghostfolio: https://github.com/topics/ghostfolio
|
Discover a variety of community projects for Ghostfolio: https://github.com/topics/ghostfolio
|
||||||
@ -296,16 +272,12 @@ Are you building your own project? Add the `ghostfolio` topic to your _GitHub_ r
|
|||||||
|
|
||||||
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 [some ideas](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22), even for [newcomers](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22). Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://x.com/ghostfolio_) on _X_. 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).
|
||||||
|
|
||||||
## Analytics
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
© 2021 - 2025 [Ghostfolio](https://ghostfol.io)
|
© 2021 - 2023 [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).
|
||||||
|
13
SECURITY.md
13
SECURITY.md
@ -1,13 +0,0 @@
|
|||||||
# Security Policy
|
|
||||||
|
|
||||||
## Reporting Security Issues
|
|
||||||
|
|
||||||
If you discover a security vulnerability in this repository, please report it to security[at]ghostfol.io. We will acknowledge your report and provide guidance on the next steps.
|
|
||||||
|
|
||||||
To help us resolve the issue, please include the following details:
|
|
||||||
|
|
||||||
- A description of the vulnerability
|
|
||||||
- Steps to reproduce the vulnerability
|
|
||||||
- Affected versions of the software
|
|
||||||
|
|
||||||
We appreciate your responsible disclosure and will work to address the issue promptly.
|
|
22
apps/api/.eslintrc.json
Normal file
22
apps/api/.eslintrc.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../.eslintrc.json",
|
||||||
|
"ignorePatterns": ["!**/*"],
|
||||||
|
"rules": {},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
||||||
|
"parserOptions": {
|
||||||
|
"project": ["apps/api/tsconfig.*?.json"]
|
||||||
|
},
|
||||||
|
"rules": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": ["*.ts", "*.tsx"],
|
||||||
|
"rules": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": ["*.js", "*.jsx"],
|
||||||
|
"rules": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
@ -1,31 +0,0 @@
|
|||||||
const baseConfig = require('../../eslint.config.cjs');
|
|
||||||
|
|
||||||
module.exports = [
|
|
||||||
{
|
|
||||||
ignores: ['**/dist']
|
|
||||||
},
|
|
||||||
...baseConfig,
|
|
||||||
{
|
|
||||||
rules: {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
|
|
||||||
// Override or add rules here
|
|
||||||
rules: {},
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['apps/api/tsconfig.*?.json']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
files: ['**/*.ts', '**/*.tsx'],
|
|
||||||
// Override or add rules here
|
|
||||||
rules: {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
files: ['**/*.js', '**/*.jsx'],
|
|
||||||
// Override or add rules here
|
|
||||||
rules: {}
|
|
||||||
}
|
|
||||||
];
|
|
@ -13,6 +13,7 @@ export default {
|
|||||||
},
|
},
|
||||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||||
coverageDirectory: '../../coverage/apps/api',
|
coverageDirectory: '../../coverage/apps/api',
|
||||||
|
testTimeout: 10000,
|
||||||
testEnvironment: 'node',
|
testEnvironment: 'node',
|
||||||
preset: '../../jest.preset.js'
|
preset: '../../jest.preset.js'
|
||||||
};
|
};
|
||||||
|
@ -7,15 +7,14 @@
|
|||||||
"generators": {},
|
"generators": {},
|
||||||
"targets": {
|
"targets": {
|
||||||
"build": {
|
"build": {
|
||||||
"executor": "@nx/webpack:webpack",
|
"executor": "@nrwl/webpack:webpack",
|
||||||
"options": {
|
"options": {
|
||||||
"compiler": "tsc",
|
|
||||||
"deleteOutputPath": false,
|
|
||||||
"main": "apps/api/src/main.ts",
|
|
||||||
"outputPath": "dist/apps/api",
|
"outputPath": "dist/apps/api",
|
||||||
"sourceMap": true,
|
"main": "apps/api/src/main.ts",
|
||||||
"target": "node",
|
|
||||||
"tsConfig": "apps/api/tsconfig.app.json",
|
"tsConfig": "apps/api/tsconfig.app.json",
|
||||||
|
"assets": ["apps/api/src/assets"],
|
||||||
|
"target": "node",
|
||||||
|
"compiler": "tsc",
|
||||||
"webpackConfig": "apps/api/webpack.config.js"
|
"webpackConfig": "apps/api/webpack.config.js"
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
@ -34,26 +33,6 @@
|
|||||||
},
|
},
|
||||||
"outputs": ["{options.outputPath}"]
|
"outputs": ["{options.outputPath}"]
|
||||||
},
|
},
|
||||||
"copy-assets": {
|
|
||||||
"executor": "nx:run-commands",
|
|
||||||
"options": {
|
|
||||||
"commands": [
|
|
||||||
{
|
|
||||||
"command": "shx rm -rf dist/apps/api"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "shx mkdir -p dist/apps/api/assets/locales"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "shx cp -r apps/api/src/assets/* dist/apps/api/assets"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"command": "shx cp -r apps/client/src/locales/* dist/apps/api/assets/locales"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parallel": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"serve": {
|
"serve": {
|
||||||
"executor": "@nx/js:node",
|
"executor": "@nx/js:node",
|
||||||
"options": {
|
"options": {
|
||||||
@ -61,7 +40,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"lint": {
|
"lint": {
|
||||||
"executor": "@nx/eslint:lint",
|
"executor": "@nrwl/linter:eslint",
|
||||||
"options": {
|
"options": {
|
||||||
"lintFilePatterns": ["apps/api/**/*.ts"]
|
"lintFilePatterns": ["apps/api/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,6 @@
|
|||||||
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 { permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@ -28,12 +24,11 @@ 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'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getAllAccesses(): Promise<Access[]> {
|
public async getAllAccesses(): Promise<Access[]> {
|
||||||
const accessesWithGranteeUser = await this.accessService.accesses({
|
const accessesWithGranteeUser = await this.accessService.accesses({
|
||||||
include: {
|
include: {
|
||||||
@ -43,38 +38,32 @@ export class AccessController {
|
|||||||
where: { userId: this.request.user.id }
|
where: { userId: this.request.user.id }
|
||||||
});
|
});
|
||||||
|
|
||||||
return accessesWithGranteeUser.map(
|
return accessesWithGranteeUser.map((access) => {
|
||||||
({ alias, GranteeUser, id, permissions }) => {
|
if (access.GranteeUser) {
|
||||||
if (GranteeUser) {
|
|
||||||
return {
|
|
||||||
alias,
|
|
||||||
id,
|
|
||||||
permissions,
|
|
||||||
grantee: GranteeUser?.id,
|
|
||||||
type: 'PRIVATE'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
alias,
|
alias: access.alias,
|
||||||
id,
|
grantee: access.GranteeUser?.id,
|
||||||
permissions,
|
id: access.id,
|
||||||
grantee: 'Public',
|
type: 'RESTRICTED_VIEW'
|
||||||
type: 'PUBLIC'
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
return {
|
||||||
|
alias: access.alias,
|
||||||
|
grantee: 'Public',
|
||||||
|
id: access.id,
|
||||||
|
type: 'PUBLIC'
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@HasPermission(permissions.createAccess)
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async createAccess(
|
public async createAccess(
|
||||||
@Body() data: CreateAccessDto
|
@Body() data: CreateAccessDto
|
||||||
): Promise<AccessModel> {
|
): Promise<AccessModel> {
|
||||||
if (
|
if (
|
||||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
!hasPermission(this.request.user.permissions, permissions.createAccess)
|
||||||
this.request.user.subscription.type === 'Basic'
|
|
||||||
) {
|
) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
@ -82,30 +71,25 @@ export class AccessController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return this.accessService.createAccess({
|
||||||
return this.accessService.createAccess({
|
alias: data.alias || undefined,
|
||||||
alias: data.alias || undefined,
|
GranteeUser: data.granteeUserId
|
||||||
GranteeUser: data.granteeUserId
|
? { connect: { id: data.granteeUserId } }
|
||||||
? { connect: { id: data.granteeUserId } }
|
: undefined,
|
||||||
: undefined,
|
User: { connect: { id: this.request.user.id } }
|
||||||
permissions: data.permissions,
|
});
|
||||||
User: { connect: { id: this.request.user.id } }
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
|
||||||
StatusCodes.BAD_REQUEST
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@HasPermission(permissions.deleteAccess)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
|
||||||
public async deleteAccess(@Param('id') id: string): Promise<AccessModel> {
|
public async deleteAccess(@Param('id') id: string): Promise<AccessModel> {
|
||||||
const access = await this.accessService.access({ id });
|
const access = await this.accessService.access({ id });
|
||||||
|
|
||||||
if (!access || access.userId !== this.request.user.id) {
|
if (
|
||||||
|
!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,6 +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';
|
||||||
|
|
||||||
import { AccessController } from './access.controller';
|
import { AccessController } from './access.controller';
|
||||||
@ -9,7 +7,7 @@ import { AccessService } from './access.service';
|
|||||||
@Module({
|
@Module({
|
||||||
controllers: [AccessController],
|
controllers: [AccessController],
|
||||||
exports: [AccessService],
|
exports: [AccessService],
|
||||||
imports: [ConfigurationModule, PrismaModule],
|
imports: [PrismaModule],
|
||||||
providers: [AccessService]
|
providers: [AccessService]
|
||||||
})
|
})
|
||||||
export class AccessModule {}
|
export class AccessModule {}
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { AccessWithGranteeUser } from '@ghostfolio/common/types';
|
import { AccessWithGranteeUser } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Access, Prisma } from '@prisma/client';
|
import { Access, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { AccessPermission } from '@prisma/client';
|
import { IsOptional, IsString } from 'class-validator';
|
||||||
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
|
|
||||||
|
|
||||||
export class CreateAccessDto {
|
export class CreateAccessDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ -7,10 +6,10 @@ export class CreateAccessDto {
|
|||||||
alias?: string;
|
alias?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsString()
|
||||||
granteeUserId?: string;
|
granteeUserId?: string;
|
||||||
|
|
||||||
@IsEnum(AccessPermission, { each: true })
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
permissions?: AccessPermission[];
|
@IsString()
|
||||||
|
type?: 'PUBLIC';
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,7 @@
|
|||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
|
||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
|
||||||
import { permissions } from '@ghostfolio/common/permissions';
|
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Body,
|
|
||||||
Post,
|
|
||||||
Delete,
|
Delete,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
@ -20,56 +14,31 @@ import { AccountBalance } from '@prisma/client';
|
|||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { AccountBalanceService } from './account-balance.service';
|
import { AccountBalanceService } from './account-balance.service';
|
||||||
import { CreateAccountBalanceDto } from './create-account-balance.dto';
|
|
||||||
|
|
||||||
@Controller('account-balance')
|
@Controller('account-balance')
|
||||||
export class AccountBalanceController {
|
export class AccountBalanceController {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly accountBalanceService: AccountBalanceService,
|
private readonly accountBalanceService: AccountBalanceService,
|
||||||
private readonly accountService: AccountService,
|
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@HasPermission(permissions.createAccountBalance)
|
|
||||||
@Post()
|
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
|
||||||
public async createAccountBalance(
|
|
||||||
@Body() data: CreateAccountBalanceDto
|
|
||||||
): Promise<AccountBalance> {
|
|
||||||
const account = await this.accountService.account({
|
|
||||||
id_userId: {
|
|
||||||
id: data.accountId,
|
|
||||||
userId: this.request.user.id
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!account) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.accountBalanceService.createOrUpdateAccountBalance({
|
|
||||||
accountId: account.id,
|
|
||||||
balance: data.balance,
|
|
||||||
date: data.date,
|
|
||||||
userId: account.userId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@HasPermission(permissions.deleteAccountBalance)
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async deleteAccountBalance(
|
public async deleteAccountBalance(
|
||||||
@Param('id') id: string
|
@Param('id') id: string
|
||||||
): Promise<AccountBalance> {
|
): Promise<AccountBalance> {
|
||||||
const accountBalance = await this.accountBalanceService.accountBalance({
|
const accountBalance = await this.accountBalanceService.accountBalance({
|
||||||
id,
|
id
|
||||||
userId: this.request.user.id
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!accountBalance) {
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.deleteAccountBalance
|
||||||
|
) ||
|
||||||
|
!accountBalance ||
|
||||||
|
accountBalance.userId !== this.request.user.id
|
||||||
|
) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
StatusCodes.FORBIDDEN
|
StatusCodes.FORBIDDEN
|
||||||
@ -77,8 +46,7 @@ export class AccountBalanceController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return this.accountBalanceService.deleteAccountBalance({
|
return this.accountBalanceService.deleteAccountBalance({
|
||||||
id: accountBalance.id,
|
id
|
||||||
userId: accountBalance.userId
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
|
||||||
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 { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { AccountBalanceController } from './account-balance.controller';
|
import { AccountBalanceController } from './account-balance.controller';
|
||||||
@ -11,6 +9,6 @@ import { AccountBalanceService } from './account-balance.service';
|
|||||||
controllers: [AccountBalanceController],
|
controllers: [AccountBalanceController],
|
||||||
exports: [AccountBalanceService],
|
exports: [AccountBalanceService],
|
||||||
imports: [ExchangeRateDataModule, PrismaModule],
|
imports: [ExchangeRateDataModule, PrismaModule],
|
||||||
providers: [AccountBalanceService, AccountService]
|
providers: [AccountBalanceService]
|
||||||
})
|
})
|
||||||
export class AccountBalanceModule {}
|
export class AccountBalanceModule {}
|
||||||
|
@ -1,26 +1,13 @@
|
|||||||
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
|
|
||||||
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
|
|
||||||
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 { DATE_FORMAT, getSum, resetHours } from '@ghostfolio/common/helper';
|
import { AccountBalancesResponse, Filter } from '@ghostfolio/common/interfaces';
|
||||||
import {
|
import { UserWithSettings } from '@ghostfolio/common/types';
|
||||||
AccountBalancesResponse,
|
|
||||||
Filter,
|
|
||||||
HistoricalDataItem
|
|
||||||
} from '@ghostfolio/common/interfaces';
|
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
|
||||||
import { AccountBalance, Prisma } from '@prisma/client';
|
import { AccountBalance, Prisma } from '@prisma/client';
|
||||||
import { Big } from 'big.js';
|
|
||||||
import { format, parseISO } from 'date-fns';
|
|
||||||
|
|
||||||
import { CreateAccountBalanceDto } from './create-account-balance.dto';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AccountBalanceService {
|
export class AccountBalanceService {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly eventEmitter: EventEmitter2,
|
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly prismaService: PrismaService
|
private readonly prismaService: PrismaService
|
||||||
) {}
|
) {}
|
||||||
@ -36,114 +23,32 @@ export class AccountBalanceService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createOrUpdateAccountBalance({
|
public async createAccountBalance(
|
||||||
accountId,
|
data: Prisma.AccountBalanceCreateInput
|
||||||
balance,
|
): Promise<AccountBalance> {
|
||||||
date,
|
return this.prismaService.accountBalance.create({
|
||||||
userId
|
data
|
||||||
}: CreateAccountBalanceDto & {
|
|
||||||
userId: string;
|
|
||||||
}): Promise<AccountBalance> {
|
|
||||||
const accountBalance = await this.prismaService.accountBalance.upsert({
|
|
||||||
create: {
|
|
||||||
Account: {
|
|
||||||
connect: {
|
|
||||||
id_userId: {
|
|
||||||
userId,
|
|
||||||
id: accountId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
date: resetHours(parseISO(date)),
|
|
||||||
value: balance
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
value: balance
|
|
||||||
},
|
|
||||||
where: {
|
|
||||||
accountId_date: {
|
|
||||||
accountId,
|
|
||||||
date: resetHours(parseISO(date))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.eventEmitter.emit(
|
|
||||||
PortfolioChangedEvent.getName(),
|
|
||||||
new PortfolioChangedEvent({
|
|
||||||
userId
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return accountBalance;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deleteAccountBalance(
|
public async deleteAccountBalance(
|
||||||
where: Prisma.AccountBalanceWhereUniqueInput
|
where: Prisma.AccountBalanceWhereUniqueInput
|
||||||
): Promise<AccountBalance> {
|
): Promise<AccountBalance> {
|
||||||
const accountBalance = await this.prismaService.accountBalance.delete({
|
return this.prismaService.accountBalance.delete({
|
||||||
where
|
where
|
||||||
});
|
});
|
||||||
|
|
||||||
this.eventEmitter.emit(
|
|
||||||
PortfolioChangedEvent.getName(),
|
|
||||||
new PortfolioChangedEvent({
|
|
||||||
userId: where.userId as string
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return accountBalance;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAccountBalanceItems({
|
|
||||||
filters,
|
|
||||||
userCurrency,
|
|
||||||
userId
|
|
||||||
}: {
|
|
||||||
filters?: Filter[];
|
|
||||||
userCurrency: string;
|
|
||||||
userId: string;
|
|
||||||
}): Promise<HistoricalDataItem[]> {
|
|
||||||
const { balances } = await this.getAccountBalances({
|
|
||||||
filters,
|
|
||||||
userCurrency,
|
|
||||||
userId,
|
|
||||||
withExcludedAccounts: false // TODO
|
|
||||||
});
|
|
||||||
const accumulatedBalancesByDate: { [date: string]: HistoricalDataItem } =
|
|
||||||
{};
|
|
||||||
const lastBalancesByAccount: { [accountId: string]: Big } = {};
|
|
||||||
|
|
||||||
for (const { accountId, date, valueInBaseCurrency } of balances) {
|
|
||||||
const formattedDate = format(date, DATE_FORMAT);
|
|
||||||
|
|
||||||
lastBalancesByAccount[accountId] = new Big(valueInBaseCurrency);
|
|
||||||
|
|
||||||
const totalBalance = getSum(Object.values(lastBalancesByAccount));
|
|
||||||
|
|
||||||
// Add or update the accumulated balance for this date
|
|
||||||
accumulatedBalancesByDate[formattedDate] = {
|
|
||||||
date: formattedDate,
|
|
||||||
value: totalBalance.toNumber()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.values(accumulatedBalancesByDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
@LogPerformance
|
|
||||||
public async getAccountBalances({
|
public async getAccountBalances({
|
||||||
filters,
|
filters,
|
||||||
userCurrency,
|
user,
|
||||||
userId,
|
|
||||||
withExcludedAccounts
|
withExcludedAccounts
|
||||||
}: {
|
}: {
|
||||||
filters?: Filter[];
|
filters?: Filter[];
|
||||||
userCurrency: string;
|
user: UserWithSettings;
|
||||||
userId: string;
|
|
||||||
withExcludedAccounts?: boolean;
|
withExcludedAccounts?: boolean;
|
||||||
}): Promise<AccountBalancesResponse> {
|
}): Promise<AccountBalancesResponse> {
|
||||||
const where: Prisma.AccountBalanceWhereInput = { userId };
|
const where: Prisma.AccountBalanceWhereInput = { userId: user.id };
|
||||||
|
|
||||||
const accountFilter = filters?.find(({ type }) => {
|
const accountFilter = filters?.find(({ type }) => {
|
||||||
return type === 'ACCOUNT';
|
return type === 'ACCOUNT';
|
||||||
@ -174,11 +79,10 @@ export class AccountBalanceService {
|
|||||||
balances: balances.map((balance) => {
|
balances: balances.map((balance) => {
|
||||||
return {
|
return {
|
||||||
...balance,
|
...balance,
|
||||||
accountId: balance.Account.id,
|
|
||||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
balance.value,
|
balance.value,
|
||||||
balance.Account.currency,
|
balance.Account.currency,
|
||||||
userCurrency
|
user.Settings.settings.baseCurrency
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
@ -1,12 +0,0 @@
|
|||||||
import { IsISO8601, IsNumber, IsUUID } from 'class-validator';
|
|
||||||
|
|
||||||
export class CreateAccountBalanceDto {
|
|
||||||
@IsUUID()
|
|
||||||
accountId: string;
|
|
||||||
|
|
||||||
@IsNumber()
|
|
||||||
balance: number;
|
|
||||||
|
|
||||||
@IsISO8601()
|
|
||||||
date: string;
|
|
||||||
}
|
|
@ -1,22 +1,17 @@
|
|||||||
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
||||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
|
||||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
|
|
||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
|
||||||
import { ApiService } from '@ghostfolio/api/services/api/api.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 {
|
import {
|
||||||
AccountBalancesResponse,
|
AccountBalancesResponse,
|
||||||
Accounts
|
Accounts
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import type {
|
import type {
|
||||||
AccountWithValue,
|
AccountWithValue,
|
||||||
RequestWithUser
|
RequestWithUser
|
||||||
} from '@ghostfolio/common/types';
|
} from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@ -28,7 +23,6 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
Query,
|
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors
|
UseInterceptors
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@ -47,16 +41,23 @@ export class AccountController {
|
|||||||
public constructor(
|
public constructor(
|
||||||
private readonly accountBalanceService: AccountBalanceService,
|
private readonly accountBalanceService: AccountBalanceService,
|
||||||
private readonly accountService: AccountService,
|
private readonly accountService: AccountService,
|
||||||
private readonly apiService: ApiService,
|
|
||||||
private readonly impersonationService: ImpersonationService,
|
private readonly impersonationService: ImpersonationService,
|
||||||
private readonly portfolioService: PortfolioService,
|
private readonly portfolioService: PortfolioService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@HasPermission(permissions.deleteAccount)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@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: {
|
||||||
@ -67,47 +68,41 @@ export class AccountController {
|
|||||||
{ Order: true }
|
{ Order: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!account || account?.Order.length > 0) {
|
if (account?.isDefault || account?.Order.length > 0) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
StatusCodes.FORBIDDEN
|
StatusCodes.FORBIDDEN
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.accountService.deleteAccount({
|
return this.accountService.deleteAccount(
|
||||||
id_userId: {
|
{
|
||||||
id,
|
id_userId: {
|
||||||
userId: this.request.user.id
|
id,
|
||||||
}
|
userId: this.request.user.id
|
||||||
});
|
}
|
||||||
|
},
|
||||||
|
this.request.user.id
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
|
||||||
public async getAllAccounts(
|
public async getAllAccounts(
|
||||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId
|
||||||
@Query('dataSource') filterByDataSource?: string,
|
|
||||||
@Query('symbol') filterBySymbol?: string
|
|
||||||
): Promise<Accounts> {
|
): Promise<Accounts> {
|
||||||
const impersonationUserId =
|
const impersonationUserId =
|
||||||
await this.impersonationService.validateImpersonationId(impersonationId);
|
await this.impersonationService.validateImpersonationId(impersonationId);
|
||||||
|
|
||||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
|
||||||
filterByDataSource,
|
|
||||||
filterBySymbol
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.portfolioService.getAccountsWithAggregations({
|
return this.portfolioService.getAccountsWithAggregations({
|
||||||
filters,
|
|
||||||
userId: impersonationUserId || this.request.user.id,
|
userId: impersonationUserId || this.request.user.id,
|
||||||
withExcludedAccounts: true
|
withExcludedAccounts: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
public async getAccountById(
|
public async getAccountById(
|
||||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||||
@ -127,24 +122,31 @@ export class AccountController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/balances')
|
@Get(':id/balances')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||||
public async getAccountBalancesById(
|
public async getAccountBalancesById(
|
||||||
@Param('id') id: string
|
@Param('id') id: string
|
||||||
): Promise<AccountBalancesResponse> {
|
): Promise<AccountBalancesResponse> {
|
||||||
return this.accountBalanceService.getAccountBalances({
|
return this.accountBalanceService.getAccountBalances({
|
||||||
filters: [{ id, type: 'ACCOUNT' }],
|
filters: [{ id, type: 'ACCOUNT' }],
|
||||||
userCurrency: this.request.user.Settings.settings.baseCurrency,
|
user: this.request.user
|
||||||
userId: this.request.user.id
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@HasPermission(permissions.createAccount)
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
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;
|
||||||
@ -170,12 +172,20 @@ export class AccountController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@HasPermission(permissions.updateAccount)
|
|
||||||
@Post('transfer-balance')
|
@Post('transfer-balance')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async transferAccountBalance(
|
public async transferAccountBalance(
|
||||||
@Body() { accountIdFrom, accountIdTo, balance }: TransferBalanceDto
|
@Body() { accountIdFrom, accountIdTo, balance }: TransferBalanceDto
|
||||||
) {
|
) {
|
||||||
|
if (
|
||||||
|
!hasPermission(this.request.user.permissions, permissions.updateAccount)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const accountsOfUser = await this.accountService.getAccounts(
|
const accountsOfUser = await this.accountService.getAccounts(
|
||||||
this.request.user.id
|
this.request.user.id
|
||||||
);
|
);
|
||||||
@ -224,10 +234,18 @@ export class AccountController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@HasPermission(permissions.updateAccount)
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {
|
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {
|
||||||
|
if (
|
||||||
|
!hasPermission(this.request.user.permissions, permissions.updateAccount)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const originalAccount = await this.accountService.account({
|
const originalAccount = await this.accountService.account({
|
||||||
id_userId: {
|
id_userId: {
|
||||||
id,
|
id,
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module';
|
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 { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { ApiModule } from '@ghostfolio/api/services/api/api.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 { 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';
|
||||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.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';
|
||||||
|
|
||||||
import { AccountController } from './account.controller';
|
import { AccountController } from './account.controller';
|
||||||
@ -17,13 +17,14 @@ import { AccountService } from './account.service';
|
|||||||
exports: [AccountService],
|
exports: [AccountService],
|
||||||
imports: [
|
imports: [
|
||||||
AccountBalanceModule,
|
AccountBalanceModule,
|
||||||
ApiModule,
|
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
|
DataProviderModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
ImpersonationModule,
|
ImpersonationModule,
|
||||||
PortfolioModule,
|
PortfolioModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
RedactValuesInResponseModule
|
RedisCacheModule,
|
||||||
|
UserModule
|
||||||
],
|
],
|
||||||
providers: [AccountService]
|
providers: [AccountService]
|
||||||
})
|
})
|
||||||
|
@ -1,15 +1,10 @@
|
|||||||
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
||||||
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
|
|
||||||
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 { DATE_FORMAT } from '@ghostfolio/common/helper';
|
|
||||||
import { Filter } from '@ghostfolio/common/interfaces';
|
import { Filter } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
|
||||||
import { Account, Order, Platform, Prisma } from '@prisma/client';
|
import { Account, Order, Platform, Prisma } from '@prisma/client';
|
||||||
import { Big } from 'big.js';
|
import Big from 'big.js';
|
||||||
import { format } from 'date-fns';
|
|
||||||
import { groupBy } from 'lodash';
|
import { groupBy } from 'lodash';
|
||||||
|
|
||||||
import { CashDetails } from './interfaces/cash-details.interface';
|
import { CashDetails } from './interfaces/cash-details.interface';
|
||||||
@ -18,7 +13,6 @@ import { CashDetails } from './interfaces/cash-details.interface';
|
|||||||
export class AccountService {
|
export class AccountService {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly accountBalanceService: AccountBalanceService,
|
private readonly accountBalanceService: AccountBalanceService,
|
||||||
private readonly eventEmitter: EventEmitter2,
|
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly prismaService: PrismaService
|
private readonly prismaService: PrismaService
|
||||||
) {}
|
) {}
|
||||||
@ -26,8 +20,10 @@ export class AccountService {
|
|||||||
public async account({
|
public async account({
|
||||||
id_userId
|
id_userId
|
||||||
}: Prisma.AccountWhereUniqueInput): Promise<Account | null> {
|
}: Prisma.AccountWhereUniqueInput): Promise<Account | null> {
|
||||||
|
const { id, userId } = id_userId;
|
||||||
|
|
||||||
const [account] = await this.accounts({
|
const [account] = await this.accounts({
|
||||||
where: id_userId
|
where: { id, userId }
|
||||||
});
|
});
|
||||||
|
|
||||||
return account;
|
return account;
|
||||||
@ -90,38 +86,27 @@ export class AccountService {
|
|||||||
data
|
data
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.accountBalanceService.createOrUpdateAccountBalance({
|
await this.prismaService.accountBalance.create({
|
||||||
accountId: account.id,
|
data: {
|
||||||
balance: data.balance,
|
Account: {
|
||||||
date: format(new Date(), DATE_FORMAT),
|
connect: {
|
||||||
userId: aUserId
|
id_userId: { id: account.id, userId: aUserId }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
value: data.balance
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.eventEmitter.emit(
|
|
||||||
PortfolioChangedEvent.getName(),
|
|
||||||
new PortfolioChangedEvent({
|
|
||||||
userId: account.userId
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return account;
|
return account;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deleteAccount(
|
public async deleteAccount(
|
||||||
where: Prisma.AccountWhereUniqueInput
|
where: Prisma.AccountWhereUniqueInput,
|
||||||
|
aUserId: string
|
||||||
): Promise<Account> {
|
): Promise<Account> {
|
||||||
const account = await this.prismaService.account.delete({
|
return this.prismaService.account.delete({
|
||||||
where
|
where
|
||||||
});
|
});
|
||||||
|
|
||||||
this.eventEmitter.emit(
|
|
||||||
PortfolioChangedEvent.getName(),
|
|
||||||
new PortfolioChangedEvent({
|
|
||||||
userId: account.userId
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return account;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAccounts(aUserId: string): Promise<Account[]> {
|
public async getAccounts(aUserId: string): Promise<Account[]> {
|
||||||
@ -169,8 +154,12 @@ export class AccountService {
|
|||||||
where.isExcluded = false;
|
where.isExcluded = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { ACCOUNT: filtersByAccount } = groupBy(filters, ({ type }) => {
|
const {
|
||||||
return type;
|
ACCOUNT: filtersByAccount,
|
||||||
|
ASSET_CLASS: filtersByAssetClass,
|
||||||
|
TAG: filtersByTag
|
||||||
|
} = groupBy(filters, (filter) => {
|
||||||
|
return filter.type;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (filtersByAccount?.length > 0) {
|
if (filtersByAccount?.length > 0) {
|
||||||
@ -208,26 +197,21 @@ export class AccountService {
|
|||||||
): Promise<Account> {
|
): Promise<Account> {
|
||||||
const { data, where } = params;
|
const { data, where } = params;
|
||||||
|
|
||||||
await this.accountBalanceService.createOrUpdateAccountBalance({
|
await this.prismaService.accountBalance.create({
|
||||||
accountId: data.id as string,
|
data: {
|
||||||
balance: data.balance as number,
|
Account: {
|
||||||
date: format(new Date(), DATE_FORMAT),
|
connect: {
|
||||||
userId: aUserId
|
id_userId: where.id_userId
|
||||||
|
}
|
||||||
|
},
|
||||||
|
value: <number>data.balance
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const account = await this.prismaService.account.update({
|
return this.prismaService.account.update({
|
||||||
data,
|
data,
|
||||||
where
|
where
|
||||||
});
|
});
|
||||||
|
|
||||||
this.eventEmitter.emit(
|
|
||||||
PortfolioChangedEvent.getName(),
|
|
||||||
new PortfolioChangedEvent({
|
|
||||||
userId: account.userId
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return account;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async updateAccountBalance({
|
public async updateAccountBalance({
|
||||||
@ -259,11 +243,17 @@ export class AccountService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (amountInCurrencyOfAccount) {
|
if (amountInCurrencyOfAccount) {
|
||||||
await this.accountBalanceService.createOrUpdateAccountBalance({
|
await this.accountBalanceService.createAccountBalance({
|
||||||
accountId,
|
date,
|
||||||
userId,
|
Account: {
|
||||||
balance: new Big(balance).plus(amountInCurrencyOfAccount).toNumber(),
|
connect: {
|
||||||
date: date.toISOString()
|
id_userId: {
|
||||||
|
userId,
|
||||||
|
id: accountId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
value: new Big(balance).plus(amountInCurrencyOfAccount).toNumber()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
|
||||||
|
|
||||||
import { Transform, TransformFnParams } from 'class-transformer';
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
@ -21,7 +19,7 @@ export class CreateAccountDto {
|
|||||||
)
|
)
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
|
||||||
@IsCurrencyCode()
|
@IsString()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ -36,6 +34,6 @@ export class CreateAccountDto {
|
|||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@ValidateIf((_object, value) => value !== null)
|
@ValidateIf((object, value) => value !== null)
|
||||||
platformId: string | null;
|
platformId: string | null;
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
|
||||||
|
|
||||||
import { Transform, TransformFnParams } from 'class-transformer';
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
@ -21,7 +19,7 @@ export class UpdateAccountDto {
|
|||||||
)
|
)
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
|
||||||
@IsCurrencyCode()
|
@IsString()
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@ -35,6 +33,6 @@ export class UpdateAccountDto {
|
|||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@ValidateIf((_object, value) => value !== null)
|
@ValidateIf((object, value) => value !== null)
|
||||||
platformId: string | null;
|
platformId: string | null;
|
||||||
}
|
}
|
||||||
|
@ -1,31 +1,27 @@
|
|||||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
|
||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
|
||||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||||
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
|
|
||||||
import {
|
import {
|
||||||
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
|
|
||||||
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
|
|
||||||
GATHER_ASSET_PROFILE_PROCESS,
|
GATHER_ASSET_PROFILE_PROCESS,
|
||||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
import {
|
||||||
|
getAssetProfileIdentifier,
|
||||||
|
resetHours
|
||||||
|
} from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
AdminData,
|
AdminData,
|
||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
AdminMarketDataDetails,
|
AdminMarketDataDetails,
|
||||||
AdminUsers,
|
|
||||||
EnhancedSymbolProfile
|
EnhancedSymbolProfile
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import type {
|
import type {
|
||||||
MarketDataPreset,
|
MarketDataPreset,
|
||||||
RequestWithUser
|
RequestWithUser
|
||||||
} from '@ghostfolio/common/types';
|
} from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@ -33,7 +29,6 @@ import {
|
|||||||
Get,
|
Get,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
Logger,
|
|
||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
@ -59,34 +54,65 @@ export class AdminController {
|
|||||||
private readonly adminService: AdminService,
|
private readonly adminService: AdminService,
|
||||||
private readonly apiService: ApiService,
|
private readonly apiService: ApiService,
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
private readonly manualService: ManualService,
|
|
||||||
private readonly marketDataService: MarketDataService,
|
private readonly marketDataService: MarketDataService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@HasPermission(permissions.accessAdminControl)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@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'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
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'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async gatherMax(): Promise<void> {
|
public async gatherMax(): Promise<void> {
|
||||||
const assetProfileIdentifiers =
|
if (
|
||||||
await this.dataGatheringService.getAllAssetProfileIdentifiers();
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||||
|
|
||||||
await this.dataGatheringService.addJobsToQueue(
|
await this.dataGatheringService.addJobsToQueue(
|
||||||
assetProfileIdentifiers.map(({ dataSource, symbol }) => {
|
uniqueAssets.map(({ dataSource, symbol }) => {
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
dataSource,
|
dataSource,
|
||||||
@ -95,8 +121,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: getAssetProfileIdentifier({ dataSource, symbol }),
|
jobId: getAssetProfileIdentifier({ dataSource, symbol })
|
||||||
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@ -105,15 +130,25 @@ export class AdminController {
|
|||||||
this.dataGatheringService.gatherMax();
|
this.dataGatheringService.gatherMax();
|
||||||
}
|
}
|
||||||
|
|
||||||
@HasPermission(permissions.accessAdminControl)
|
|
||||||
@Post('gather/profile-data')
|
@Post('gather/profile-data')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async gatherProfileData(): Promise<void> {
|
public async gatherProfileData(): Promise<void> {
|
||||||
const assetProfileIdentifiers =
|
if (
|
||||||
await this.dataGatheringService.getAllAssetProfileIdentifiers();
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||||
|
|
||||||
await this.dataGatheringService.addJobsToQueue(
|
await this.dataGatheringService.addJobsToQueue(
|
||||||
assetProfileIdentifiers.map(({ dataSource, symbol }) => {
|
uniqueAssets.map(({ dataSource, symbol }) => {
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
dataSource,
|
dataSource,
|
||||||
@ -122,21 +157,31 @@ 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: getAssetProfileIdentifier({ dataSource, symbol }),
|
jobId: getAssetProfileIdentifier({ dataSource, symbol })
|
||||||
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@HasPermission(permissions.accessAdminControl)
|
|
||||||
@Post('gather/profile-data/:dataSource/:symbol')
|
@Post('gather/profile-data/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
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,
|
||||||
@ -145,32 +190,53 @@ 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: getAssetProfileIdentifier({ dataSource, symbol }),
|
jobId: getAssetProfileIdentifier({ dataSource, symbol })
|
||||||
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('gather/:dataSource/:symbol')
|
@Post('gather/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@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'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async gatherSymbolForDate(
|
public async gatherSymbolForDate(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('dateString') dateString: string,
|
@Param('dateString') dateString: string,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
): Promise<MarketData> {
|
): Promise<MarketData> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const date = parseISO(dateString);
|
const date = parseISO(dateString);
|
||||||
|
|
||||||
if (!isDate(date)) {
|
if (!isDate(date)) {
|
||||||
@ -188,8 +254,7 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('market-data')
|
@Get('market-data')
|
||||||
@HasPermission(permissions.accessAdminControl)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@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,
|
||||||
@ -199,6 +264,18 @@ export class AdminController {
|
|||||||
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
|
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
|
||||||
@Query('take') take?: number
|
@Query('take') take?: number
|
||||||
): Promise<AdminMarketData> {
|
): Promise<AdminMarketData> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||||
filterByAssetSubClasses,
|
filterByAssetSubClasses,
|
||||||
filterBySearchQuery
|
filterBySearchQuery
|
||||||
@ -214,62 +291,52 @@ export class AdminController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated
|
|
||||||
*/
|
|
||||||
@Get('market-data/:dataSource/:symbol')
|
@Get('market-data/:dataSource/:symbol')
|
||||||
@HasPermission(permissions.accessAdminControl)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@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 for ${symbol} (${dataSource})`
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
Logger.error(error, 'AdminController');
|
|
||||||
|
|
||||||
throw new HttpException(error.message, StatusCodes.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated
|
|
||||||
*/
|
|
||||||
@HasPermission(permissions.accessAdminControl)
|
|
||||||
@Post('market-data/:dataSource/:symbol')
|
@Post('market-data/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async updateMarketData(
|
public async updateMarketData(
|
||||||
@Body() data: UpdateBulkMarketDataDto,
|
@Body() data: UpdateBulkMarketDataDto,
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
) {
|
) {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map(
|
const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map(
|
||||||
({ date, marketPrice }) => ({
|
({ date, marketPrice }) => ({
|
||||||
dataSource,
|
dataSource,
|
||||||
marketPrice,
|
marketPrice,
|
||||||
symbol,
|
symbol,
|
||||||
date: parseISO(date),
|
date: resetHours(parseISO(date)),
|
||||||
state: 'CLOSE'
|
state: 'CLOSE'
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@ -282,15 +349,26 @@ export class AdminController {
|
|||||||
/**
|
/**
|
||||||
* @deprecated
|
* @deprecated
|
||||||
*/
|
*/
|
||||||
@HasPermission(permissions.accessAdminControl)
|
|
||||||
@Put('market-data/:dataSource/:symbol/:dateString')
|
@Put('market-data/:dataSource/:symbol/:dateString')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async update(
|
public async update(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('dateString') dateString: string,
|
@Param('dateString') dateString: string,
|
||||||
@Param('symbol') symbol: string,
|
@Param('symbol') symbol: string,
|
||||||
@Body() data: UpdateMarketDataDto
|
@Body() data: UpdateMarketDataDto
|
||||||
) {
|
) {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const date = parseISO(dateString);
|
const date = parseISO(dateString);
|
||||||
|
|
||||||
return this.marketDataService.updateMarketData({
|
return this.marketDataService.updateMarketData({
|
||||||
@ -305,14 +383,24 @@ export class AdminController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@HasPermission(permissions.accessAdminControl)
|
|
||||||
@Post('profile-data/:dataSource/:symbol')
|
@Post('profile-data/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
public async addProfileData(
|
public async addProfileData(
|
||||||
@Param('dataSource') dataSource: DataSource,
|
@Param('dataSource') dataSource: DataSource,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
): Promise<SymbolProfile | never> {
|
): Promise<SymbolProfile | never> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
return this.adminService.addAssetProfile({
|
return this.adminService.addAssetProfile({
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol,
|
symbol,
|
||||||
@ -321,23 +409,45 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete('profile-data/:dataSource/:symbol')
|
@Delete('profile-data/:dataSource/:symbol')
|
||||||
@HasPermission(permissions.accessAdminControl)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@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'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
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,
|
||||||
@ -345,26 +455,24 @@ export class AdminController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@HasPermission(permissions.accessAdminControl)
|
|
||||||
@Put('settings/:key')
|
@Put('settings/:key')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async updateProperty(
|
public async updateProperty(
|
||||||
@Param('key') key: string,
|
@Param('key') key: string,
|
||||||
@Body() data: PropertyDto
|
@Body() data: PropertyDto
|
||||||
) {
|
) {
|
||||||
return this.adminService.putSetting(key, data.value);
|
if (
|
||||||
}
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('user')
|
return await this.adminService.putSetting(key, data.value);
|
||||||
@HasPermission(permissions.accessAdminControl)
|
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
|
||||||
public async getUsers(
|
|
||||||
@Query('skip') skip?: number,
|
|
||||||
@Query('take') take?: number
|
|
||||||
): Promise<AdminUsers> {
|
|
||||||
return this.adminService.getUsers({
|
|
||||||
skip: isNaN(skip) ? undefined : skip,
|
|
||||||
take: isNaN(take) ? undefined : take
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,17 +1,13 @@
|
|||||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
|
||||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
|
||||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||||
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.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 { 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';
|
||||||
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';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
|
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { AdminController } from './admin.controller';
|
import { AdminController } from './admin.controller';
|
||||||
@ -21,19 +17,16 @@ import { QueueModule } from './queue/queue.module';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ApiModule,
|
ApiModule,
|
||||||
BenchmarkModule,
|
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
MarketDataModule,
|
MarketDataModule,
|
||||||
OrderModule,
|
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
PropertyModule,
|
PropertyModule,
|
||||||
QueueModule,
|
QueueModule,
|
||||||
SubscriptionModule,
|
SubscriptionModule,
|
||||||
SymbolProfileModule,
|
SymbolProfileModule
|
||||||
TransformDataSourceInRequestModule
|
|
||||||
],
|
],
|
||||||
controllers: [AdminController],
|
controllers: [AdminController],
|
||||||
providers: [AdminService],
|
providers: [AdminService],
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
|
||||||
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 { environment } from '@ghostfolio/api/environments/environment';
|
||||||
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
|
|
||||||
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';
|
||||||
@ -15,31 +13,20 @@ import {
|
|||||||
PROPERTY_IS_READ_ONLY_MODE,
|
PROPERTY_IS_READ_ONLY_MODE,
|
||||||
PROPERTY_IS_USER_SIGNUP_ENABLED
|
PROPERTY_IS_USER_SIGNUP_ENABLED
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import {
|
|
||||||
getAssetProfileIdentifier,
|
|
||||||
getCurrencyFromSymbol,
|
|
||||||
isCurrency
|
|
||||||
} from '@ghostfolio/common/helper';
|
|
||||||
import {
|
import {
|
||||||
AdminData,
|
AdminData,
|
||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
AdminMarketDataDetails,
|
AdminMarketDataDetails,
|
||||||
AdminMarketDataItem,
|
AdminMarketDataItem,
|
||||||
AdminUsers,
|
Filter,
|
||||||
AssetProfileIdentifier,
|
UniqueAsset
|
||||||
EnhancedSymbolProfile,
|
|
||||||
Filter
|
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
|
||||||
import { MarketDataPreset } from '@ghostfolio/common/types';
|
import { MarketDataPreset } from '@ghostfolio/common/types';
|
||||||
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
|
||||||
import {
|
import {
|
||||||
AssetClass,
|
|
||||||
AssetSubClass,
|
AssetSubClass,
|
||||||
DataSource,
|
DataSource,
|
||||||
Prisma,
|
Prisma,
|
||||||
PrismaClient,
|
|
||||||
Property,
|
Property,
|
||||||
SymbolProfile
|
SymbolProfile
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
@ -49,12 +36,10 @@ import { groupBy } from 'lodash';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminService {
|
export class AdminService {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly benchmarkService: BenchmarkService,
|
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly marketDataService: MarketDataService,
|
private readonly marketDataService: MarketDataService,
|
||||||
private readonly orderService: OrderService,
|
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly propertyService: PropertyService,
|
private readonly propertyService: PropertyService,
|
||||||
private readonly subscriptionService: SubscriptionService,
|
private readonly subscriptionService: SubscriptionService,
|
||||||
@ -65,9 +50,7 @@ export class AdminService {
|
|||||||
currency,
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
}: AssetProfileIdentifier & { currency?: string }): Promise<
|
}: UniqueAsset & { currency?: string }): Promise<SymbolProfile | never> {
|
||||||
SymbolProfile | never
|
|
||||||
> {
|
|
||||||
try {
|
try {
|
||||||
if (dataSource === 'MANUAL') {
|
if (dataSource === 'MANUAL') {
|
||||||
return this.symbolProfileService.add({
|
return this.symbolProfileService.add({
|
||||||
@ -87,7 +70,7 @@ export class AdminService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.symbolProfileService.add(
|
return await this.symbolProfileService.add(
|
||||||
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
|
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -104,51 +87,41 @@ export class AdminService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deleteProfileData({
|
public async deleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
||||||
dataSource,
|
|
||||||
symbol
|
|
||||||
}: AssetProfileIdentifier) {
|
|
||||||
await this.marketDataService.deleteMany({ dataSource, symbol });
|
await this.marketDataService.deleteMany({ dataSource, symbol });
|
||||||
await this.symbolProfileService.delete({ dataSource, symbol });
|
await this.symbolProfileService.delete({ dataSource, symbol });
|
||||||
}
|
}
|
||||||
|
|
||||||
public async get(): Promise<AdminData> {
|
public async get(): Promise<AdminData> {
|
||||||
const exchangeRates = this.exchangeRateDataService
|
|
||||||
.getCurrencies()
|
|
||||||
.filter((currency) => {
|
|
||||||
return currency !== DEFAULT_CURRENCY;
|
|
||||||
})
|
|
||||||
.map((currency) => {
|
|
||||||
const label1 = DEFAULT_CURRENCY;
|
|
||||||
const label2 = currency;
|
|
||||||
|
|
||||||
return {
|
|
||||||
label1,
|
|
||||||
label2,
|
|
||||||
dataSource:
|
|
||||||
DataSource[
|
|
||||||
this.configurationService.get('DATA_SOURCE_EXCHANGE_RATES')
|
|
||||||
],
|
|
||||||
symbol: `${label1}${label2}`,
|
|
||||||
value: this.exchangeRateDataService.toCurrency(
|
|
||||||
1,
|
|
||||||
DEFAULT_CURRENCY,
|
|
||||||
currency
|
|
||||||
)
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const [settings, transactionCount, userCount] = await Promise.all([
|
|
||||||
this.propertyService.get(),
|
|
||||||
this.prismaService.order.count(),
|
|
||||||
this.countUsersWithAnalytics()
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
exchangeRates,
|
exchangeRates: this.exchangeRateDataService
|
||||||
settings,
|
.getCurrencies()
|
||||||
transactionCount,
|
.filter((currency) => {
|
||||||
userCount,
|
return currency !== DEFAULT_CURRENCY;
|
||||||
|
})
|
||||||
|
.map((currency) => {
|
||||||
|
const label1 = DEFAULT_CURRENCY;
|
||||||
|
const label2 = currency;
|
||||||
|
|
||||||
|
return {
|
||||||
|
label1,
|
||||||
|
label2,
|
||||||
|
dataSource:
|
||||||
|
DataSource[
|
||||||
|
this.configurationService.get('DATA_SOURCE_EXCHANGE_RATES')
|
||||||
|
],
|
||||||
|
symbol: `${label1}${label2}`,
|
||||||
|
value: this.exchangeRateDataService.toCurrency(
|
||||||
|
1,
|
||||||
|
DEFAULT_CURRENCY,
|
||||||
|
currency
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
settings: await this.propertyService.get(),
|
||||||
|
transactionCount: await this.prismaService.order.count(),
|
||||||
|
userCount: await this.prismaService.user.count(),
|
||||||
|
users: await this.getUsersWithAnalytics(),
|
||||||
version: environment.version
|
version: environment.version
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -172,16 +145,7 @@ export class AdminService {
|
|||||||
[{ symbol: 'asc' }];
|
[{ symbol: 'asc' }];
|
||||||
const where: Prisma.SymbolProfileWhereInput = {};
|
const where: Prisma.SymbolProfileWhereInput = {};
|
||||||
|
|
||||||
if (presetId === 'BENCHMARKS') {
|
if (presetId === 'CURRENCIES') {
|
||||||
const benchmarkAssetProfiles =
|
|
||||||
await this.benchmarkService.getBenchmarkAssetProfiles();
|
|
||||||
|
|
||||||
where.id = {
|
|
||||||
in: benchmarkAssetProfiles.map(({ id }) => {
|
|
||||||
return id;
|
|
||||||
})
|
|
||||||
};
|
|
||||||
} else if (presetId === 'CURRENCIES') {
|
|
||||||
return this.getMarketDataForCurrencies();
|
return this.getMarketDataForCurrencies();
|
||||||
} else if (
|
} else if (
|
||||||
presetId === 'ETF_WITHOUT_COUNTRIES' ||
|
presetId === 'ETF_WITHOUT_COUNTRIES' ||
|
||||||
@ -212,7 +176,6 @@ export class AdminService {
|
|||||||
|
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
where.OR = [
|
where.OR = [
|
||||||
{ id: { mode: 'insensitive', startsWith: searchQuery } },
|
|
||||||
{ isin: { mode: 'insensitive', startsWith: searchQuery } },
|
{ isin: { mode: 'insensitive', startsWith: searchQuery } },
|
||||||
{ name: { mode: 'insensitive', startsWith: searchQuery } },
|
{ name: { mode: 'insensitive', startsWith: searchQuery } },
|
||||||
{ symbol: { mode: 'insensitive', startsWith: searchQuery } }
|
{ symbol: { mode: 'insensitive', startsWith: searchQuery } }
|
||||||
@ -231,196 +194,101 @@ export class AdminService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const extendedPrismaClient = this.getExtendedPrismaClient();
|
let [assetProfiles, count] = await Promise.all([
|
||||||
|
this.prismaService.symbolProfile.findMany({
|
||||||
try {
|
orderBy,
|
||||||
const symbolProfileResult = await Promise.all([
|
skip,
|
||||||
extendedPrismaClient.symbolProfile.findMany({
|
take,
|
||||||
orderBy,
|
where,
|
||||||
skip,
|
|
||||||
take,
|
|
||||||
where,
|
|
||||||
select: {
|
|
||||||
_count: {
|
|
||||||
select: { Order: true }
|
|
||||||
},
|
|
||||||
assetClass: true,
|
|
||||||
assetSubClass: true,
|
|
||||||
comment: true,
|
|
||||||
countries: true,
|
|
||||||
currency: true,
|
|
||||||
dataSource: true,
|
|
||||||
id: true,
|
|
||||||
isUsedByUsersWithSubscription: true,
|
|
||||||
name: true,
|
|
||||||
Order: {
|
|
||||||
orderBy: [{ date: 'asc' }],
|
|
||||||
select: { date: true },
|
|
||||||
take: 1
|
|
||||||
},
|
|
||||||
scraperConfiguration: true,
|
|
||||||
sectors: true,
|
|
||||||
symbol: true,
|
|
||||||
SymbolProfileOverrides: true
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
this.prismaService.symbolProfile.count({ where })
|
|
||||||
]);
|
|
||||||
const assetProfiles = symbolProfileResult[0];
|
|
||||||
let count = symbolProfileResult[1];
|
|
||||||
|
|
||||||
const lastMarketPrices = await this.prismaService.marketData.findMany({
|
|
||||||
distinct: ['dataSource', 'symbol'],
|
|
||||||
orderBy: { date: 'desc' },
|
|
||||||
select: {
|
select: {
|
||||||
dataSource: true,
|
_count: {
|
||||||
marketPrice: true,
|
select: { Order: true }
|
||||||
symbol: true
|
|
||||||
},
|
|
||||||
where: {
|
|
||||||
dataSource: {
|
|
||||||
in: assetProfiles.map(({ dataSource }) => {
|
|
||||||
return dataSource;
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
symbol: {
|
assetClass: true,
|
||||||
in: assetProfiles.map(({ symbol }) => {
|
assetSubClass: true,
|
||||||
return symbol;
|
comment: true,
|
||||||
})
|
countries: true,
|
||||||
}
|
currency: true,
|
||||||
|
dataSource: true,
|
||||||
|
name: true,
|
||||||
|
Order: {
|
||||||
|
orderBy: [{ date: 'asc' }],
|
||||||
|
select: { date: true },
|
||||||
|
take: 1
|
||||||
|
},
|
||||||
|
scraperConfiguration: true,
|
||||||
|
sectors: true,
|
||||||
|
symbol: true
|
||||||
}
|
}
|
||||||
});
|
}),
|
||||||
|
this.prismaService.symbolProfile.count({ where })
|
||||||
|
]);
|
||||||
|
|
||||||
const lastMarketPriceMap = new Map<string, number>();
|
let marketData = assetProfiles.map(
|
||||||
|
({
|
||||||
for (const { dataSource, marketPrice, symbol } of lastMarketPrices) {
|
_count,
|
||||||
lastMarketPriceMap.set(
|
assetClass,
|
||||||
getAssetProfileIdentifier({ dataSource, symbol }),
|
assetSubClass,
|
||||||
marketPrice
|
comment,
|
||||||
);
|
countries,
|
||||||
}
|
currency,
|
||||||
|
dataSource,
|
||||||
let marketData: AdminMarketDataItem[] = await Promise.all(
|
name,
|
||||||
assetProfiles.map(
|
Order,
|
||||||
async ({
|
sectors,
|
||||||
_count,
|
symbol
|
||||||
assetClass,
|
}) => {
|
||||||
assetSubClass,
|
const countriesCount = countries ? Object.keys(countries).length : 0;
|
||||||
comment,
|
const marketDataItemCount =
|
||||||
countries,
|
marketDataItems.find((marketDataItem) => {
|
||||||
currency,
|
return (
|
||||||
dataSource,
|
marketDataItem.dataSource === dataSource &&
|
||||||
id,
|
marketDataItem.symbol === symbol
|
||||||
isUsedByUsersWithSubscription,
|
|
||||||
name,
|
|
||||||
Order,
|
|
||||||
sectors,
|
|
||||||
symbol,
|
|
||||||
SymbolProfileOverrides
|
|
||||||
}) => {
|
|
||||||
let countriesCount = countries ? Object.keys(countries).length : 0;
|
|
||||||
|
|
||||||
const lastMarketPrice = lastMarketPriceMap.get(
|
|
||||||
getAssetProfileIdentifier({ dataSource, symbol })
|
|
||||||
);
|
);
|
||||||
|
})?._count ?? 0;
|
||||||
|
const sectorsCount = sectors ? Object.keys(sectors).length : 0;
|
||||||
|
|
||||||
const marketDataItemCount =
|
return {
|
||||||
marketDataItems.find((marketDataItem) => {
|
assetClass,
|
||||||
return (
|
assetSubClass,
|
||||||
marketDataItem.dataSource === dataSource &&
|
comment,
|
||||||
marketDataItem.symbol === symbol
|
currency,
|
||||||
);
|
countriesCount,
|
||||||
})?._count ?? 0;
|
dataSource,
|
||||||
|
name,
|
||||||
|
symbol,
|
||||||
|
marketDataItemCount,
|
||||||
|
sectorsCount,
|
||||||
|
activitiesCount: _count.Order,
|
||||||
|
date: Order?.[0]?.date
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
let sectorsCount = sectors ? Object.keys(sectors).length : 0;
|
if (presetId) {
|
||||||
|
if (presetId === 'ETF_WITHOUT_COUNTRIES') {
|
||||||
if (SymbolProfileOverrides) {
|
marketData = marketData.filter(({ countriesCount }) => {
|
||||||
assetClass = SymbolProfileOverrides.assetClass ?? assetClass;
|
return countriesCount === 0;
|
||||||
assetSubClass =
|
});
|
||||||
SymbolProfileOverrides.assetSubClass ?? assetSubClass;
|
} else if (presetId === 'ETF_WITHOUT_SECTORS') {
|
||||||
|
marketData = marketData.filter(({ sectorsCount }) => {
|
||||||
if (
|
return sectorsCount === 0;
|
||||||
(
|
});
|
||||||
SymbolProfileOverrides.countries as unknown as Prisma.JsonArray
|
|
||||||
)?.length > 0
|
|
||||||
) {
|
|
||||||
countriesCount = (
|
|
||||||
SymbolProfileOverrides.countries as unknown as Prisma.JsonArray
|
|
||||||
).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
name = SymbolProfileOverrides.name ?? name;
|
|
||||||
|
|
||||||
if (
|
|
||||||
(SymbolProfileOverrides.sectors as unknown as Sector[])
|
|
||||||
?.length > 0
|
|
||||||
) {
|
|
||||||
sectorsCount = (
|
|
||||||
SymbolProfileOverrides.sectors as unknown as Prisma.JsonArray
|
|
||||||
).length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
assetClass,
|
|
||||||
assetSubClass,
|
|
||||||
comment,
|
|
||||||
currency,
|
|
||||||
countriesCount,
|
|
||||||
dataSource,
|
|
||||||
id,
|
|
||||||
lastMarketPrice,
|
|
||||||
name,
|
|
||||||
symbol,
|
|
||||||
marketDataItemCount,
|
|
||||||
sectorsCount,
|
|
||||||
activitiesCount: _count.Order,
|
|
||||||
date: Order?.[0]?.date,
|
|
||||||
isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription
|
|
||||||
};
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (presetId) {
|
|
||||||
if (presetId === 'ETF_WITHOUT_COUNTRIES') {
|
|
||||||
marketData = marketData.filter(({ countriesCount }) => {
|
|
||||||
return countriesCount === 0;
|
|
||||||
});
|
|
||||||
} else if (presetId === 'ETF_WITHOUT_SECTORS') {
|
|
||||||
marketData = marketData.filter(({ sectorsCount }) => {
|
|
||||||
return sectorsCount === 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
count = marketData.length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
count = marketData.length;
|
||||||
count,
|
|
||||||
marketData
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
await extendedPrismaClient.$disconnect();
|
|
||||||
|
|
||||||
Logger.debug('Disconnect extended prisma client', 'AdminService');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
count,
|
||||||
|
marketData
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getMarketDataBySymbol({
|
public async getMarketDataBySymbol({
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
}: AssetProfileIdentifier): Promise<AdminMarketDataDetails> {
|
}: UniqueAsset): Promise<AdminMarketDataDetails> {
|
||||||
let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0;
|
|
||||||
let currency: EnhancedSymbolProfile['currency'] = '-';
|
|
||||||
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
|
|
||||||
|
|
||||||
if (isCurrency(getCurrencyFromSymbol(symbol))) {
|
|
||||||
currency = getCurrencyFromSymbol(symbol);
|
|
||||||
({ activitiesCount, dateOfFirstActivity } =
|
|
||||||
await this.orderService.getStatisticsByCurrency(currency));
|
|
||||||
}
|
|
||||||
|
|
||||||
const [[assetProfile], marketData] = await Promise.all([
|
const [[assetProfile], marketData] = await Promise.all([
|
||||||
this.symbolProfileService.getSymbolProfiles([
|
this.symbolProfileService.getSymbolProfiles([
|
||||||
{
|
{
|
||||||
@ -439,85 +307,35 @@ export class AdminService {
|
|||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (assetProfile) {
|
|
||||||
assetProfile.dataProviderInfo = this.dataProviderService
|
|
||||||
.getDataProvider(assetProfile.dataSource)
|
|
||||||
.getDataProviderInfo();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
marketData,
|
marketData,
|
||||||
assetProfile: assetProfile ?? {
|
assetProfile: assetProfile ?? {
|
||||||
activitiesCount,
|
symbol,
|
||||||
currency,
|
currency: '-'
|
||||||
dataSource,
|
|
||||||
dateOfFirstActivity,
|
|
||||||
symbol
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getUsers({
|
|
||||||
skip,
|
|
||||||
take = Number.MAX_SAFE_INTEGER
|
|
||||||
}: {
|
|
||||||
skip?: number;
|
|
||||||
take?: number;
|
|
||||||
}): Promise<AdminUsers> {
|
|
||||||
const [count, users] = await Promise.all([
|
|
||||||
this.countUsersWithAnalytics(),
|
|
||||||
this.getUsersWithAnalytics({ skip, take })
|
|
||||||
]);
|
|
||||||
|
|
||||||
return { count, users };
|
|
||||||
}
|
|
||||||
|
|
||||||
public async patchAssetProfileData({
|
public async patchAssetProfileData({
|
||||||
assetClass,
|
assetClass,
|
||||||
assetSubClass,
|
assetSubClass,
|
||||||
comment,
|
comment,
|
||||||
countries,
|
|
||||||
currency,
|
|
||||||
dataSource,
|
dataSource,
|
||||||
holdings,
|
|
||||||
name,
|
name,
|
||||||
scraperConfiguration,
|
scraperConfiguration,
|
||||||
sectors,
|
|
||||||
symbol,
|
symbol,
|
||||||
symbolMapping,
|
symbolMapping
|
||||||
url
|
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
||||||
}: AssetProfileIdentifier & Prisma.SymbolProfileUpdateInput) {
|
await this.symbolProfileService.updateSymbolProfile({
|
||||||
const symbolProfileOverrides = {
|
assetClass,
|
||||||
assetClass: assetClass as AssetClass,
|
assetSubClass,
|
||||||
assetSubClass: assetSubClass as AssetSubClass,
|
|
||||||
name: name as string,
|
|
||||||
url: url as string
|
|
||||||
};
|
|
||||||
|
|
||||||
const updatedSymbolProfile: AssetProfileIdentifier &
|
|
||||||
Prisma.SymbolProfileUpdateInput = {
|
|
||||||
comment,
|
comment,
|
||||||
countries,
|
|
||||||
currency,
|
|
||||||
dataSource,
|
dataSource,
|
||||||
holdings,
|
name,
|
||||||
scraperConfiguration,
|
scraperConfiguration,
|
||||||
sectors,
|
|
||||||
symbol,
|
symbol,
|
||||||
symbolMapping,
|
symbolMapping
|
||||||
...(dataSource === 'MANUAL'
|
});
|
||||||
? { assetClass, assetSubClass, name, url }
|
|
||||||
: {
|
|
||||||
SymbolProfileOverrides: {
|
|
||||||
upsert: {
|
|
||||||
create: symbolProfileOverrides,
|
|
||||||
update: symbolProfileOverrides
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
await this.symbolProfileService.updateSymbolProfile(updatedSymbolProfile);
|
|
||||||
|
|
||||||
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
|
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
|
||||||
{
|
{
|
||||||
@ -547,124 +365,15 @@ export class AdminService {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async countUsersWithAnalytics() {
|
|
||||||
let where: Prisma.UserWhereInput;
|
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
|
||||||
where = {
|
|
||||||
NOT: {
|
|
||||||
Analytics: null
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.prismaService.user.count({
|
|
||||||
where
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private getExtendedPrismaClient() {
|
|
||||||
Logger.debug('Connect extended prisma client', 'AdminService');
|
|
||||||
|
|
||||||
const symbolProfileExtension = Prisma.defineExtension((client) => {
|
|
||||||
return client.$extends({
|
|
||||||
result: {
|
|
||||||
symbolProfile: {
|
|
||||||
isUsedByUsersWithSubscription: {
|
|
||||||
compute: async ({ id }) => {
|
|
||||||
const { _count } =
|
|
||||||
await this.prismaService.symbolProfile.findUnique({
|
|
||||||
select: {
|
|
||||||
_count: {
|
|
||||||
select: {
|
|
||||||
Order: {
|
|
||||||
where: {
|
|
||||||
User: {
|
|
||||||
Subscription: {
|
|
||||||
some: {
|
|
||||||
expiresAt: {
|
|
||||||
gt: new Date()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
where: {
|
|
||||||
id
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return _count.Order > 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return new PrismaClient().$extends(symbolProfileExtension);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getMarketDataForCurrencies(): Promise<AdminMarketData> {
|
private async getMarketDataForCurrencies(): Promise<AdminMarketData> {
|
||||||
const currencyPairs = this.exchangeRateDataService.getCurrencyPairs();
|
const marketDataItems = await this.prismaService.marketData.groupBy({
|
||||||
|
_count: true,
|
||||||
const [lastMarketPrices, marketDataItems] = await Promise.all([
|
by: ['dataSource', 'symbol']
|
||||||
this.prismaService.marketData.findMany({
|
});
|
||||||
distinct: ['dataSource', 'symbol'],
|
|
||||||
orderBy: { date: 'desc' },
|
|
||||||
select: {
|
|
||||||
dataSource: true,
|
|
||||||
marketPrice: true,
|
|
||||||
symbol: true
|
|
||||||
},
|
|
||||||
where: {
|
|
||||||
dataSource: {
|
|
||||||
in: currencyPairs.map(({ dataSource }) => {
|
|
||||||
return dataSource;
|
|
||||||
})
|
|
||||||
},
|
|
||||||
symbol: {
|
|
||||||
in: currencyPairs.map(({ symbol }) => {
|
|
||||||
return symbol;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
this.prismaService.marketData.groupBy({
|
|
||||||
_count: true,
|
|
||||||
by: ['dataSource', 'symbol']
|
|
||||||
})
|
|
||||||
]);
|
|
||||||
|
|
||||||
const lastMarketPriceMap = new Map<string, number>();
|
|
||||||
|
|
||||||
for (const { dataSource, marketPrice, symbol } of lastMarketPrices) {
|
|
||||||
lastMarketPriceMap.set(
|
|
||||||
getAssetProfileIdentifier({ dataSource, symbol }),
|
|
||||||
marketPrice
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const marketDataPromise: Promise<AdminMarketDataItem>[] = currencyPairs.map(
|
|
||||||
async ({ dataSource, symbol }) => {
|
|
||||||
let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0;
|
|
||||||
let currency: EnhancedSymbolProfile['currency'] = '-';
|
|
||||||
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
|
|
||||||
|
|
||||||
if (isCurrency(getCurrencyFromSymbol(symbol))) {
|
|
||||||
currency = getCurrencyFromSymbol(symbol);
|
|
||||||
({ activitiesCount, dateOfFirstActivity } =
|
|
||||||
await this.orderService.getStatisticsByCurrency(currency));
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastMarketPrice = lastMarketPriceMap.get(
|
|
||||||
getAssetProfileIdentifier({ dataSource, symbol })
|
|
||||||
);
|
|
||||||
|
|
||||||
|
const marketData: AdminMarketDataItem[] = this.exchangeRateDataService
|
||||||
|
.getCurrencyPairs()
|
||||||
|
.map(({ dataSource, symbol }) => {
|
||||||
const marketDataItemCount =
|
const marketDataItemCount =
|
||||||
marketDataItems.find((marketDataItem) => {
|
marketDataItems.find((marketDataItem) => {
|
||||||
return (
|
return (
|
||||||
@ -674,43 +383,30 @@ export class AdminService {
|
|||||||
})?._count ?? 0;
|
})?._count ?? 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activitiesCount,
|
|
||||||
currency,
|
|
||||||
dataSource,
|
dataSource,
|
||||||
lastMarketPrice,
|
|
||||||
marketDataItemCount,
|
marketDataItemCount,
|
||||||
symbol,
|
symbol,
|
||||||
assetClass: AssetClass.LIQUIDITY,
|
assetClass: 'CASH',
|
||||||
assetSubClass: AssetSubClass.CASH,
|
|
||||||
countriesCount: 0,
|
countriesCount: 0,
|
||||||
date: dateOfFirstActivity,
|
currency: symbol.replace(DEFAULT_CURRENCY, ''),
|
||||||
id: undefined,
|
|
||||||
name: symbol,
|
name: symbol,
|
||||||
sectorsCount: 0
|
sectorsCount: 0
|
||||||
};
|
};
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const marketData = await Promise.all(marketDataPromise);
|
|
||||||
return { marketData, count: marketData.length };
|
return { marketData, count: marketData.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getUsersWithAnalytics({
|
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
|
||||||
skip,
|
let orderBy: any = {
|
||||||
take
|
|
||||||
}: {
|
|
||||||
skip?: number;
|
|
||||||
take?: number;
|
|
||||||
}): Promise<AdminUsers['users']> {
|
|
||||||
let orderBy: Prisma.UserOrderByWithRelationInput = {
|
|
||||||
createdAt: 'desc'
|
createdAt: 'desc'
|
||||||
};
|
};
|
||||||
let where: Prisma.UserWhereInput;
|
let where;
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
orderBy = {
|
orderBy = {
|
||||||
Analytics: {
|
Analytics: {
|
||||||
lastRequestAt: 'desc'
|
updatedAt: 'desc'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
where = {
|
where = {
|
||||||
@ -722,8 +418,6 @@ export class AdminService {
|
|||||||
|
|
||||||
const usersWithAnalytics = await this.prismaService.user.findMany({
|
const usersWithAnalytics = await this.prismaService.user.findMany({
|
||||||
orderBy,
|
orderBy,
|
||||||
skip,
|
|
||||||
take,
|
|
||||||
where,
|
where,
|
||||||
select: {
|
select: {
|
||||||
_count: {
|
_count: {
|
||||||
@ -733,19 +427,18 @@ export class AdminService {
|
|||||||
select: {
|
select: {
|
||||||
activityCount: true,
|
activityCount: true,
|
||||||
country: true,
|
country: true,
|
||||||
dataProviderGhostfolioDailyRequests: true,
|
|
||||||
updatedAt: true
|
updatedAt: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
id: true,
|
id: true,
|
||||||
role: true,
|
|
||||||
Subscription: true
|
Subscription: true
|
||||||
}
|
},
|
||||||
|
take: 30
|
||||||
});
|
});
|
||||||
|
|
||||||
return usersWithAnalytics.map(
|
return usersWithAnalytics.map(
|
||||||
({ _count, Analytics, createdAt, id, role, Subscription }) => {
|
({ _count, Analytics, createdAt, id, Subscription }) => {
|
||||||
const daysSinceRegistration =
|
const daysSinceRegistration =
|
||||||
differenceInDays(new Date(), createdAt) + 1;
|
differenceInDays(new Date(), createdAt) + 1;
|
||||||
const engagement = Analytics
|
const engagement = Analytics
|
||||||
@ -755,21 +448,16 @@ export class AdminService {
|
|||||||
const subscription = this.configurationService.get(
|
const subscription = this.configurationService.get(
|
||||||
'ENABLE_FEATURE_SUBSCRIPTION'
|
'ENABLE_FEATURE_SUBSCRIPTION'
|
||||||
)
|
)
|
||||||
? this.subscriptionService.getSubscription({
|
? this.subscriptionService.getSubscription(Subscription)
|
||||||
createdAt,
|
|
||||||
subscriptions: Subscription
|
|
||||||
})
|
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
createdAt,
|
createdAt,
|
||||||
engagement,
|
engagement,
|
||||||
id,
|
id,
|
||||||
role,
|
|
||||||
subscription,
|
subscription,
|
||||||
accountCount: _count.Account || 0,
|
accountCount: _count.Account || 0,
|
||||||
country: Analytics?.country,
|
country: Analytics?.country,
|
||||||
dailyApiRequests: Analytics?.dataProviderGhostfolioDailyRequests || 0,
|
|
||||||
lastActivity: Analytics?.updatedAt,
|
lastActivity: Analytics?.updatedAt,
|
||||||
transactionCount: _count.Order || 0
|
transactionCount: _count.Order || 0
|
||||||
};
|
};
|
||||||
|
@ -1,56 +1,87 @@
|
|||||||
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 { permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, 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(private readonly queueService: QueueService) {}
|
public constructor(
|
||||||
|
private readonly queueService: QueueService,
|
||||||
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
|
) {}
|
||||||
|
|
||||||
@Delete('job')
|
@Delete('job')
|
||||||
@HasPermission(permissions.accessAdminControl)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
|
||||||
public async deleteJobs(
|
public async deleteJobs(
|
||||||
@Query('status') filterByStatus?: string
|
@Query('status') filterByStatus?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const status = (filterByStatus?.split(',') as JobStatus[]) ?? undefined;
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
|
||||||
return this.queueService.deleteJobs({ status });
|
return this.queueService.deleteJobs({ status });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('job')
|
@Get('job')
|
||||||
@HasPermission(permissions.accessAdminControl)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
|
||||||
public async getJobs(
|
public async getJobs(
|
||||||
@Query('status') filterByStatus?: string
|
@Query('status') filterByStatus?: string
|
||||||
): Promise<AdminJobs> {
|
): Promise<AdminJobs> {
|
||||||
const status = (filterByStatus?.split(',') as JobStatus[]) ?? undefined;
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
|
||||||
return this.queueService.getJobs({ status });
|
return this.queueService.getJobs({ status });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('job/:id')
|
@Delete('job/:id')
|
||||||
@HasPermission(permissions.accessAdminControl)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('job/:id/execute')
|
|
||||||
@HasPermission(permissions.accessAdminControl)
|
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
|
||||||
public async executeJob(@Param('id') id: string): Promise<void> {
|
|
||||||
return this.queueService.executeJob(id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||||
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
|
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { QueueController } from './queue.controller';
|
import { QueueController } from './queue.controller';
|
||||||
@ -8,7 +6,7 @@ import { QueueService } from './queue.service';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [QueueController],
|
controllers: [QueueController],
|
||||||
imports: [DataGatheringModule, PortfolioSnapshotQueueModule],
|
imports: [DataGatheringModule],
|
||||||
providers: [QueueService]
|
providers: [QueueService]
|
||||||
})
|
})
|
||||||
export class QueueModule {}
|
export class QueueModule {}
|
||||||
|
@ -1,10 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
DATA_GATHERING_QUEUE,
|
DATA_GATHERING_QUEUE,
|
||||||
PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE,
|
|
||||||
QUEUE_JOB_STATUS_LIST
|
QUEUE_JOB_STATUS_LIST
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { InjectQueue } from '@nestjs/bull';
|
import { InjectQueue } from '@nestjs/bull';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { JobStatus, Queue } from 'bull';
|
import { JobStatus, Queue } from 'bull';
|
||||||
@ -13,19 +11,11 @@ import { JobStatus, Queue } from 'bull';
|
|||||||
export class QueueService {
|
export class QueueService {
|
||||||
public constructor(
|
public constructor(
|
||||||
@InjectQueue(DATA_GATHERING_QUEUE)
|
@InjectQueue(DATA_GATHERING_QUEUE)
|
||||||
private readonly dataGatheringQueue: Queue,
|
private readonly dataGatheringQueue: Queue
|
||||||
@InjectQueue(PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE)
|
|
||||||
private readonly portfolioSnapshotQueue: Queue
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async deleteJob(aId: string) {
|
public async deleteJob(aId: string) {
|
||||||
let job = await this.dataGatheringQueue.getJob(aId);
|
return (await this.dataGatheringQueue.getJob(aId))?.remove();
|
||||||
|
|
||||||
if (!job) {
|
|
||||||
job = await this.portfolioSnapshotQueue.getJob(aId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return job?.remove();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deleteJobs({
|
public async deleteJobs({
|
||||||
@ -34,23 +24,13 @@ export class QueueService {
|
|||||||
status?: JobStatus[];
|
status?: JobStatus[];
|
||||||
}) {
|
}) {
|
||||||
for (const statusItem of status) {
|
for (const statusItem of status) {
|
||||||
const queueStatus = statusItem === 'waiting' ? 'wait' : statusItem;
|
await this.dataGatheringQueue.clean(
|
||||||
|
300,
|
||||||
await this.dataGatheringQueue.clean(300, queueStatus);
|
statusItem === 'waiting' ? 'wait' : statusItem
|
||||||
await this.portfolioSnapshotQueue.clean(300, queueStatus);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async executeJob(aId: string) {
|
|
||||||
let job = await this.dataGatheringQueue.getJob(aId);
|
|
||||||
|
|
||||||
if (!job) {
|
|
||||||
job = await this.portfolioSnapshotQueue.getJob(aId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return job?.promote();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getJobs({
|
public async getJobs({
|
||||||
limit = 1000,
|
limit = 1000,
|
||||||
status = QUEUE_JOB_STATUS_LIST
|
status = QUEUE_JOB_STATUS_LIST
|
||||||
@ -58,13 +38,10 @@ export class QueueService {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
status?: JobStatus[];
|
status?: JobStatus[];
|
||||||
}): Promise<AdminJobs> {
|
}): Promise<AdminJobs> {
|
||||||
const [dataGatheringJobs, portfolioSnapshotJobs] = await Promise.all([
|
const jobs = await this.dataGatheringQueue.getJobs(status);
|
||||||
this.dataGatheringQueue.getJobs(status),
|
|
||||||
this.portfolioSnapshotQueue.getJobs(status)
|
|
||||||
]);
|
|
||||||
|
|
||||||
const jobsWithState = await Promise.all(
|
const jobsWithState = await Promise.all(
|
||||||
[...dataGatheringJobs, ...portfolioSnapshotJobs]
|
jobs
|
||||||
.filter((job) => {
|
.filter((job) => {
|
||||||
return job;
|
return job;
|
||||||
})
|
})
|
||||||
@ -76,7 +53,6 @@ export class QueueService {
|
|||||||
finishedOn: job.finishedOn,
|
finishedOn: job.finishedOn,
|
||||||
id: job.id,
|
id: job.id,
|
||||||
name: job.name,
|
name: job.name,
|
||||||
opts: job.opts,
|
|
||||||
stacktrace: job.stacktrace,
|
stacktrace: job.stacktrace,
|
||||||
state: await job.getState(),
|
state: await job.getState(),
|
||||||
timestamp: job.timestamp
|
timestamp: job.timestamp
|
||||||
|
@ -1,14 +1,5 @@
|
|||||||
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
|
||||||
|
|
||||||
import { AssetClass, AssetSubClass, Prisma } from '@prisma/client';
|
import { AssetClass, AssetSubClass, Prisma } from '@prisma/client';
|
||||||
import {
|
import { IsEnum, IsObject, IsOptional, IsString } from 'class-validator';
|
||||||
IsArray,
|
|
||||||
IsEnum,
|
|
||||||
IsObject,
|
|
||||||
IsOptional,
|
|
||||||
IsString,
|
|
||||||
IsUrl
|
|
||||||
} from 'class-validator';
|
|
||||||
|
|
||||||
export class UpdateAssetProfileDto {
|
export class UpdateAssetProfileDto {
|
||||||
@IsEnum(AssetClass, { each: true })
|
@IsEnum(AssetClass, { each: true })
|
||||||
@ -23,14 +14,6 @@ export class UpdateAssetProfileDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
|
||||||
@IsArray()
|
|
||||||
@IsOptional()
|
|
||||||
countries?: Prisma.InputJsonArray;
|
|
||||||
|
|
||||||
@IsCurrencyCode()
|
|
||||||
@IsOptional()
|
|
||||||
currency?: string;
|
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
name?: string;
|
name?: string;
|
||||||
@ -39,20 +22,9 @@ export class UpdateAssetProfileDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
scraperConfiguration?: Prisma.InputJsonObject;
|
scraperConfiguration?: Prisma.InputJsonObject;
|
||||||
|
|
||||||
@IsArray()
|
|
||||||
@IsOptional()
|
|
||||||
sectors?: Prisma.InputJsonArray;
|
|
||||||
|
|
||||||
@IsObject()
|
@IsObject()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
symbolMapping?: {
|
symbolMapping?: {
|
||||||
[dataProvider: string]: string;
|
[dataProvider: string]: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsUrl({
|
|
||||||
protocols: ['https'],
|
|
||||||
require_protocol: true
|
|
||||||
})
|
|
||||||
url?: string;
|
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { ArrayNotEmpty, IsArray } from 'class-validator';
|
import { ArrayNotEmpty, IsArray, isNotEmptyObject } from 'class-validator';
|
||||||
|
|
||||||
import { UpdateMarketDataDto } from './update-market-data.dto';
|
import { UpdateMarketDataDto } from './update-market-data.dto';
|
||||||
|
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
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 { Controller } from '@nestjs/common';
|
import { Controller } from '@nestjs/common';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
|
@ -1,42 +1,31 @@
|
|||||||
import { EventsModule } from '@ghostfolio/api/events/events.module';
|
import { join } from 'path';
|
||||||
|
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { CronService } from '@ghostfolio/api/services/cron.service';
|
import { CronService } from '@ghostfolio/api/services/cron.service';
|
||||||
|
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';
|
||||||
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 { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
|
|
||||||
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
|
|
||||||
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
|
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
|
||||||
import {
|
import {
|
||||||
DEFAULT_LANGUAGE_CODE,
|
DEFAULT_LANGUAGE_CODE,
|
||||||
SUPPORTED_LANGUAGE_CODES
|
SUPPORTED_LANGUAGE_CODES
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
|
|
||||||
import { BullModule } from '@nestjs/bull';
|
import { BullModule } from '@nestjs/bull';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
|
||||||
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 { StatusCodes } from 'http-status-codes';
|
||||||
import { join } from 'path';
|
|
||||||
|
|
||||||
import { AccessModule } from './access/access.module';
|
import { AccessModule } from './access/access.module';
|
||||||
import { AccountModule } from './account/account.module';
|
import { AccountModule } from './account/account.module';
|
||||||
import { AdminModule } from './admin/admin.module';
|
import { AdminModule } from './admin/admin.module';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { AssetModule } from './asset/asset.module';
|
|
||||||
import { AuthDeviceModule } from './auth-device/auth-device.module';
|
import { AuthDeviceModule } from './auth-device/auth-device.module';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
|
import { BenchmarkModule } from './benchmark/benchmark.module';
|
||||||
import { CacheModule } from './cache/cache.module';
|
import { CacheModule } from './cache/cache.module';
|
||||||
import { AiModule } from './endpoints/ai/ai.module';
|
|
||||||
import { ApiKeysModule } from './endpoints/api-keys/api-keys.module';
|
|
||||||
import { BenchmarksModule } from './endpoints/benchmarks/benchmarks.module';
|
|
||||||
import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module';
|
|
||||||
import { MarketDataModule } from './endpoints/market-data/market-data.module';
|
|
||||||
import { PublicModule } from './endpoints/public/public.module';
|
|
||||||
import { TagsModule } from './endpoints/tags/tags.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 { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
@ -50,23 +39,19 @@ import { RedisCacheModule } from './redis-cache/redis-cache.module';
|
|||||||
import { SitemapModule } from './sitemap/sitemap.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({
|
||||||
controllers: [AppController],
|
|
||||||
imports: [
|
imports: [
|
||||||
AdminModule,
|
AdminModule,
|
||||||
AccessModule,
|
AccessModule,
|
||||||
AccountModule,
|
AccountModule,
|
||||||
AiModule,
|
|
||||||
ApiKeysModule,
|
|
||||||
AssetModule,
|
|
||||||
AuthDeviceModule,
|
AuthDeviceModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
BenchmarksModule,
|
BenchmarkModule,
|
||||||
BullModule.forRoot({
|
BullModule.forRoot({
|
||||||
redis: {
|
redis: {
|
||||||
db: parseInt(process.env.REDIS_DB ?? '0', 10),
|
|
||||||
host: process.env.REDIS_HOST,
|
host: process.env.REDIS_HOST,
|
||||||
port: parseInt(process.env.REDIS_PORT ?? '6379', 10),
|
port: parseInt(process.env.REDIS_PORT ?? '6379', 10),
|
||||||
password: process.env.REDIS_PASSWORD
|
password: process.env.REDIS_PASSWORD
|
||||||
@ -77,24 +62,17 @@ import { UserModule } from './user/user.module';
|
|||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
EventEmitterModule.forRoot(),
|
|
||||||
EventsModule,
|
|
||||||
ExchangeRateModule,
|
ExchangeRateModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
ExportModule,
|
ExportModule,
|
||||||
GhostfolioModule,
|
|
||||||
HealthModule,
|
HealthModule,
|
||||||
ImportModule,
|
ImportModule,
|
||||||
InfoModule,
|
InfoModule,
|
||||||
LogoModule,
|
LogoModule,
|
||||||
MarketDataModule,
|
|
||||||
OrderModule,
|
OrderModule,
|
||||||
PlatformModule,
|
PlatformModule,
|
||||||
PortfolioModule,
|
PortfolioModule,
|
||||||
PortfolioSnapshotQueueModule,
|
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
PropertyModule,
|
|
||||||
PublicModule,
|
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
ServeStaticModule.forRoot({
|
ServeStaticModule.forRoot({
|
||||||
@ -124,10 +102,11 @@ import { UserModule } from './user/user.module';
|
|||||||
SitemapModule,
|
SitemapModule,
|
||||||
SubscriptionModule,
|
SubscriptionModule,
|
||||||
SymbolModule,
|
SymbolModule,
|
||||||
TagsModule,
|
TagModule,
|
||||||
TwitterBotModule,
|
TwitterBotModule,
|
||||||
UserModule
|
UserModule
|
||||||
],
|
],
|
||||||
|
controllers: [AppController],
|
||||||
providers: [CronService]
|
providers: [CronService]
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
@ -1,29 +0,0 @@
|
|||||||
import { AdminService } from '@ghostfolio/api/app/admin/admin.service';
|
|
||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
|
|
||||||
import type { AdminMarketDataDetails } from '@ghostfolio/common/interfaces';
|
|
||||||
|
|
||||||
import { Controller, Get, Param, UseInterceptors } from '@nestjs/common';
|
|
||||||
import { DataSource } from '@prisma/client';
|
|
||||||
import { pick } from 'lodash';
|
|
||||||
|
|
||||||
@Controller('asset')
|
|
||||||
export class AssetController {
|
|
||||||
public constructor(private readonly adminService: AdminService) {}
|
|
||||||
|
|
||||||
@Get(':dataSource/:symbol')
|
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
|
||||||
public async getAsset(
|
|
||||||
@Param('dataSource') dataSource: DataSource,
|
|
||||||
@Param('symbol') symbol: string
|
|
||||||
): Promise<AdminMarketDataDetails> {
|
|
||||||
const { assetProfile, marketData } =
|
|
||||||
await this.adminService.getMarketDataBySymbol({ dataSource, symbol });
|
|
||||||
|
|
||||||
return {
|
|
||||||
marketData,
|
|
||||||
assetProfile: pick(assetProfile, ['dataSource', 'name', 'symbol'])
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,17 +0,0 @@
|
|||||||
import { AdminModule } from '@ghostfolio/api/app/admin/admin.module';
|
|
||||||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
|
||||||
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
|
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
|
||||||
|
|
||||||
import { AssetController } from './asset.controller';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
controllers: [AssetController],
|
|
||||||
imports: [
|
|
||||||
AdminModule,
|
|
||||||
TransformDataSourceInRequestModule,
|
|
||||||
TransformDataSourceInResponseModule
|
|
||||||
]
|
|
||||||
})
|
|
||||||
export class AssetModule {}
|
|
@ -1,19 +1,40 @@
|
|||||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import { permissions } from '@ghostfolio/common/permissions';
|
import {
|
||||||
|
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(private readonly authDeviceService: AuthDeviceService) {}
|
public constructor(
|
||||||
|
private readonly authDeviceService: AuthDeviceService,
|
||||||
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
|
) {}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@HasPermission(permissions.deleteAuthDevice)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@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,13 +1,14 @@
|
|||||||
import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller';
|
import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller';
|
||||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||||
|
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';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [AuthDeviceController],
|
controllers: [AuthDeviceController],
|
||||||
imports: [
|
imports: [
|
||||||
|
ConfigurationModule,
|
||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
secret: process.env.JWT_SECRET_KEY,
|
secret: process.env.JWT_SECRET_KEY,
|
||||||
signOptions: { expiresIn: '180 days' }
|
signOptions: { expiresIn: '180 days' }
|
||||||
|
@ -1,11 +1,14 @@
|
|||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { AuthDevice, Prisma } from '@prisma/client';
|
import { AuthDevice, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthDeviceService {
|
export class AuthDeviceService {
|
||||||
public constructor(private readonly prismaService: PrismaService) {}
|
public constructor(
|
||||||
|
private readonly configurationService: ConfigurationService,
|
||||||
|
private readonly prismaService: PrismaService
|
||||||
|
) {}
|
||||||
|
|
||||||
public async authDevice(
|
public async authDevice(
|
||||||
where: Prisma.AuthDeviceWhereUniqueInput
|
where: Prisma.AuthDeviceWhereUniqueInput
|
||||||
|
@ -1,76 +0,0 @@
|
|||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
|
||||||
import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service';
|
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
|
||||||
import { HEADER_KEY_TOKEN } from '@ghostfolio/common/config';
|
|
||||||
import { hasRole } from '@ghostfolio/common/permissions';
|
|
||||||
|
|
||||||
import { HttpException, Injectable } from '@nestjs/common';
|
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
|
||||||
import { HeaderAPIKeyStrategy } from 'passport-headerapikey';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class ApiKeyStrategy extends PassportStrategy(
|
|
||||||
HeaderAPIKeyStrategy,
|
|
||||||
'api-key'
|
|
||||||
) {
|
|
||||||
public constructor(
|
|
||||||
private readonly apiKeyService: ApiKeyService,
|
|
||||||
private readonly configurationService: ConfigurationService,
|
|
||||||
private readonly prismaService: PrismaService,
|
|
||||||
private readonly userService: UserService
|
|
||||||
) {
|
|
||||||
super(
|
|
||||||
{ header: HEADER_KEY_TOKEN, prefix: 'Api-Key ' },
|
|
||||||
true,
|
|
||||||
async (apiKey: string, done: (error: any, user?: any) => void) => {
|
|
||||||
try {
|
|
||||||
const user = await this.validateApiKey(apiKey);
|
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
|
||||||
if (hasRole(user, 'INACTIVE')) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
|
||||||
StatusCodes.TOO_MANY_REQUESTS
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.prismaService.analytics.upsert({
|
|
||||||
create: { User: { connect: { id: user.id } } },
|
|
||||||
update: {
|
|
||||||
activityCount: { increment: 1 },
|
|
||||||
lastRequestAt: new Date()
|
|
||||||
},
|
|
||||||
where: { userId: user.id }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
done(null, user);
|
|
||||||
} catch (error) {
|
|
||||||
done(error, null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async validateApiKey(apiKey: string) {
|
|
||||||
if (!apiKey) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.UNAUTHORIZED),
|
|
||||||
StatusCodes.UNAUTHORIZED
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { id } = await this.apiKeyService.getUserByApiKey(apiKey);
|
|
||||||
|
|
||||||
return this.userService.user({ id });
|
|
||||||
} catch {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.UNAUTHORIZED),
|
|
||||||
StatusCodes.UNAUTHORIZED
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,9 +1,7 @@
|
|||||||
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';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@ -14,12 +12,12 @@ import {
|
|||||||
Req,
|
Req,
|
||||||
Res,
|
Res,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
Version,
|
VERSION_NEUTRAL,
|
||||||
VERSION_NEUTRAL
|
Version
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import {
|
import {
|
||||||
@ -85,7 +83,7 @@ export class AuthController {
|
|||||||
@Res() response: Response
|
@Res() response: Response
|
||||||
) {
|
) {
|
||||||
// Handles the Google OAuth2 callback
|
// Handles the Google OAuth2 callback
|
||||||
const jwt: string = (request.user as any).jwt;
|
const jwt: string = (<any>request.user).jwt;
|
||||||
|
|
||||||
if (jwt) {
|
if (jwt) {
|
||||||
response.redirect(
|
response.redirect(
|
||||||
@ -120,17 +118,20 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('webauthn/generate-registration-options')
|
@Get('webauthn/generate-registration-options')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
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'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async verifyAttestation(
|
public async verifyAttestation(
|
||||||
@Body() body: { deviceName: string; credential: AttestationCredentialJSON }
|
@Body() body: { deviceName: string; credential: AttestationCredentialJSON }
|
||||||
) {
|
) {
|
||||||
return this.webAuthService.verifyAttestation(body.credential);
|
return this.webAuthService.verifyAttestation(
|
||||||
|
body.deviceName,
|
||||||
|
body.credential
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('webauthn/generate-assertion-options')
|
@Post('webauthn/generate-assertion-options')
|
||||||
|
@ -2,15 +2,12 @@ import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.s
|
|||||||
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
||||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service';
|
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
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 { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
|
||||||
import { ApiKeyStrategy } from './api-key.strategy';
|
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { GoogleStrategy } from './google.strategy';
|
import { GoogleStrategy } from './google.strategy';
|
||||||
@ -30,8 +27,6 @@ import { JwtStrategy } from './jwt.strategy';
|
|||||||
UserModule
|
UserModule
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
ApiKeyService,
|
|
||||||
ApiKeyStrategy,
|
|
||||||
AuthDeviceService,
|
AuthDeviceService,
|
||||||
AuthService,
|
AuthService,
|
||||||
GoogleStrategy,
|
GoogleStrategy,
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { UserService } from '@ghostfolio/api/app/user/user.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 { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
|
|
||||||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { Provider } from '@prisma/client';
|
import { Provider } from '@prisma/client';
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
|
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { Provider } from '@prisma/client';
|
import { Provider } from '@prisma/client';
|
||||||
import { Profile, Strategy } from 'passport-google-oauth20';
|
import { Strategy } from 'passport-google-oauth20';
|
||||||
|
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
@ -11,7 +10,7 @@ import { AuthService } from './auth.service';
|
|||||||
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService,
|
||||||
configurationService: ConfigurationService
|
readonly configurationService: ConfigurationService
|
||||||
) {
|
) {
|
||||||
super({
|
super({
|
||||||
callbackURL: `${configurationService.get(
|
callbackURL: `${configurationService.get(
|
||||||
@ -20,24 +19,28 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
|||||||
clientID: configurationService.get('GOOGLE_CLIENT_ID'),
|
clientID: configurationService.get('GOOGLE_CLIENT_ID'),
|
||||||
clientSecret: configurationService.get('GOOGLE_SECRET'),
|
clientSecret: configurationService.get('GOOGLE_SECRET'),
|
||||||
passReqToCallback: true,
|
passReqToCallback: true,
|
||||||
scope: ['profile']
|
scope: ['email', 'profile']
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async validate(
|
public async validate(
|
||||||
_request: any,
|
request: any,
|
||||||
_token: string,
|
token: string,
|
||||||
_refreshToken: string,
|
refreshToken: string,
|
||||||
profile: Profile,
|
profile,
|
||||||
done: Function
|
done: Function,
|
||||||
|
done2: Function
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const jwt = await this.authService.validateOAuthLogin({
|
const jwt: string = await this.authService.validateOAuthLogin({
|
||||||
provider: Provider.GOOGLE,
|
provider: Provider.GOOGLE,
|
||||||
thirdPartyId: profile.id
|
thirdPartyId: profile.id
|
||||||
});
|
});
|
||||||
|
const user = {
|
||||||
|
jwt
|
||||||
|
};
|
||||||
|
|
||||||
done(null, { jwt });
|
done(null, user);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error, 'GoogleStrategy');
|
Logger.error(error, 'GoogleStrategy');
|
||||||
done(error, false);
|
done(error, false);
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
|
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
|
||||||
|
|
||||||
import { Provider } from '@prisma/client';
|
import { Provider } from '@prisma/client';
|
||||||
|
|
||||||
export interface AuthDeviceDialogParams {
|
export interface AuthDeviceDialogParams {
|
||||||
|
@ -198,12 +198,12 @@ export interface AuthenticatorAssertionResponseJSON
|
|||||||
/**
|
/**
|
||||||
* A WebAuthn-compatible device and the information needed to verify assertions by it
|
* A WebAuthn-compatible device and the information needed to verify assertions by it
|
||||||
*/
|
*/
|
||||||
export declare interface AuthenticatorDevice {
|
export declare type AuthenticatorDevice = {
|
||||||
credentialPublicKey: Buffer;
|
credentialPublicKey: Buffer;
|
||||||
credentialID: Buffer;
|
credentialID: Buffer;
|
||||||
counter: number;
|
counter: number;
|
||||||
transports?: AuthenticatorTransport[];
|
transports?: AuthenticatorTransport[];
|
||||||
}
|
};
|
||||||
/**
|
/**
|
||||||
* An attempt to communicate that this isn't just any string, but a Base64URL-encoded string
|
* An attempt to communicate that this isn't just any string, but a Base64URL-encoded string
|
||||||
*/
|
*/
|
||||||
|
@ -2,12 +2,9 @@ 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 { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config';
|
import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config';
|
||||||
import { hasRole } from '@ghostfolio/common/permissions';
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
|
||||||
import { HttpException, Injectable } from '@nestjs/common';
|
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import * as countriesAndTimezones from 'countries-and-timezones';
|
import * as countriesAndTimezones from 'countries-and-timezones';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
|
||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -31,13 +28,6 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
if (hasRole(user, 'INACTIVE')) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
|
||||||
StatusCodes.TOO_MANY_REQUESTS
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const country =
|
const country =
|
||||||
countriesAndTimezones.getCountryForTimezone(timezone)?.id;
|
countriesAndTimezones.getCountryForTimezone(timezone)?.id;
|
||||||
|
|
||||||
@ -46,7 +36,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|||||||
update: {
|
update: {
|
||||||
country,
|
country,
|
||||||
activityCount: { increment: 1 },
|
activityCount: { increment: 1 },
|
||||||
lastRequestAt: new Date()
|
updatedAt: new Date()
|
||||||
},
|
},
|
||||||
where: { userId: user.id }
|
where: { userId: user.id }
|
||||||
});
|
});
|
||||||
@ -54,20 +44,10 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|||||||
|
|
||||||
return user;
|
return user;
|
||||||
} else {
|
} else {
|
||||||
throw new HttpException(
|
throw '';
|
||||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
|
||||||
StatusCodes.NOT_FOUND
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error?.getStatus?.() === StatusCodes.TOO_MANY_REQUESTS) {
|
|
||||||
throw error;
|
|
||||||
} else {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.UNAUTHORIZED),
|
|
||||||
StatusCodes.UNAUTHORIZED
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
throw new UnauthorizedException('unauthorized', err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,6 @@ import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.s
|
|||||||
import { UserService } from '@ghostfolio/api/app/user/user.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 type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Inject,
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
@ -13,16 +12,16 @@ import {
|
|||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import {
|
import {
|
||||||
generateAuthenticationOptions,
|
|
||||||
GenerateAuthenticationOptionsOpts,
|
GenerateAuthenticationOptionsOpts,
|
||||||
generateRegistrationOptions,
|
|
||||||
GenerateRegistrationOptionsOpts,
|
GenerateRegistrationOptionsOpts,
|
||||||
VerifiedAuthenticationResponse,
|
VerifiedAuthenticationResponse,
|
||||||
VerifiedRegistrationResponse,
|
VerifiedRegistrationResponse,
|
||||||
verifyAuthenticationResponse,
|
|
||||||
VerifyAuthenticationResponseOpts,
|
VerifyAuthenticationResponseOpts,
|
||||||
verifyRegistrationResponse,
|
VerifyRegistrationResponseOpts,
|
||||||
VerifyRegistrationResponseOpts
|
generateAuthenticationOptions,
|
||||||
|
generateRegistrationOptions,
|
||||||
|
verifyAuthenticationResponse,
|
||||||
|
verifyRegistrationResponse
|
||||||
} from '@simplewebauthn/server';
|
} from '@simplewebauthn/server';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -41,7 +40,7 @@ export class WebAuthService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
get rpID() {
|
get rpID() {
|
||||||
return new URL(this.configurationService.get('ROOT_URL')).hostname;
|
return this.configurationService.get('WEB_AUTH_RP_ID');
|
||||||
}
|
}
|
||||||
|
|
||||||
get expectedOrigin() {
|
get expectedOrigin() {
|
||||||
@ -80,6 +79,7 @@ export class WebAuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async verifyAttestation(
|
public async verifyAttestation(
|
||||||
|
deviceName: string,
|
||||||
credential: AttestationCredentialJSON
|
credential: AttestationCredentialJSON
|
||||||
): Promise<AuthDeviceDto> {
|
): Promise<AuthDeviceDto> {
|
||||||
const user = this.request.user;
|
const user = this.request.user;
|
||||||
|
138
apps/api/src/app/benchmark/benchmark.controller.ts
Normal file
138
apps/api/src/app/benchmark/benchmark.controller.ts
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
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 type {
|
||||||
|
BenchmarkMarketDataDetails,
|
||||||
|
BenchmarkResponse,
|
||||||
|
UniqueAsset
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpException,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { REQUEST } from '@nestjs/core';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
|
import { BenchmarkService } from './benchmark.service';
|
||||||
|
|
||||||
|
@Controller('benchmark')
|
||||||
|
export class BenchmarkController {
|
||||||
|
public constructor(
|
||||||
|
private readonly benchmarkService: BenchmarkService,
|
||||||
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const benchmark = await this.benchmarkService.addBenchmark({
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':dataSource/:symbol')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async deleteBenchmark(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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'))
|
||||||
|
@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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -1,22 +1,27 @@
|
|||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
|
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.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 { 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';
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { BenchmarkController } from './benchmark.controller';
|
||||||
import { BenchmarkService } from './benchmark.service';
|
import { BenchmarkService } from './benchmark.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
controllers: [BenchmarkController],
|
||||||
exports: [BenchmarkService],
|
exports: [BenchmarkService],
|
||||||
imports: [
|
imports: [
|
||||||
|
ConfigurationModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
MarketDataModule,
|
MarketDataModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
PropertyModule,
|
PropertyModule,
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
|
SymbolModule,
|
||||||
SymbolProfileModule
|
SymbolProfileModule
|
||||||
],
|
],
|
||||||
providers: [BenchmarkService]
|
providers: [BenchmarkService]
|
@ -4,7 +4,15 @@ describe('BenchmarkService', () => {
|
|||||||
let benchmarkService: BenchmarkService;
|
let benchmarkService: BenchmarkService;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
benchmarkService = new BenchmarkService(null, null, null, null, null, null);
|
benchmarkService = new BenchmarkService(
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calculateChangeInPercentage', async () => {
|
it('calculateChangeInPercentage', async () => {
|
@ -1,31 +1,33 @@
|
|||||||
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 { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.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';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
import {
|
import {
|
||||||
CACHE_TTL_INFINITE,
|
MAX_CHART_ITEMS,
|
||||||
PROPERTY_BENCHMARKS
|
PROPERTY_BENCHMARKS
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { calculateBenchmarkTrend } from '@ghostfolio/common/helper';
|
|
||||||
import {
|
import {
|
||||||
AssetProfileIdentifier,
|
DATE_FORMAT,
|
||||||
|
calculateBenchmarkTrend
|
||||||
|
} from '@ghostfolio/common/helper';
|
||||||
|
import {
|
||||||
Benchmark,
|
Benchmark,
|
||||||
|
BenchmarkMarketDataDetails,
|
||||||
BenchmarkProperty,
|
BenchmarkProperty,
|
||||||
BenchmarkResponse
|
BenchmarkResponse,
|
||||||
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { BenchmarkTrend } from '@ghostfolio/common/types';
|
import { BenchmarkTrend } from '@ghostfolio/common/types';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { SymbolProfile } from '@prisma/client';
|
import { SymbolProfile } from '@prisma/client';
|
||||||
import { Big } from 'big.js';
|
import Big from 'big.js';
|
||||||
import { addHours, isAfter, subDays } from 'date-fns';
|
import { format, subDays } from 'date-fns';
|
||||||
import { uniqBy } from 'lodash';
|
import { uniqBy } from 'lodash';
|
||||||
import ms from 'ms';
|
import ms from 'ms';
|
||||||
|
|
||||||
import { BenchmarkValue } from './interfaces/benchmark-value.interface';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BenchmarkService {
|
export class BenchmarkService {
|
||||||
private readonly CACHE_KEY_BENCHMARKS = 'BENCHMARKS';
|
private readonly CACHE_KEY_BENCHMARKS = 'BENCHMARKS';
|
||||||
@ -36,7 +38,8 @@ export class BenchmarkService {
|
|||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly propertyService: PropertyService,
|
private readonly propertyService: PropertyService,
|
||||||
private readonly redisCacheService: RedisCacheService,
|
private readonly redisCacheService: RedisCacheService,
|
||||||
private readonly symbolProfileService: SymbolProfileService
|
private readonly symbolProfileService: SymbolProfileService,
|
||||||
|
private readonly symbolService: SymbolService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public calculateChangeInPercentage(baseValue: number, currentValue: number) {
|
public calculateChangeInPercentage(baseValue: number, currentValue: number) {
|
||||||
@ -47,10 +50,7 @@ export class BenchmarkService {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getBenchmarkTrends({
|
public async getBenchmarkTrends({ dataSource, symbol }: UniqueAsset) {
|
||||||
dataSource,
|
|
||||||
symbol
|
|
||||||
}: AssetProfileIdentifier) {
|
|
||||||
const historicalData = await this.marketDataService.marketDataItems({
|
const historicalData = await this.marketDataService.marketDataItems({
|
||||||
orderBy: {
|
orderBy: {
|
||||||
date: 'desc'
|
date: 'desc'
|
||||||
@ -78,28 +78,92 @@ export class BenchmarkService {
|
|||||||
enableSharing = false,
|
enableSharing = false,
|
||||||
useCache = true
|
useCache = true
|
||||||
} = {}): Promise<BenchmarkResponse['benchmarks']> {
|
} = {}): Promise<BenchmarkResponse['benchmarks']> {
|
||||||
|
let benchmarks: BenchmarkResponse['benchmarks'];
|
||||||
|
|
||||||
if (useCache) {
|
if (useCache) {
|
||||||
try {
|
try {
|
||||||
const cachedBenchmarkValue = await this.redisCacheService.get(
|
benchmarks = JSON.parse(
|
||||||
this.CACHE_KEY_BENCHMARKS
|
await this.redisCacheService.get(this.CACHE_KEY_BENCHMARKS)
|
||||||
);
|
);
|
||||||
|
|
||||||
const { benchmarks, expiration }: BenchmarkValue =
|
if (benchmarks) {
|
||||||
JSON.parse(cachedBenchmarkValue);
|
return benchmarks;
|
||||||
|
|
||||||
Logger.debug('Fetched benchmarks from cache', 'BenchmarkService');
|
|
||||||
|
|
||||||
if (isAfter(new Date(), new Date(expiration))) {
|
|
||||||
this.calculateAndCacheBenchmarks({
|
|
||||||
enableSharing
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return benchmarks;
|
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.calculateAndCacheBenchmarks({ enableSharing });
|
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles({
|
||||||
|
enableSharing
|
||||||
|
});
|
||||||
|
|
||||||
|
const promisesAllTimeHighs: Promise<{ date: Date; marketPrice: number }>[] =
|
||||||
|
[];
|
||||||
|
const promisesBenchmarkTrends: Promise<{
|
||||||
|
trend50d: BenchmarkTrend;
|
||||||
|
trend200d: BenchmarkTrend;
|
||||||
|
}>[] = [];
|
||||||
|
|
||||||
|
const quotes = await this.dataProviderService.getQuotes({
|
||||||
|
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
||||||
|
return { dataSource, symbol };
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
|
||||||
|
promisesAllTimeHighs.push(
|
||||||
|
this.marketDataService.getMax({ dataSource, symbol })
|
||||||
|
);
|
||||||
|
promisesBenchmarkTrends.push(
|
||||||
|
this.getBenchmarkTrends({ dataSource, symbol })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [allTimeHighs, benchmarkTrends] = await Promise.all([
|
||||||
|
Promise.all(promisesAllTimeHighs),
|
||||||
|
Promise.all(promisesBenchmarkTrends)
|
||||||
|
]);
|
||||||
|
let storeInCache = true;
|
||||||
|
|
||||||
|
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
|
||||||
|
const { marketPrice } =
|
||||||
|
quotes[benchmarkAssetProfiles[index].symbol] ?? {};
|
||||||
|
|
||||||
|
let performancePercentFromAllTimeHigh = 0;
|
||||||
|
|
||||||
|
if (allTimeHigh?.marketPrice && marketPrice) {
|
||||||
|
performancePercentFromAllTimeHigh = this.calculateChangeInPercentage(
|
||||||
|
allTimeHigh.marketPrice,
|
||||||
|
marketPrice
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
storeInCache = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
marketCondition: this.getMarketCondition(
|
||||||
|
performancePercentFromAllTimeHigh
|
||||||
|
),
|
||||||
|
name: benchmarkAssetProfiles[index].name,
|
||||||
|
performances: {
|
||||||
|
allTimeHigh: {
|
||||||
|
date: allTimeHigh?.date,
|
||||||
|
performancePercent: performancePercentFromAllTimeHigh
|
||||||
|
}
|
||||||
|
},
|
||||||
|
trend50d: benchmarkTrends[index].trend50d,
|
||||||
|
trend200d: benchmarkTrends[index].trend200d
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (storeInCache) {
|
||||||
|
await this.redisCacheService.set(
|
||||||
|
this.CACHE_KEY_BENCHMARKS,
|
||||||
|
JSON.stringify(benchmarks),
|
||||||
|
ms('4 hours') / 1000
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return benchmarks;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getBenchmarkAssetProfiles({
|
public async getBenchmarkAssetProfiles({
|
||||||
@ -136,10 +200,76 @@ export class BenchmarkService {
|
|||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getMarketDataBySymbol({
|
||||||
|
dataSource,
|
||||||
|
startDate,
|
||||||
|
symbol
|
||||||
|
}: { startDate: Date } & UniqueAsset): Promise<BenchmarkMarketDataDetails> {
|
||||||
|
const [currentSymbolItem, marketDataItems] = await Promise.all([
|
||||||
|
this.symbolService.get({
|
||||||
|
dataGatheringItem: {
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
this.marketDataService.marketDataItems({
|
||||||
|
orderBy: {
|
||||||
|
date: 'asc'
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
date: {
|
||||||
|
gte: startDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
const step = Math.round(
|
||||||
|
marketDataItems.length / Math.min(marketDataItems.length, MAX_CHART_ITEMS)
|
||||||
|
);
|
||||||
|
|
||||||
|
const marketPriceAtStartDate = marketDataItems?.[0]?.marketPrice ?? 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) {
|
||||||
|
response.marketData.push({
|
||||||
|
date: format(new Date(), DATE_FORMAT),
|
||||||
|
value:
|
||||||
|
this.calculateChangeInPercentage(
|
||||||
|
marketPriceAtStartDate,
|
||||||
|
currentSymbolItem.marketPrice
|
||||||
|
) * 100
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
public async addBenchmark({
|
public async addBenchmark({
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
}: AssetProfileIdentifier): Promise<Partial<SymbolProfile>> {
|
}: UniqueAsset): Promise<Partial<SymbolProfile>> {
|
||||||
const assetProfile = await this.prismaService.symbolProfile.findFirst({
|
const assetProfile = await this.prismaService.symbolProfile.findFirst({
|
||||||
where: {
|
where: {
|
||||||
dataSource,
|
dataSource,
|
||||||
@ -176,7 +306,7 @@ export class BenchmarkService {
|
|||||||
public async deleteBenchmark({
|
public async deleteBenchmark({
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
}: AssetProfileIdentifier): Promise<Partial<SymbolProfile>> {
|
}: UniqueAsset): Promise<Partial<SymbolProfile>> {
|
||||||
const assetProfile = await this.prismaService.symbolProfile.findFirst({
|
const assetProfile = await this.prismaService.symbolProfile.findFirst({
|
||||||
where: {
|
where: {
|
||||||
dataSource,
|
dataSource,
|
||||||
@ -210,101 +340,10 @@ export class BenchmarkService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async calculateAndCacheBenchmarks({
|
|
||||||
enableSharing = false
|
|
||||||
}): Promise<BenchmarkResponse['benchmarks']> {
|
|
||||||
Logger.debug('Calculate benchmarks', 'BenchmarkService');
|
|
||||||
|
|
||||||
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles({
|
|
||||||
enableSharing
|
|
||||||
});
|
|
||||||
|
|
||||||
const promisesAllTimeHighs: Promise<{ date: Date; marketPrice: number }>[] =
|
|
||||||
[];
|
|
||||||
const promisesBenchmarkTrends: Promise<{
|
|
||||||
trend50d: BenchmarkTrend;
|
|
||||||
trend200d: BenchmarkTrend;
|
|
||||||
}>[] = [];
|
|
||||||
|
|
||||||
const quotes = await this.dataProviderService.getQuotes({
|
|
||||||
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
|
||||||
return { dataSource, symbol };
|
|
||||||
}),
|
|
||||||
requestTimeout: ms('30 seconds'),
|
|
||||||
useCache: false
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
|
|
||||||
promisesAllTimeHighs.push(
|
|
||||||
this.marketDataService.getMax({ dataSource, symbol })
|
|
||||||
);
|
|
||||||
promisesBenchmarkTrends.push(
|
|
||||||
this.getBenchmarkTrends({ dataSource, symbol })
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [allTimeHighs, benchmarkTrends] = await Promise.all([
|
|
||||||
Promise.all(promisesAllTimeHighs),
|
|
||||||
Promise.all(promisesBenchmarkTrends)
|
|
||||||
]);
|
|
||||||
let storeInCache = true;
|
|
||||||
|
|
||||||
const benchmarks = allTimeHighs.map((allTimeHigh, index) => {
|
|
||||||
const { marketPrice } =
|
|
||||||
quotes[benchmarkAssetProfiles[index].symbol] ?? {};
|
|
||||||
|
|
||||||
let performancePercentFromAllTimeHigh = 0;
|
|
||||||
|
|
||||||
if (allTimeHigh?.marketPrice && marketPrice) {
|
|
||||||
performancePercentFromAllTimeHigh = this.calculateChangeInPercentage(
|
|
||||||
allTimeHigh.marketPrice,
|
|
||||||
marketPrice
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
storeInCache = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
dataSource: benchmarkAssetProfiles[index].dataSource,
|
|
||||||
marketCondition: this.getMarketCondition(
|
|
||||||
performancePercentFromAllTimeHigh
|
|
||||||
),
|
|
||||||
name: benchmarkAssetProfiles[index].name,
|
|
||||||
performances: {
|
|
||||||
allTimeHigh: {
|
|
||||||
date: allTimeHigh?.date,
|
|
||||||
performancePercent:
|
|
||||||
performancePercentFromAllTimeHigh >= 0
|
|
||||||
? 0
|
|
||||||
: performancePercentFromAllTimeHigh
|
|
||||||
}
|
|
||||||
},
|
|
||||||
symbol: benchmarkAssetProfiles[index].symbol,
|
|
||||||
trend50d: benchmarkTrends[index].trend50d,
|
|
||||||
trend200d: benchmarkTrends[index].trend200d
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!enableSharing && storeInCache) {
|
|
||||||
const expiration = addHours(new Date(), 2);
|
|
||||||
|
|
||||||
await this.redisCacheService.set(
|
|
||||||
this.CACHE_KEY_BENCHMARKS,
|
|
||||||
JSON.stringify({
|
|
||||||
benchmarks,
|
|
||||||
expiration: expiration.getTime()
|
|
||||||
} as BenchmarkValue),
|
|
||||||
CACHE_TTL_INFINITE
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return benchmarks;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getMarketCondition(
|
private getMarketCondition(
|
||||||
aPerformanceInPercent: number
|
aPerformanceInPercent: number
|
||||||
): Benchmark['marketCondition'] {
|
): Benchmark['marketCondition'] {
|
||||||
if (aPerformanceInPercent >= 0) {
|
if (aPerformanceInPercent === 0) {
|
||||||
return 'ALL_TIME_HIGH';
|
return 'ALL_TIME_HIGH';
|
||||||
} else if (aPerformanceInPercent <= -0.2) {
|
} else if (aPerformanceInPercent <= -0.2) {
|
||||||
return 'BEAR_MARKET';
|
return 'BEAR_MARKET';
|
38
apps/api/src/app/cache/cache.controller.ts
vendored
38
apps/api/src/app/cache/cache.controller.ts
vendored
@ -1,19 +1,39 @@
|
|||||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import { permissions } from '@ghostfolio/common/permissions';
|
import {
|
||||||
|
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(private readonly redisCacheService: RedisCacheService) {}
|
public constructor(
|
||||||
|
private readonly redisCacheService: RedisCacheService,
|
||||||
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
|
) {}
|
||||||
|
|
||||||
@HasPermission(permissions.accessAdminControl)
|
|
||||||
@Post('flush')
|
@Post('flush')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async flushCache(): Promise<void> {
|
public async flushCache(): Promise<void> {
|
||||||
await this.redisCacheService.reset();
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.redisCacheService.reset();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
17
apps/api/src/app/cache/cache.module.ts
vendored
17
apps/api/src/app/cache/cache.module.ts
vendored
@ -1,11 +1,24 @@
|
|||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
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 { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { CacheController } from './cache.controller';
|
import { CacheController } from './cache.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [CacheController],
|
controllers: [CacheController],
|
||||||
imports: [RedisCacheModule]
|
imports: [
|
||||||
|
ConfigurationModule,
|
||||||
|
DataGatheringModule,
|
||||||
|
DataProviderModule,
|
||||||
|
ExchangeRateDataModule,
|
||||||
|
PrismaModule,
|
||||||
|
RedisCacheModule,
|
||||||
|
SymbolProfileModule
|
||||||
|
]
|
||||||
})
|
})
|
||||||
export class CacheModule {}
|
export class CacheModule {}
|
||||||
|
@ -1,39 +0,0 @@
|
|||||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
|
||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
|
||||||
import {
|
|
||||||
DEFAULT_CURRENCY,
|
|
||||||
DEFAULT_LANGUAGE_CODE
|
|
||||||
} from '@ghostfolio/common/config';
|
|
||||||
import { AiPromptResponse } from '@ghostfolio/common/interfaces';
|
|
||||||
import { permissions } from '@ghostfolio/common/permissions';
|
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
|
||||||
|
|
||||||
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
|
|
||||||
import { REQUEST } from '@nestjs/core';
|
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
|
||||||
|
|
||||||
import { AiService } from './ai.service';
|
|
||||||
|
|
||||||
@Controller('ai')
|
|
||||||
export class AiController {
|
|
||||||
public constructor(
|
|
||||||
private readonly aiService: AiService,
|
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Get('prompt')
|
|
||||||
@HasPermission(permissions.readAiPrompt)
|
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
|
||||||
public async getPrompt(): Promise<AiPromptResponse> {
|
|
||||||
const prompt = await this.aiService.getPrompt({
|
|
||||||
impersonationId: undefined,
|
|
||||||
languageCode:
|
|
||||||
this.request.user.Settings.settings.language ?? DEFAULT_LANGUAGE_CODE,
|
|
||||||
userCurrency:
|
|
||||||
this.request.user.Settings.settings.baseCurrency ?? DEFAULT_CURRENCY,
|
|
||||||
userId: this.request.user.id
|
|
||||||
});
|
|
||||||
|
|
||||||
return { prompt };
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,51 +0,0 @@
|
|||||||
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
|
||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
|
||||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
|
||||||
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
|
||||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
|
||||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
|
||||||
import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service';
|
|
||||||
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 { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
|
||||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
|
||||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
|
||||||
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
|
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
|
||||||
|
|
||||||
import { AiController } from './ai.controller';
|
|
||||||
import { AiService } from './ai.service';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
controllers: [AiController],
|
|
||||||
imports: [
|
|
||||||
ConfigurationModule,
|
|
||||||
DataProviderModule,
|
|
||||||
ExchangeRateDataModule,
|
|
||||||
ImpersonationModule,
|
|
||||||
MarketDataModule,
|
|
||||||
OrderModule,
|
|
||||||
PortfolioSnapshotQueueModule,
|
|
||||||
PrismaModule,
|
|
||||||
RedisCacheModule,
|
|
||||||
SymbolProfileModule,
|
|
||||||
UserModule
|
|
||||||
],
|
|
||||||
providers: [
|
|
||||||
AccountBalanceService,
|
|
||||||
AccountService,
|
|
||||||
AiService,
|
|
||||||
CurrentRateService,
|
|
||||||
MarketDataService,
|
|
||||||
PortfolioCalculatorFactory,
|
|
||||||
PortfolioService,
|
|
||||||
RulesService
|
|
||||||
]
|
|
||||||
})
|
|
||||||
export class AiModule {}
|
|
@ -1,60 +0,0 @@
|
|||||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class AiService {
|
|
||||||
public constructor(private readonly portfolioService: PortfolioService) {}
|
|
||||||
|
|
||||||
public async getPrompt({
|
|
||||||
impersonationId,
|
|
||||||
languageCode,
|
|
||||||
userCurrency,
|
|
||||||
userId
|
|
||||||
}: {
|
|
||||||
impersonationId: string;
|
|
||||||
languageCode: string;
|
|
||||||
userCurrency: string;
|
|
||||||
userId: string;
|
|
||||||
}) {
|
|
||||||
const { holdings } = await this.portfolioService.getDetails({
|
|
||||||
impersonationId,
|
|
||||||
userId
|
|
||||||
});
|
|
||||||
|
|
||||||
const holdingsTable = [
|
|
||||||
'| Name | Symbol | Currency | Asset Class | Asset Sub Class | Allocation in Percentage |',
|
|
||||||
'| --- | --- | --- | --- | --- | --- |',
|
|
||||||
...Object.values(holdings)
|
|
||||||
.sort((a, b) => {
|
|
||||||
return b.allocationInPercentage - a.allocationInPercentage;
|
|
||||||
})
|
|
||||||
.map(
|
|
||||||
({
|
|
||||||
allocationInPercentage,
|
|
||||||
assetClass,
|
|
||||||
assetSubClass,
|
|
||||||
currency,
|
|
||||||
name,
|
|
||||||
symbol
|
|
||||||
}) => {
|
|
||||||
return `| ${name} | ${symbol} | ${currency} | ${assetClass} | ${assetSubClass} | ${(allocationInPercentage * 100).toFixed(3)}% |`;
|
|
||||||
}
|
|
||||||
)
|
|
||||||
];
|
|
||||||
|
|
||||||
return [
|
|
||||||
`You are a neutral financial assistant. Please analyze the following investment portfolio (base currency being ${userCurrency}) in simple words.`,
|
|
||||||
...holdingsTable,
|
|
||||||
'Structure your answer with these sections:',
|
|
||||||
'Overview: Briefly summarize the portfolio’s composition and allocation rationale.',
|
|
||||||
'Risk Assessment: Identify potential risks, including market volatility, concentration, and sectoral imbalances.',
|
|
||||||
'Advantages: Highlight strengths, focusing on growth potential, diversification, or other benefits.',
|
|
||||||
'Disadvantages: Point out weaknesses, such as overexposure or lack of defensive assets.',
|
|
||||||
'Target Group: Discuss who this portfolio might suit (e.g., risk tolerance, investment goals, life stages, and experience levels).',
|
|
||||||
'Optimization Ideas: Offer ideas to complement the portfolio, ensuring they are constructive and neutral in tone.',
|
|
||||||
'Conclusion: Provide a concise summary highlighting key insights.',
|
|
||||||
`Provide your answer in the following language: ${languageCode}.`
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,25 +0,0 @@
|
|||||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
|
||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
|
||||||
import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service';
|
|
||||||
import { ApiKeyResponse } from '@ghostfolio/common/interfaces';
|
|
||||||
import { permissions } from '@ghostfolio/common/permissions';
|
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
|
||||||
|
|
||||||
import { Controller, Inject, Post, UseGuards } from '@nestjs/common';
|
|
||||||
import { REQUEST } from '@nestjs/core';
|
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
|
||||||
|
|
||||||
@Controller('api-keys')
|
|
||||||
export class ApiKeysController {
|
|
||||||
public constructor(
|
|
||||||
private readonly apiKeyService: ApiKeyService,
|
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@HasPermission(permissions.createApiKey)
|
|
||||||
@Post()
|
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
|
||||||
public async createApiKey(): Promise<ApiKeyResponse> {
|
|
||||||
return this.apiKeyService.create({ userId: this.request.user.id });
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,11 +0,0 @@
|
|||||||
import { ApiKeyModule } from '@ghostfolio/api/services/api-key/api-key.module';
|
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
|
||||||
|
|
||||||
import { ApiKeysController } from './api-keys.controller';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
controllers: [ApiKeysController],
|
|
||||||
imports: [ApiKeyModule]
|
|
||||||
})
|
|
||||||
export class ApiKeysModule {}
|
|
@ -1,156 +0,0 @@
|
|||||||
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/transform-data-source-in-request.interceptor';
|
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
|
|
||||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
|
||||||
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
|
|
||||||
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
|
|
||||||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
|
||||||
import type {
|
|
||||||
AssetProfileIdentifier,
|
|
||||||
BenchmarkMarketDataDetails,
|
|
||||||
BenchmarkResponse
|
|
||||||
} from '@ghostfolio/common/interfaces';
|
|
||||||
import { permissions } from '@ghostfolio/common/permissions';
|
|
||||||
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
|
|
||||||
|
|
||||||
import {
|
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
Delete,
|
|
||||||
Get,
|
|
||||||
Headers,
|
|
||||||
HttpException,
|
|
||||||
Inject,
|
|
||||||
Param,
|
|
||||||
Post,
|
|
||||||
Query,
|
|
||||||
UseGuards,
|
|
||||||
UseInterceptors
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { REQUEST } from '@nestjs/core';
|
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
|
||||||
import { DataSource } from '@prisma/client';
|
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
|
||||||
|
|
||||||
import { BenchmarksService } from './benchmarks.service';
|
|
||||||
|
|
||||||
@Controller('benchmarks')
|
|
||||||
export class BenchmarksController {
|
|
||||||
public constructor(
|
|
||||||
private readonly apiService: ApiService,
|
|
||||||
private readonly benchmarkService: BenchmarkService,
|
|
||||||
private readonly benchmarksService: BenchmarksService,
|
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@HasPermission(permissions.accessAdminControl)
|
|
||||||
@Post()
|
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
|
||||||
public async addBenchmark(
|
|
||||||
@Body() { dataSource, symbol }: AssetProfileIdentifier
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const benchmark = await this.benchmarkService.addBenchmark({
|
|
||||||
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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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 getBenchmarkMarketDataForUser(
|
|
||||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
|
||||||
@Param('dataSource') dataSource: DataSource,
|
|
||||||
@Param('startDateString') startDateString: string,
|
|
||||||
@Param('symbol') symbol: string,
|
|
||||||
@Query('range') dateRange: DateRange = 'max',
|
|
||||||
@Query('accounts') filterByAccounts?: string,
|
|
||||||
@Query('assetClasses') filterByAssetClasses?: string,
|
|
||||||
@Query('dataSource') filterByDataSource?: string,
|
|
||||||
@Query('symbol') filterBySymbol?: string,
|
|
||||||
@Query('tags') filterByTags?: string,
|
|
||||||
@Query('withExcludedAccounts') withExcludedAccountsParam = 'false'
|
|
||||||
): Promise<BenchmarkMarketDataDetails> {
|
|
||||||
const { endDate, startDate } = getIntervalFromDateRange(
|
|
||||||
dateRange,
|
|
||||||
new Date(startDateString)
|
|
||||||
);
|
|
||||||
|
|
||||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
|
||||||
filterByAccounts,
|
|
||||||
filterByAssetClasses,
|
|
||||||
filterByDataSource,
|
|
||||||
filterBySymbol,
|
|
||||||
filterByTags
|
|
||||||
});
|
|
||||||
|
|
||||||
const withExcludedAccounts = withExcludedAccountsParam === 'true';
|
|
||||||
|
|
||||||
return this.benchmarksService.getMarketDataForUser({
|
|
||||||
dataSource,
|
|
||||||
dateRange,
|
|
||||||
endDate,
|
|
||||||
filters,
|
|
||||||
impersonationId,
|
|
||||||
startDate,
|
|
||||||
symbol,
|
|
||||||
withExcludedAccounts,
|
|
||||||
user: this.request.user
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,63 +0,0 @@
|
|||||||
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
|
||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
|
||||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
|
||||||
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
|
||||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
|
||||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
|
||||||
import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service';
|
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
|
||||||
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
|
|
||||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
|
||||||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
|
||||||
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
|
|
||||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
|
||||||
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
|
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.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 { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
|
||||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
|
||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
|
||||||
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
|
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
|
||||||
|
|
||||||
import { BenchmarksController } from './benchmarks.controller';
|
|
||||||
import { BenchmarksService } from './benchmarks.service';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
controllers: [BenchmarksController],
|
|
||||||
imports: [
|
|
||||||
ApiModule,
|
|
||||||
ConfigurationModule,
|
|
||||||
DataProviderModule,
|
|
||||||
ExchangeRateDataModule,
|
|
||||||
ImpersonationModule,
|
|
||||||
MarketDataModule,
|
|
||||||
OrderModule,
|
|
||||||
PortfolioSnapshotQueueModule,
|
|
||||||
PrismaModule,
|
|
||||||
PropertyModule,
|
|
||||||
RedisCacheModule,
|
|
||||||
SymbolModule,
|
|
||||||
SymbolProfileModule,
|
|
||||||
TransformDataSourceInRequestModule,
|
|
||||||
TransformDataSourceInResponseModule,
|
|
||||||
UserModule
|
|
||||||
],
|
|
||||||
providers: [
|
|
||||||
AccountBalanceService,
|
|
||||||
AccountService,
|
|
||||||
BenchmarkService,
|
|
||||||
BenchmarksService,
|
|
||||||
CurrentRateService,
|
|
||||||
MarketDataService,
|
|
||||||
PortfolioCalculatorFactory,
|
|
||||||
PortfolioService,
|
|
||||||
RulesService
|
|
||||||
]
|
|
||||||
})
|
|
||||||
export class BenchmarksModule {}
|
|
@ -1,163 +0,0 @@
|
|||||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
|
||||||
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
|
|
||||||
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.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 { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
|
||||||
import {
|
|
||||||
AssetProfileIdentifier,
|
|
||||||
BenchmarkMarketDataDetails,
|
|
||||||
Filter
|
|
||||||
} from '@ghostfolio/common/interfaces';
|
|
||||||
import { DateRange, UserWithSettings } from '@ghostfolio/common/types';
|
|
||||||
|
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { format, isSameDay } from 'date-fns';
|
|
||||||
import { isNumber } from 'lodash';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class BenchmarksService {
|
|
||||||
public constructor(
|
|
||||||
private readonly benchmarkService: BenchmarkService,
|
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
|
||||||
private readonly marketDataService: MarketDataService,
|
|
||||||
private readonly portfolioService: PortfolioService,
|
|
||||||
private readonly symbolService: SymbolService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public async getMarketDataForUser({
|
|
||||||
dataSource,
|
|
||||||
dateRange,
|
|
||||||
endDate = new Date(),
|
|
||||||
filters,
|
|
||||||
impersonationId,
|
|
||||||
startDate,
|
|
||||||
symbol,
|
|
||||||
user,
|
|
||||||
withExcludedAccounts
|
|
||||||
}: {
|
|
||||||
dateRange: DateRange;
|
|
||||||
endDate?: Date;
|
|
||||||
filters?: Filter[];
|
|
||||||
impersonationId: string;
|
|
||||||
startDate: Date;
|
|
||||||
user: UserWithSettings;
|
|
||||||
withExcludedAccounts?: boolean;
|
|
||||||
} & AssetProfileIdentifier): Promise<BenchmarkMarketDataDetails> {
|
|
||||||
const marketData: { date: string; value: number }[] = [];
|
|
||||||
const userCurrency = user.Settings.settings.baseCurrency;
|
|
||||||
const userId = user.id;
|
|
||||||
|
|
||||||
const { chart } = await this.portfolioService.getPerformance({
|
|
||||||
dateRange,
|
|
||||||
filters,
|
|
||||||
impersonationId,
|
|
||||||
userId,
|
|
||||||
withExcludedAccounts
|
|
||||||
});
|
|
||||||
|
|
||||||
const [currentSymbolItem, marketDataItems] = await Promise.all([
|
|
||||||
this.symbolService.get({
|
|
||||||
dataGatheringItem: {
|
|
||||||
dataSource,
|
|
||||||
symbol
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
this.marketDataService.marketDataItems({
|
|
||||||
orderBy: {
|
|
||||||
date: 'asc'
|
|
||||||
},
|
|
||||||
where: {
|
|
||||||
dataSource,
|
|
||||||
symbol,
|
|
||||||
date: {
|
|
||||||
in: chart.map(({ date }) => {
|
|
||||||
return resetHours(parseDate(date));
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
]);
|
|
||||||
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const marketDataItem of marketDataItems) {
|
|
||||||
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.benchmarkService.calculateChangeInPercentage(
|
|
||||||
marketPriceAtStartDate,
|
|
||||||
marketDataItem.marketPrice * exchangeRateFactor
|
|
||||||
) * 100
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const includesEndDate = isSameDay(
|
|
||||||
parseDate(marketData.at(-1).date),
|
|
||||||
endDate
|
|
||||||
);
|
|
||||||
|
|
||||||
if (currentSymbolItem?.marketPrice && !includesEndDate) {
|
|
||||||
const exchangeRate =
|
|
||||||
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
|
|
||||||
format(endDate, DATE_FORMAT)
|
|
||||||
];
|
|
||||||
|
|
||||||
const exchangeRateFactor =
|
|
||||||
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate)
|
|
||||||
? exchangeRate / exchangeRateAtStartDate
|
|
||||||
: 1;
|
|
||||||
|
|
||||||
marketData.push({
|
|
||||||
date: format(endDate, DATE_FORMAT),
|
|
||||||
value:
|
|
||||||
this.benchmarkService.calculateChangeInPercentage(
|
|
||||||
marketPriceAtStartDate,
|
|
||||||
currentSymbolItem.marketPrice * exchangeRateFactor
|
|
||||||
) * 100
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
marketData
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,15 +0,0 @@
|
|||||||
import { Granularity } from '@ghostfolio/common/types';
|
|
||||||
|
|
||||||
import { IsIn, IsISO8601, IsOptional } from 'class-validator';
|
|
||||||
|
|
||||||
export class GetDividendsDto {
|
|
||||||
@IsISO8601()
|
|
||||||
from: string;
|
|
||||||
|
|
||||||
@IsIn(['day', 'month'] as Granularity[])
|
|
||||||
@IsOptional()
|
|
||||||
granularity: Granularity;
|
|
||||||
|
|
||||||
@IsISO8601()
|
|
||||||
to: string;
|
|
||||||
}
|
|
@ -1,15 +0,0 @@
|
|||||||
import { Granularity } from '@ghostfolio/common/types';
|
|
||||||
|
|
||||||
import { IsIn, IsISO8601, IsOptional } from 'class-validator';
|
|
||||||
|
|
||||||
export class GetHistoricalDto {
|
|
||||||
@IsISO8601()
|
|
||||||
from: string;
|
|
||||||
|
|
||||||
@IsIn(['day', 'month'] as Granularity[])
|
|
||||||
@IsOptional()
|
|
||||||
granularity: Granularity;
|
|
||||||
|
|
||||||
@IsISO8601()
|
|
||||||
to: string;
|
|
||||||
}
|
|
@ -1,10 +0,0 @@
|
|||||||
import { Transform } from 'class-transformer';
|
|
||||||
import { IsString } from 'class-validator';
|
|
||||||
|
|
||||||
export class GetQuotesDto {
|
|
||||||
@IsString({ each: true })
|
|
||||||
@Transform(({ value }) =>
|
|
||||||
typeof value === 'string' ? value.split(',') : value
|
|
||||||
)
|
|
||||||
symbols: string[];
|
|
||||||
}
|
|
@ -1,375 +0,0 @@
|
|||||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
|
||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
|
||||||
import { parseDate } from '@ghostfolio/common/helper';
|
|
||||||
import {
|
|
||||||
DataProviderGhostfolioStatusResponse,
|
|
||||||
DividendsResponse,
|
|
||||||
HistoricalResponse,
|
|
||||||
LookupResponse,
|
|
||||||
QuotesResponse
|
|
||||||
} from '@ghostfolio/common/interfaces';
|
|
||||||
import { permissions } from '@ghostfolio/common/permissions';
|
|
||||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
|
||||||
|
|
||||||
import {
|
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
HttpException,
|
|
||||||
Inject,
|
|
||||||
Param,
|
|
||||||
Query,
|
|
||||||
UseGuards,
|
|
||||||
Version
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { REQUEST } from '@nestjs/core';
|
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
|
||||||
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
|
|
||||||
|
|
||||||
import { GetDividendsDto } from './get-dividends.dto';
|
|
||||||
import { GetHistoricalDto } from './get-historical.dto';
|
|
||||||
import { GetQuotesDto } from './get-quotes.dto';
|
|
||||||
import { GhostfolioService } from './ghostfolio.service';
|
|
||||||
|
|
||||||
@Controller('data-providers/ghostfolio')
|
|
||||||
export class GhostfolioController {
|
|
||||||
public constructor(
|
|
||||||
private readonly ghostfolioService: GhostfolioService,
|
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated
|
|
||||||
*/
|
|
||||||
@Get('dividends/:symbol')
|
|
||||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
|
||||||
public async getDividendsV1(
|
|
||||||
@Param('symbol') symbol: string,
|
|
||||||
@Query() query: GetDividendsDto
|
|
||||||
): Promise<DividendsResponse> {
|
|
||||||
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
|
|
||||||
|
|
||||||
if (
|
|
||||||
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
|
||||||
StatusCodes.TOO_MANY_REQUESTS
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const dividends = await this.ghostfolioService.getDividends({
|
|
||||||
symbol,
|
|
||||||
from: parseDate(query.from),
|
|
||||||
granularity: query.granularity,
|
|
||||||
to: parseDate(query.to)
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.ghostfolioService.incrementDailyRequests({
|
|
||||||
userId: this.request.user.id
|
|
||||||
});
|
|
||||||
|
|
||||||
return dividends;
|
|
||||||
} catch {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
|
||||||
StatusCodes.INTERNAL_SERVER_ERROR
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('dividends/:symbol')
|
|
||||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
|
||||||
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
|
|
||||||
@Version('2')
|
|
||||||
public async getDividends(
|
|
||||||
@Param('symbol') symbol: string,
|
|
||||||
@Query() query: GetDividendsDto
|
|
||||||
): Promise<DividendsResponse> {
|
|
||||||
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
|
|
||||||
|
|
||||||
if (
|
|
||||||
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
|
||||||
StatusCodes.TOO_MANY_REQUESTS
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const dividends = await this.ghostfolioService.getDividends({
|
|
||||||
symbol,
|
|
||||||
from: parseDate(query.from),
|
|
||||||
granularity: query.granularity,
|
|
||||||
to: parseDate(query.to)
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.ghostfolioService.incrementDailyRequests({
|
|
||||||
userId: this.request.user.id
|
|
||||||
});
|
|
||||||
|
|
||||||
return dividends;
|
|
||||||
} catch {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
|
||||||
StatusCodes.INTERNAL_SERVER_ERROR
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated
|
|
||||||
*/
|
|
||||||
@Get('historical/:symbol')
|
|
||||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
|
||||||
public async getHistoricalV1(
|
|
||||||
@Param('symbol') symbol: string,
|
|
||||||
@Query() query: GetHistoricalDto
|
|
||||||
): Promise<HistoricalResponse> {
|
|
||||||
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
|
|
||||||
|
|
||||||
if (
|
|
||||||
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
|
||||||
StatusCodes.TOO_MANY_REQUESTS
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const historicalData = await this.ghostfolioService.getHistorical({
|
|
||||||
symbol,
|
|
||||||
from: parseDate(query.from),
|
|
||||||
granularity: query.granularity,
|
|
||||||
to: parseDate(query.to)
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.ghostfolioService.incrementDailyRequests({
|
|
||||||
userId: this.request.user.id
|
|
||||||
});
|
|
||||||
|
|
||||||
return historicalData;
|
|
||||||
} catch {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
|
||||||
StatusCodes.INTERNAL_SERVER_ERROR
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('historical/:symbol')
|
|
||||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
|
||||||
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
|
|
||||||
@Version('2')
|
|
||||||
public async getHistorical(
|
|
||||||
@Param('symbol') symbol: string,
|
|
||||||
@Query() query: GetHistoricalDto
|
|
||||||
): Promise<HistoricalResponse> {
|
|
||||||
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
|
|
||||||
|
|
||||||
if (
|
|
||||||
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
|
||||||
StatusCodes.TOO_MANY_REQUESTS
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const historicalData = await this.ghostfolioService.getHistorical({
|
|
||||||
symbol,
|
|
||||||
from: parseDate(query.from),
|
|
||||||
granularity: query.granularity,
|
|
||||||
to: parseDate(query.to)
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.ghostfolioService.incrementDailyRequests({
|
|
||||||
userId: this.request.user.id
|
|
||||||
});
|
|
||||||
|
|
||||||
return historicalData;
|
|
||||||
} catch {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
|
||||||
StatusCodes.INTERNAL_SERVER_ERROR
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated
|
|
||||||
*/
|
|
||||||
@Get('lookup')
|
|
||||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
|
||||||
public async lookupSymbolV1(
|
|
||||||
@Query('includeIndices') includeIndicesParam = 'false',
|
|
||||||
@Query('query') query = ''
|
|
||||||
): Promise<LookupResponse> {
|
|
||||||
const includeIndices = includeIndicesParam === 'true';
|
|
||||||
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
|
|
||||||
|
|
||||||
if (
|
|
||||||
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
|
||||||
StatusCodes.TOO_MANY_REQUESTS
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await this.ghostfolioService.lookup({
|
|
||||||
includeIndices,
|
|
||||||
query: query.toLowerCase()
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.ghostfolioService.incrementDailyRequests({
|
|
||||||
userId: this.request.user.id
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
|
||||||
StatusCodes.INTERNAL_SERVER_ERROR
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('lookup')
|
|
||||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
|
||||||
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
|
|
||||||
@Version('2')
|
|
||||||
public async lookupSymbol(
|
|
||||||
@Query('includeIndices') includeIndicesParam = 'false',
|
|
||||||
@Query('query') query = ''
|
|
||||||
): Promise<LookupResponse> {
|
|
||||||
const includeIndices = includeIndicesParam === 'true';
|
|
||||||
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
|
|
||||||
|
|
||||||
if (
|
|
||||||
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
|
||||||
StatusCodes.TOO_MANY_REQUESTS
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await this.ghostfolioService.lookup({
|
|
||||||
includeIndices,
|
|
||||||
query: query.toLowerCase()
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.ghostfolioService.incrementDailyRequests({
|
|
||||||
userId: this.request.user.id
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
|
||||||
StatusCodes.INTERNAL_SERVER_ERROR
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated
|
|
||||||
*/
|
|
||||||
@Get('quotes')
|
|
||||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
|
||||||
public async getQuotesV1(
|
|
||||||
@Query() query: GetQuotesDto
|
|
||||||
): Promise<QuotesResponse> {
|
|
||||||
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
|
|
||||||
|
|
||||||
if (
|
|
||||||
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
|
||||||
StatusCodes.TOO_MANY_REQUESTS
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const quotes = await this.ghostfolioService.getQuotes({
|
|
||||||
symbols: query.symbols
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.ghostfolioService.incrementDailyRequests({
|
|
||||||
userId: this.request.user.id
|
|
||||||
});
|
|
||||||
|
|
||||||
return quotes;
|
|
||||||
} catch {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
|
||||||
StatusCodes.INTERNAL_SERVER_ERROR
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('quotes')
|
|
||||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
|
||||||
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
|
|
||||||
@Version('2')
|
|
||||||
public async getQuotes(
|
|
||||||
@Query() query: GetQuotesDto
|
|
||||||
): Promise<QuotesResponse> {
|
|
||||||
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
|
|
||||||
|
|
||||||
if (
|
|
||||||
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
|
|
||||||
) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
|
||||||
StatusCodes.TOO_MANY_REQUESTS
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const quotes = await this.ghostfolioService.getQuotes({
|
|
||||||
symbols: query.symbols
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.ghostfolioService.incrementDailyRequests({
|
|
||||||
userId: this.request.user.id
|
|
||||||
});
|
|
||||||
|
|
||||||
return quotes;
|
|
||||||
} catch {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
|
||||||
StatusCodes.INTERNAL_SERVER_ERROR
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated
|
|
||||||
*/
|
|
||||||
@Get('status')
|
|
||||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
|
||||||
public async getStatusV1(): Promise<DataProviderGhostfolioStatusResponse> {
|
|
||||||
return this.ghostfolioService.getStatus({ user: this.request.user });
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('status')
|
|
||||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
|
||||||
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
|
|
||||||
@Version('2')
|
|
||||||
public async getStatus(): Promise<DataProviderGhostfolioStatusResponse> {
|
|
||||||
return this.ghostfolioService.getStatus({ user: this.request.user });
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,83 +0,0 @@
|
|||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
|
||||||
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
|
|
||||||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
|
||||||
import { CoinGeckoService } from '@ghostfolio/api/services/data-provider/coingecko/coingecko.service';
|
|
||||||
import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service';
|
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
|
||||||
import { EodHistoricalDataService } from '@ghostfolio/api/services/data-provider/eod-historical-data/eod-historical-data.service';
|
|
||||||
import { FinancialModelingPrepService } from '@ghostfolio/api/services/data-provider/financial-modeling-prep/financial-modeling-prep.service';
|
|
||||||
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service';
|
|
||||||
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
|
|
||||||
import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service';
|
|
||||||
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
|
||||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
|
||||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
|
||||||
|
|
||||||
import { GhostfolioController } from './ghostfolio.controller';
|
|
||||||
import { GhostfolioService } from './ghostfolio.service';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
controllers: [GhostfolioController],
|
|
||||||
imports: [
|
|
||||||
CryptocurrencyModule,
|
|
||||||
DataProviderModule,
|
|
||||||
MarketDataModule,
|
|
||||||
PrismaModule,
|
|
||||||
PropertyModule,
|
|
||||||
RedisCacheModule,
|
|
||||||
SymbolProfileModule
|
|
||||||
],
|
|
||||||
providers: [
|
|
||||||
AlphaVantageService,
|
|
||||||
CoinGeckoService,
|
|
||||||
ConfigurationService,
|
|
||||||
DataProviderService,
|
|
||||||
EodHistoricalDataService,
|
|
||||||
FinancialModelingPrepService,
|
|
||||||
GhostfolioService,
|
|
||||||
GoogleSheetsService,
|
|
||||||
ManualService,
|
|
||||||
RapidApiService,
|
|
||||||
YahooFinanceService,
|
|
||||||
YahooFinanceDataEnhancerService,
|
|
||||||
{
|
|
||||||
inject: [
|
|
||||||
AlphaVantageService,
|
|
||||||
CoinGeckoService,
|
|
||||||
EodHistoricalDataService,
|
|
||||||
FinancialModelingPrepService,
|
|
||||||
GoogleSheetsService,
|
|
||||||
ManualService,
|
|
||||||
RapidApiService,
|
|
||||||
YahooFinanceService
|
|
||||||
],
|
|
||||||
provide: 'DataProviderInterfaces',
|
|
||||||
useFactory: (
|
|
||||||
alphaVantageService,
|
|
||||||
coinGeckoService,
|
|
||||||
eodHistoricalDataService,
|
|
||||||
financialModelingPrepService,
|
|
||||||
googleSheetsService,
|
|
||||||
manualService,
|
|
||||||
rapidApiService,
|
|
||||||
yahooFinanceService
|
|
||||||
) => [
|
|
||||||
alphaVantageService,
|
|
||||||
coinGeckoService,
|
|
||||||
eodHistoricalDataService,
|
|
||||||
financialModelingPrepService,
|
|
||||||
googleSheetsService,
|
|
||||||
manualService,
|
|
||||||
rapidApiService,
|
|
||||||
yahooFinanceService
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
})
|
|
||||||
export class GhostfolioModule {}
|
|
@ -1,303 +0,0 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
|
||||||
import {
|
|
||||||
GetDividendsParams,
|
|
||||||
GetHistoricalParams,
|
|
||||||
GetQuotesParams,
|
|
||||||
GetSearchParams
|
|
||||||
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
|
||||||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
|
||||||
import {
|
|
||||||
DEFAULT_CURRENCY,
|
|
||||||
DERIVED_CURRENCIES
|
|
||||||
} from '@ghostfolio/common/config';
|
|
||||||
import { PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS } from '@ghostfolio/common/config';
|
|
||||||
import {
|
|
||||||
DataProviderInfo,
|
|
||||||
DividendsResponse,
|
|
||||||
HistoricalResponse,
|
|
||||||
LookupItem,
|
|
||||||
LookupResponse,
|
|
||||||
QuotesResponse
|
|
||||||
} from '@ghostfolio/common/interfaces';
|
|
||||||
import { UserWithSettings } from '@ghostfolio/common/types';
|
|
||||||
|
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { DataSource } from '@prisma/client';
|
|
||||||
import { Big } from 'big.js';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class GhostfolioService {
|
|
||||||
public constructor(
|
|
||||||
private readonly configurationService: ConfigurationService,
|
|
||||||
private readonly dataProviderService: DataProviderService,
|
|
||||||
private readonly prismaService: PrismaService,
|
|
||||||
private readonly propertyService: PropertyService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public async getDividends({
|
|
||||||
from,
|
|
||||||
granularity,
|
|
||||||
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
|
|
||||||
symbol,
|
|
||||||
to
|
|
||||||
}: GetDividendsParams) {
|
|
||||||
const result: DividendsResponse = { dividends: {} };
|
|
||||||
|
|
||||||
try {
|
|
||||||
const promises: Promise<{
|
|
||||||
[date: string]: IDataProviderHistoricalResponse;
|
|
||||||
}>[] = [];
|
|
||||||
|
|
||||||
for (const dataProviderService of this.getDataProviderServices()) {
|
|
||||||
promises.push(
|
|
||||||
dataProviderService
|
|
||||||
.getDividends({
|
|
||||||
from,
|
|
||||||
granularity,
|
|
||||||
requestTimeout,
|
|
||||||
symbol,
|
|
||||||
to
|
|
||||||
})
|
|
||||||
.then((dividends) => {
|
|
||||||
result.dividends = dividends;
|
|
||||||
|
|
||||||
return dividends;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(promises);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
Logger.error(error, 'GhostfolioService');
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getHistorical({
|
|
||||||
from,
|
|
||||||
granularity,
|
|
||||||
requestTimeout,
|
|
||||||
to,
|
|
||||||
symbol
|
|
||||||
}: GetHistoricalParams) {
|
|
||||||
const result: HistoricalResponse = { historicalData: {} };
|
|
||||||
|
|
||||||
try {
|
|
||||||
const promises: Promise<{
|
|
||||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
|
||||||
}>[] = [];
|
|
||||||
|
|
||||||
for (const dataProviderService of this.getDataProviderServices()) {
|
|
||||||
promises.push(
|
|
||||||
dataProviderService
|
|
||||||
.getHistorical({
|
|
||||||
from,
|
|
||||||
granularity,
|
|
||||||
requestTimeout,
|
|
||||||
symbol,
|
|
||||||
to
|
|
||||||
})
|
|
||||||
.then((historicalData) => {
|
|
||||||
result.historicalData = historicalData[symbol];
|
|
||||||
|
|
||||||
return historicalData;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(promises);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
Logger.error(error, 'GhostfolioService');
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getMaxDailyRequests() {
|
|
||||||
return parseInt(
|
|
||||||
((await this.propertyService.getByKey(
|
|
||||||
PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS
|
|
||||||
)) as string) || '0',
|
|
||||||
10
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getQuotes({ requestTimeout, symbols }: GetQuotesParams) {
|
|
||||||
const results: QuotesResponse = { quotes: {} };
|
|
||||||
|
|
||||||
try {
|
|
||||||
const promises: Promise<any>[] = [];
|
|
||||||
|
|
||||||
for (const dataProvider of this.getDataProviderServices()) {
|
|
||||||
const maximumNumberOfSymbolsPerRequest =
|
|
||||||
dataProvider.getMaxNumberOfSymbolsPerRequest?.() ??
|
|
||||||
Number.MAX_SAFE_INTEGER;
|
|
||||||
|
|
||||||
for (
|
|
||||||
let i = 0;
|
|
||||||
i < symbols.length;
|
|
||||||
i += maximumNumberOfSymbolsPerRequest
|
|
||||||
) {
|
|
||||||
const symbolsChunk = symbols.slice(
|
|
||||||
i,
|
|
||||||
i + maximumNumberOfSymbolsPerRequest
|
|
||||||
);
|
|
||||||
|
|
||||||
const promise = Promise.resolve(
|
|
||||||
dataProvider.getQuotes({ requestTimeout, symbols: symbolsChunk })
|
|
||||||
);
|
|
||||||
|
|
||||||
promises.push(
|
|
||||||
promise.then(async (result) => {
|
|
||||||
for (const [symbol, dataProviderResponse] of Object.entries(
|
|
||||||
result
|
|
||||||
)) {
|
|
||||||
dataProviderResponse.dataSource = 'GHOSTFOLIO';
|
|
||||||
|
|
||||||
if (
|
|
||||||
[
|
|
||||||
...DERIVED_CURRENCIES.map(({ currency }) => {
|
|
||||||
return `${DEFAULT_CURRENCY}${currency}`;
|
|
||||||
}),
|
|
||||||
`${DEFAULT_CURRENCY}USX`
|
|
||||||
].includes(symbol)
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
results.quotes[symbol] = dataProviderResponse;
|
|
||||||
|
|
||||||
for (const {
|
|
||||||
currency,
|
|
||||||
factor,
|
|
||||||
rootCurrency
|
|
||||||
} of DERIVED_CURRENCIES) {
|
|
||||||
if (symbol === `${DEFAULT_CURRENCY}${rootCurrency}`) {
|
|
||||||
results.quotes[`${DEFAULT_CURRENCY}${currency}`] = {
|
|
||||||
...dataProviderResponse,
|
|
||||||
currency,
|
|
||||||
marketPrice: new Big(
|
|
||||||
result[`${DEFAULT_CURRENCY}${rootCurrency}`].marketPrice
|
|
||||||
)
|
|
||||||
.mul(factor)
|
|
||||||
.toNumber(),
|
|
||||||
marketState: 'open'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(promises);
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
} catch (error) {
|
|
||||||
Logger.error(error, 'GhostfolioService');
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getStatus({ user }: { user: UserWithSettings }) {
|
|
||||||
return {
|
|
||||||
dailyRequests: user.dataProviderGhostfolioDailyRequests,
|
|
||||||
dailyRequestsMax: await this.getMaxDailyRequests(),
|
|
||||||
subscription: user.subscription
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async incrementDailyRequests({ userId }: { userId: string }) {
|
|
||||||
await this.prismaService.analytics.update({
|
|
||||||
data: {
|
|
||||||
dataProviderGhostfolioDailyRequests: { increment: 1 }
|
|
||||||
},
|
|
||||||
where: { userId }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async lookup({
|
|
||||||
includeIndices = false,
|
|
||||||
query
|
|
||||||
}: GetSearchParams): Promise<LookupResponse> {
|
|
||||||
const results: LookupResponse = { items: [] };
|
|
||||||
|
|
||||||
if (!query) {
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
let lookupItems: LookupItem[] = [];
|
|
||||||
const promises: Promise<{ items: LookupItem[] }>[] = [];
|
|
||||||
|
|
||||||
if (query?.length < 2) {
|
|
||||||
return { items: lookupItems };
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const dataProviderService of this.getDataProviderServices()) {
|
|
||||||
promises.push(
|
|
||||||
dataProviderService.search({
|
|
||||||
includeIndices,
|
|
||||||
query
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const searchResults = await Promise.all(promises);
|
|
||||||
|
|
||||||
for (const { items } of searchResults) {
|
|
||||||
if (items?.length > 0) {
|
|
||||||
lookupItems = lookupItems.concat(items);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredItems = lookupItems
|
|
||||||
.filter(({ currency }) => {
|
|
||||||
// Only allow symbols with supported currency
|
|
||||||
return currency ? true : false;
|
|
||||||
})
|
|
||||||
.sort(({ name: name1 }, { name: name2 }) => {
|
|
||||||
return name1?.toLowerCase().localeCompare(name2?.toLowerCase());
|
|
||||||
})
|
|
||||||
.map((lookupItem) => {
|
|
||||||
lookupItem.dataProviderInfo = this.getDataProviderInfo();
|
|
||||||
lookupItem.dataSource = 'GHOSTFOLIO';
|
|
||||||
|
|
||||||
return lookupItem;
|
|
||||||
});
|
|
||||||
|
|
||||||
results.items = filteredItems;
|
|
||||||
return results;
|
|
||||||
} catch (error) {
|
|
||||||
Logger.error(error, 'GhostfolioService');
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private getDataProviderInfo(): DataProviderInfo {
|
|
||||||
return {
|
|
||||||
isPremium: false,
|
|
||||||
name: 'Ghostfolio Premium',
|
|
||||||
url: 'https://ghostfol.io'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private getDataProviderServices() {
|
|
||||||
return this.configurationService
|
|
||||||
.get('DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER')
|
|
||||||
.map((dataSource) => {
|
|
||||||
return this.dataProviderService.getDataProvider(DataSource[dataSource]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,137 +0,0 @@
|
|||||||
import { AdminService } from '@ghostfolio/api/app/admin/admin.service';
|
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
|
||||||
import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper';
|
|
||||||
import { MarketDataDetailsResponse } from '@ghostfolio/common/interfaces';
|
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
|
||||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
|
||||||
|
|
||||||
import {
|
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
HttpException,
|
|
||||||
Inject,
|
|
||||||
Param,
|
|
||||||
Post,
|
|
||||||
UseGuards
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { REQUEST } from '@nestjs/core';
|
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
|
||||||
import { DataSource, Prisma } from '@prisma/client';
|
|
||||||
import { parseISO } from 'date-fns';
|
|
||||||
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
|
|
||||||
|
|
||||||
import { UpdateBulkMarketDataDto } from './update-bulk-market-data.dto';
|
|
||||||
|
|
||||||
@Controller('market-data')
|
|
||||||
export class MarketDataController {
|
|
||||||
public constructor(
|
|
||||||
private readonly adminService: AdminService,
|
|
||||||
private readonly marketDataService: MarketDataService,
|
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
|
||||||
private readonly symbolProfileService: SymbolProfileService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Get(':dataSource/:symbol')
|
|
||||||
@UseGuards(AuthGuard('jwt'))
|
|
||||||
public async getMarketDataBySymbol(
|
|
||||||
@Param('dataSource') dataSource: DataSource,
|
|
||||||
@Param('symbol') symbol: string
|
|
||||||
): Promise<MarketDataDetailsResponse> {
|
|
||||||
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
|
|
||||||
{ dataSource, symbol }
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!assetProfile && !isCurrency(getCurrencyFromSymbol(symbol))) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
|
||||||
StatusCodes.NOT_FOUND
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const canReadAllAssetProfiles = hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.readMarketData
|
|
||||||
);
|
|
||||||
|
|
||||||
const canReadOwnAssetProfile =
|
|
||||||
assetProfile?.userId === this.request.user.id &&
|
|
||||||
hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.readMarketDataOfOwnAssetProfile
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!canReadAllAssetProfiles && !canReadOwnAssetProfile) {
|
|
||||||
throw new HttpException(
|
|
||||||
assetProfile.userId
|
|
||||||
? getReasonPhrase(StatusCodes.NOT_FOUND)
|
|
||||||
: getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
assetProfile.userId ? StatusCodes.NOT_FOUND : StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post(':dataSource/:symbol')
|
|
||||||
@UseGuards(AuthGuard('jwt'))
|
|
||||||
public async updateMarketData(
|
|
||||||
@Body() data: UpdateBulkMarketDataDto,
|
|
||||||
@Param('dataSource') dataSource: DataSource,
|
|
||||||
@Param('symbol') symbol: string
|
|
||||||
) {
|
|
||||||
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
|
|
||||||
{ dataSource, symbol }
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!assetProfile) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
|
||||||
StatusCodes.NOT_FOUND
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const canUpsertAllAssetProfiles =
|
|
||||||
hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.createMarketData
|
|
||||||
) &&
|
|
||||||
hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.updateMarketData
|
|
||||||
);
|
|
||||||
|
|
||||||
const canUpsertOwnAssetProfile =
|
|
||||||
assetProfile.userId === this.request.user.id &&
|
|
||||||
hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.createMarketDataOfOwnAssetProfile
|
|
||||||
) &&
|
|
||||||
hasPermission(
|
|
||||||
this.request.user.permissions,
|
|
||||||
permissions.updateMarketDataOfOwnAssetProfile
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!canUpsertAllAssetProfiles && !canUpsertOwnAssetProfile) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map(
|
|
||||||
({ date, marketPrice }) => ({
|
|
||||||
dataSource,
|
|
||||||
marketPrice,
|
|
||||||
symbol,
|
|
||||||
date: parseISO(date),
|
|
||||||
state: 'CLOSE'
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return this.marketDataService.updateMany({
|
|
||||||
data: dataBulkUpdate
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,13 +0,0 @@
|
|||||||
import { AdminModule } from '@ghostfolio/api/app/admin/admin.module';
|
|
||||||
import { MarketDataModule as MarketDataServiceModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
|
||||||
|
|
||||||
import { MarketDataController } from './market-data.controller';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
controllers: [MarketDataController],
|
|
||||||
imports: [AdminModule, MarketDataServiceModule, SymbolProfileModule]
|
|
||||||
})
|
|
||||||
export class MarketDataModule {}
|
|
@ -1,24 +0,0 @@
|
|||||||
import { Type } from 'class-transformer';
|
|
||||||
import {
|
|
||||||
ArrayNotEmpty,
|
|
||||||
IsArray,
|
|
||||||
IsISO8601,
|
|
||||||
IsNumber,
|
|
||||||
IsOptional
|
|
||||||
} from 'class-validator';
|
|
||||||
|
|
||||||
export class UpdateBulkMarketDataDto {
|
|
||||||
@ArrayNotEmpty()
|
|
||||||
@IsArray()
|
|
||||||
@Type(() => UpdateMarketDataDto)
|
|
||||||
marketData: UpdateMarketDataDto[];
|
|
||||||
}
|
|
||||||
|
|
||||||
class UpdateMarketDataDto {
|
|
||||||
@IsISO8601()
|
|
||||||
@IsOptional()
|
|
||||||
date?: string;
|
|
||||||
|
|
||||||
@IsNumber()
|
|
||||||
marketPrice: number;
|
|
||||||
}
|
|
@ -1,139 +0,0 @@
|
|||||||
import { AccessService } from '@ghostfolio/api/app/access/access.service';
|
|
||||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
|
||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
|
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
|
||||||
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
|
|
||||||
import { getSum } from '@ghostfolio/common/helper';
|
|
||||||
import { PublicPortfolioResponse } from '@ghostfolio/common/interfaces';
|
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
|
||||||
|
|
||||||
import {
|
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
HttpException,
|
|
||||||
Inject,
|
|
||||||
Param,
|
|
||||||
UseInterceptors
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { REQUEST } from '@nestjs/core';
|
|
||||||
import { Big } from 'big.js';
|
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
|
||||||
|
|
||||||
@Controller('public')
|
|
||||||
export class PublicController {
|
|
||||||
public constructor(
|
|
||||||
private readonly accessService: AccessService,
|
|
||||||
private readonly configurationService: ConfigurationService,
|
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
|
||||||
private readonly portfolioService: PortfolioService,
|
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
|
||||||
private readonly userService: UserService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Get(':accessId/portfolio')
|
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
|
||||||
public async getPublicPortfolio(
|
|
||||||
@Param('accessId') accessId
|
|
||||||
): Promise<PublicPortfolioResponse> {
|
|
||||||
const access = await this.accessService.access({ id: accessId });
|
|
||||||
|
|
||||||
if (!access) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
|
||||||
StatusCodes.NOT_FOUND
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let hasDetails = true;
|
|
||||||
|
|
||||||
const user = await this.userService.user({
|
|
||||||
id: access.userId
|
|
||||||
});
|
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
|
||||||
hasDetails = user.subscription.type === 'Premium';
|
|
||||||
}
|
|
||||||
|
|
||||||
const [
|
|
||||||
{ holdings, markets },
|
|
||||||
{ performance: performance1d },
|
|
||||||
{ performance: performanceMax },
|
|
||||||
{ performance: performanceYtd }
|
|
||||||
] = await Promise.all([
|
|
||||||
this.portfolioService.getDetails({
|
|
||||||
impersonationId: access.userId,
|
|
||||||
userId: user.id,
|
|
||||||
withMarkets: true
|
|
||||||
}),
|
|
||||||
...['1d', 'max', 'ytd'].map((dateRange) => {
|
|
||||||
return this.portfolioService.getPerformance({
|
|
||||||
dateRange,
|
|
||||||
impersonationId: undefined,
|
|
||||||
userId: user.id
|
|
||||||
});
|
|
||||||
})
|
|
||||||
]);
|
|
||||||
|
|
||||||
Object.values(markets ?? {}).forEach((market) => {
|
|
||||||
delete market.valueInBaseCurrency;
|
|
||||||
});
|
|
||||||
|
|
||||||
const publicPortfolioResponse: PublicPortfolioResponse = {
|
|
||||||
hasDetails,
|
|
||||||
markets,
|
|
||||||
alias: access.alias,
|
|
||||||
holdings: {},
|
|
||||||
performance: {
|
|
||||||
'1d': {
|
|
||||||
relativeChange:
|
|
||||||
performance1d.netPerformancePercentageWithCurrencyEffect
|
|
||||||
},
|
|
||||||
max: {
|
|
||||||
relativeChange:
|
|
||||||
performanceMax.netPerformancePercentageWithCurrencyEffect
|
|
||||||
},
|
|
||||||
ytd: {
|
|
||||||
relativeChange:
|
|
||||||
performanceYtd.netPerformancePercentageWithCurrencyEffect
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const totalValue = getSum(
|
|
||||||
Object.values(holdings).map(({ currency, marketPrice, quantity }) => {
|
|
||||||
return new Big(
|
|
||||||
this.exchangeRateDataService.toCurrency(
|
|
||||||
quantity * marketPrice,
|
|
||||||
currency,
|
|
||||||
this.request.user?.Settings?.settings.baseCurrency ??
|
|
||||||
DEFAULT_CURRENCY
|
|
||||||
)
|
|
||||||
);
|
|
||||||
})
|
|
||||||
).toNumber();
|
|
||||||
|
|
||||||
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
|
||||||
publicPortfolioResponse.holdings[symbol] = {
|
|
||||||
allocationInPercentage:
|
|
||||||
portfolioPosition.valueInBaseCurrency / totalValue,
|
|
||||||
assetClass: hasDetails ? portfolioPosition.assetClass : undefined,
|
|
||||||
countries: hasDetails ? portfolioPosition.countries : [],
|
|
||||||
currency: hasDetails ? portfolioPosition.currency : undefined,
|
|
||||||
dataSource: portfolioPosition.dataSource,
|
|
||||||
dateOfFirstActivity: portfolioPosition.dateOfFirstActivity,
|
|
||||||
markets: hasDetails ? portfolioPosition.markets : undefined,
|
|
||||||
name: portfolioPosition.name,
|
|
||||||
netPerformancePercentWithCurrencyEffect:
|
|
||||||
portfolioPosition.netPerformancePercentWithCurrencyEffect,
|
|
||||||
sectors: hasDetails ? portfolioPosition.sectors : [],
|
|
||||||
symbol: portfolioPosition.symbol,
|
|
||||||
url: portfolioPosition.url,
|
|
||||||
valueInPercentage: portfolioPosition.valueInBaseCurrency / totalValue
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return publicPortfolioResponse;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,49 +0,0 @@
|
|||||||
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 { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
|
||||||
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
|
||||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
|
||||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
|
||||||
import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service';
|
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
|
||||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
|
||||||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.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 { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
|
||||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
|
||||||
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
|
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
|
||||||
|
|
||||||
import { PublicController } from './public.controller';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
controllers: [PublicController],
|
|
||||||
imports: [
|
|
||||||
AccessModule,
|
|
||||||
DataProviderModule,
|
|
||||||
ExchangeRateDataModule,
|
|
||||||
ImpersonationModule,
|
|
||||||
MarketDataModule,
|
|
||||||
OrderModule,
|
|
||||||
PortfolioSnapshotQueueModule,
|
|
||||||
PrismaModule,
|
|
||||||
RedisCacheModule,
|
|
||||||
SymbolProfileModule,
|
|
||||||
TransformDataSourceInRequestModule,
|
|
||||||
UserModule
|
|
||||||
],
|
|
||||||
providers: [
|
|
||||||
AccountBalanceService,
|
|
||||||
AccountService,
|
|
||||||
CurrentRateService,
|
|
||||||
PortfolioCalculatorFactory,
|
|
||||||
PortfolioService,
|
|
||||||
RulesService
|
|
||||||
]
|
|
||||||
})
|
|
||||||
export class PublicModule {}
|
|
@ -1,10 +0,0 @@
|
|||||||
import { IsOptional, IsString } from 'class-validator';
|
|
||||||
|
|
||||||
export class CreateTagDto {
|
|
||||||
@IsString()
|
|
||||||
name: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
userId?: string;
|
|
||||||
}
|
|
@ -1,12 +0,0 @@
|
|||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
|
||||||
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
|
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
|
||||||
|
|
||||||
import { TagsController } from './tags.controller';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
controllers: [TagsController],
|
|
||||||
imports: [PrismaModule, TagModule]
|
|
||||||
})
|
|
||||||
export class TagsModule {}
|
|
@ -1,13 +0,0 @@
|
|||||||
import { IsOptional, IsString } from 'class-validator';
|
|
||||||
|
|
||||||
export class UpdateTagDto {
|
|
||||||
@IsString()
|
|
||||||
id: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
name: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
userId?: string;
|
|
||||||
}
|
|
@ -1,6 +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,
|
||||||
Get,
|
Get,
|
||||||
@ -21,7 +19,7 @@ export class ExchangeRateController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get(':symbol/:dateString')
|
@Get(':symbol/:dateString')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getExchangeRate(
|
public async getExchangeRate(
|
||||||
@Param('dateString') dateString: string,
|
@Param('dateString') dateString: string,
|
||||||
@Param('symbol') symbol: string
|
@Param('symbol') symbol: string
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
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 { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { ExchangeRateController } from './exchange-rate.controller';
|
import { ExchangeRateController } from './exchange-rate.controller';
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
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 { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
@ -1,8 +1,5 @@
|
|||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
|
||||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
|
||||||
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';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
@ -12,28 +9,17 @@ import { ExportService } from './export.service';
|
|||||||
@Controller('export')
|
@Controller('export')
|
||||||
export class ExportController {
|
export class ExportController {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly apiService: ApiService,
|
|
||||||
private readonly exportService: ExportService,
|
private readonly exportService: ExportService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async export(
|
public async export(
|
||||||
@Query('accounts') filterByAccounts?: string,
|
@Query('activityIds') activityIds?: string[]
|
||||||
@Query('activityIds') activityIds?: string[],
|
|
||||||
@Query('assetClasses') filterByAssetClasses?: string,
|
|
||||||
@Query('tags') filterByTags?: string
|
|
||||||
): Promise<Export> {
|
): Promise<Export> {
|
||||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
|
||||||
filterByAccounts,
|
|
||||||
filterByAssetClasses,
|
|
||||||
filterByTags
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.exportService.export({
|
return this.exportService.export({
|
||||||
activityIds,
|
activityIds,
|
||||||
filters,
|
|
||||||
userCurrency: this.request.user.Settings.settings.baseCurrency,
|
userCurrency: this.request.user.Settings.settings.baseCurrency,
|
||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
|
@ -1,15 +1,23 @@
|
|||||||
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
|
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
|
||||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { TagModule } from '@ghostfolio/api/services/tag/tag.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 { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { ExportController } from './export.controller';
|
import { ExportController } from './export.controller';
|
||||||
import { ExportService } from './export.service';
|
import { ExportService } from './export.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AccountModule, ApiModule, OrderModule, TagModule],
|
imports: [
|
||||||
|
AccountModule,
|
||||||
|
ConfigurationModule,
|
||||||
|
DataGatheringModule,
|
||||||
|
DataProviderModule,
|
||||||
|
OrderModule,
|
||||||
|
RedisCacheModule
|
||||||
|
],
|
||||||
controllers: [ExportController],
|
controllers: [ExportController],
|
||||||
providers: [ExportService]
|
providers: [ExportService]
|
||||||
})
|
})
|
||||||
|
@ -1,27 +1,22 @@
|
|||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
import { environment } from '@ghostfolio/api/environments/environment';
|
import { environment } from '@ghostfolio/api/environments/environment';
|
||||||
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
import { Export } from '@ghostfolio/common/interfaces';
|
||||||
import { Filter, Export } from '@ghostfolio/common/interfaces';
|
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExportService {
|
export class ExportService {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly accountService: AccountService,
|
private readonly accountService: AccountService,
|
||||||
private readonly orderService: OrderService,
|
private readonly orderService: OrderService
|
||||||
private readonly tagService: TagService
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async export({
|
public async export({
|
||||||
activityIds,
|
activityIds,
|
||||||
filters,
|
|
||||||
userCurrency,
|
userCurrency,
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
activityIds?: string[];
|
activityIds?: string[];
|
||||||
filters?: Filter[];
|
|
||||||
userCurrency: string;
|
userCurrency: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
}): Promise<Export> {
|
}): Promise<Export> {
|
||||||
@ -47,7 +42,6 @@ export class ExportService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let { activities } = await this.orderService.getOrders({
|
let { activities } = await this.orderService.getOrders({
|
||||||
filters,
|
|
||||||
userCurrency,
|
userCurrency,
|
||||||
userId,
|
userId,
|
||||||
includeDrafts: true,
|
includeDrafts: true,
|
||||||
@ -62,21 +56,9 @@ export class ExportService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const tags = (await this.tagService.getTagsForUser(userId))
|
|
||||||
.filter(({ isUsed }) => {
|
|
||||||
return isUsed;
|
|
||||||
})
|
|
||||||
.map(({ id, name }) => {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
name
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
meta: { date: new Date().toISOString(), version: environment.version },
|
meta: { date: new Date().toISOString(), version: environment.version },
|
||||||
accounts,
|
accounts,
|
||||||
tags,
|
|
||||||
activities: activities.map(
|
activities: activities.map(
|
||||||
({
|
({
|
||||||
accountId,
|
accountId,
|
||||||
@ -86,7 +68,6 @@ export class ExportService {
|
|||||||
id,
|
id,
|
||||||
quantity,
|
quantity,
|
||||||
SymbolProfile,
|
SymbolProfile,
|
||||||
tags: currentTags,
|
|
||||||
type,
|
type,
|
||||||
unitPrice
|
unitPrice
|
||||||
}) => {
|
}) => {
|
||||||
@ -101,18 +82,16 @@ export class ExportService {
|
|||||||
currency: SymbolProfile.currency,
|
currency: SymbolProfile.currency,
|
||||||
dataSource: SymbolProfile.dataSource,
|
dataSource: SymbolProfile.dataSource,
|
||||||
date: date.toISOString(),
|
date: date.toISOString(),
|
||||||
symbol: ['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(type)
|
symbol:
|
||||||
? SymbolProfile.name
|
type === 'FEE' ||
|
||||||
: SymbolProfile.symbol,
|
type === 'INTEREST' ||
|
||||||
tags: currentTags.map(({ id: tagId }) => {
|
type === 'ITEM' ||
|
||||||
return tagId;
|
type === 'LIABILITY'
|
||||||
})
|
? SymbolProfile.name
|
||||||
|
: SymbolProfile.symbol
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
),
|
)
|
||||||
user: {
|
|
||||||
settings: { currency: userCurrency }
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,16 +1,12 @@
|
|||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
HttpException,
|
HttpException,
|
||||||
HttpStatus,
|
|
||||||
Param,
|
Param,
|
||||||
Res,
|
|
||||||
UseInterceptors
|
UseInterceptors
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
import { Response } from 'express';
|
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { HealthService } from './health.service';
|
import { HealthService } from './health.service';
|
||||||
@ -20,21 +16,7 @@ export class HealthController {
|
|||||||
public constructor(private readonly healthService: HealthService) {}
|
public constructor(private readonly healthService: HealthService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
public async getHealth(@Res() response: Response) {
|
public async getHealth() {}
|
||||||
const databaseServiceHealthy = await this.healthService.isDatabaseHealthy();
|
|
||||||
const redisCacheServiceHealthy =
|
|
||||||
await this.healthService.isRedisCacheHealthy();
|
|
||||||
|
|
||||||
if (databaseServiceHealthy && redisCacheServiceHealthy) {
|
|
||||||
return response
|
|
||||||
.status(HttpStatus.OK)
|
|
||||||
.json({ status: getReasonPhrase(StatusCodes.OK) });
|
|
||||||
} else {
|
|
||||||
return response
|
|
||||||
.status(HttpStatus.SERVICE_UNAVAILABLE)
|
|
||||||
.json({ status: getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('data-enhancer/:name')
|
@Get('data-enhancer/:name')
|
||||||
public async getHealthOfDataEnhancer(@Param('name') name: string) {
|
public async getHealthOfDataEnhancer(@Param('name') name: string) {
|
||||||
|
@ -1,9 +1,6 @@
|
|||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
|
||||||
import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.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 { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { HealthController } from './health.controller';
|
import { HealthController } from './health.controller';
|
||||||
@ -11,13 +8,7 @@ import { HealthService } from './health.service';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
imports: [
|
imports: [ConfigurationModule, DataEnhancerModule, DataProviderModule],
|
||||||
DataEnhancerModule,
|
|
||||||
DataProviderModule,
|
|
||||||
PropertyModule,
|
|
||||||
RedisCacheModule,
|
|
||||||
TransformDataSourceInRequestModule
|
|
||||||
],
|
|
||||||
providers: [HealthService]
|
providers: [HealthService]
|
||||||
})
|
})
|
||||||
export class HealthModule {}
|
export class HealthModule {}
|
||||||
|
@ -1,9 +1,5 @@
|
|||||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
|
||||||
import { DataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.service';
|
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 { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
|
||||||
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
|
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
|
|
||||||
@ -11,9 +7,7 @@ import { DataSource } from '@prisma/client';
|
|||||||
export class HealthService {
|
export class HealthService {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly dataEnhancerService: DataEnhancerService,
|
private readonly dataEnhancerService: DataEnhancerService,
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService
|
||||||
private readonly propertyService: PropertyService,
|
|
||||||
private readonly redisCacheService: RedisCacheService
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async hasResponseFromDataEnhancer(aName: string) {
|
public async hasResponseFromDataEnhancer(aName: string) {
|
||||||
@ -23,24 +17,4 @@ export class HealthService {
|
|||||||
public async hasResponseFromDataProvider(aDataSource: DataSource) {
|
public async hasResponseFromDataProvider(aDataSource: DataSource) {
|
||||||
return this.dataProviderService.checkQuote(aDataSource);
|
return this.dataProviderService.checkQuote(aDataSource);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async isDatabaseHealthy() {
|
|
||||||
try {
|
|
||||||
await this.propertyService.getByKey(PROPERTY_CURRENCIES);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async isRedisCacheHealthy() {
|
|
||||||
try {
|
|
||||||
const isHealthy = await this.redisCacheService.isHealthy();
|
|
||||||
|
|
||||||
return isHealthy;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
|
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
|
||||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||||
|
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { IsArray, IsOptional, ValidateNested } from 'class-validator';
|
import { IsArray, IsOptional, ValidateNested } from 'class-validator';
|
||||||
|
|
||||||
|
@ -1,12 +1,9 @@
|
|||||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
|
||||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
|
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||||
import { ImportResponse } from '@ghostfolio/common/interfaces';
|
import { ImportResponse } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@ -37,18 +34,19 @@ export class ImportController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@HasPermission(permissions.createOrder)
|
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async import(
|
public async import(
|
||||||
@Body() importData: ImportDataDto,
|
@Body() importData: ImportDataDto,
|
||||||
@Query('dryRun') isDryRunParam = 'false'
|
@Query('dryRun') isDryRun?: boolean
|
||||||
): Promise<ImportResponse> {
|
): Promise<ImportResponse> {
|
||||||
const isDryRun = isDryRunParam === 'true';
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!hasPermission(this.request.user.permissions, permissions.createAccount)
|
!hasPermission(
|
||||||
|
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),
|
||||||
@ -67,13 +65,16 @@ export class ImportController {
|
|||||||
maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
|
maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const activities = await this.importService.import({
|
const activities = await this.importService.import({
|
||||||
isDryRun,
|
isDryRun,
|
||||||
maxActivitiesToImport,
|
maxActivitiesToImport,
|
||||||
|
userCurrency,
|
||||||
accountsDto: importData.accounts ?? [],
|
accountsDto: importData.accounts ?? [],
|
||||||
activitiesDto: importData.activities,
|
activitiesDto: importData.activities,
|
||||||
user: this.request.user
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
|
|
||||||
return { activities };
|
return { activities };
|
||||||
@ -91,7 +92,7 @@ export class ImportController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('dividends/:dataSource/:symbol')
|
@Get('dividends/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||||
public async gatherDividends(
|
public async gatherDividends(
|
||||||
|
@ -4,15 +4,12 @@ import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
|||||||
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
|
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.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 { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
|
||||||
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.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 { 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';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
|
|
||||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { ImportController } from './import.controller';
|
import { ImportController } from './import.controller';
|
||||||
@ -32,9 +29,7 @@ import { ImportService } from './import.service';
|
|||||||
PortfolioModule,
|
PortfolioModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
RedisCacheModule,
|
RedisCacheModule,
|
||||||
SymbolProfileModule,
|
SymbolProfileModule
|
||||||
TransformDataSourceInRequestModule,
|
|
||||||
TransformDataSourceInResponseModule
|
|
||||||
],
|
],
|
||||||
providers: [ImportService]
|
providers: [ImportService]
|
||||||
})
|
})
|
||||||
|
@ -9,28 +9,25 @@ 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 { 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 { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
|
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||||
import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config';
|
|
||||||
import {
|
import {
|
||||||
DATE_FORMAT,
|
DATE_FORMAT,
|
||||||
getAssetProfileIdentifier,
|
getAssetProfileIdentifier,
|
||||||
parseDate
|
parseDate
|
||||||
} from '@ghostfolio/common/helper';
|
} from '@ghostfolio/common/helper';
|
||||||
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
|
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||||
import {
|
import {
|
||||||
AccountWithPlatform,
|
AccountWithPlatform,
|
||||||
OrderWithAccount,
|
OrderWithAccount
|
||||||
UserWithSettings
|
|
||||||
} from '@ghostfolio/common/types';
|
} from '@ghostfolio/common/types';
|
||||||
|
|
||||||
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, format, isAfter, isSameSecond, parseISO } from 'date-fns';
|
import { endOfToday, format, isAfter, isSameSecond, parseISO } from 'date-fns';
|
||||||
import { isNumber, uniqBy } from 'lodash';
|
import { uniqBy } from 'lodash';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -51,7 +48,7 @@ export class ImportService {
|
|||||||
dataSource,
|
dataSource,
|
||||||
symbol,
|
symbol,
|
||||||
userCurrency
|
userCurrency
|
||||||
}: AssetProfileIdentifier & { userCurrency: string }): Promise<Activity[]> {
|
}: UniqueAsset & { userCurrency: string }): Promise<Activity[]> {
|
||||||
try {
|
try {
|
||||||
const { firstBuyDate, historicalData, orders } =
|
const { firstBuyDate, historicalData, orders } =
|
||||||
await this.portfolioService.getPosition(dataSource, undefined, symbol);
|
await this.portfolioService.getPosition(dataSource, undefined, symbol);
|
||||||
@ -72,74 +69,65 @@ export class ImportService {
|
|||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const accounts = orders
|
const accounts = orders.map((order) => {
|
||||||
.filter(({ Account }) => {
|
return order.Account;
|
||||||
return !!Account;
|
});
|
||||||
})
|
|
||||||
.map(({ Account }) => {
|
|
||||||
return Account;
|
|
||||||
});
|
|
||||||
|
|
||||||
const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;
|
const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;
|
||||||
|
|
||||||
return await Promise.all(
|
return Object.entries(dividends).map(([dateString, { marketPrice }]) => {
|
||||||
Object.entries(dividends).map(async ([dateString, { marketPrice }]) => {
|
const quantity =
|
||||||
const quantity =
|
historicalData.find((historicalDataItem) => {
|
||||||
historicalData.find((historicalDataItem) => {
|
return historicalDataItem.date === dateString;
|
||||||
return historicalDataItem.date === dateString;
|
})?.quantity ?? 0;
|
||||||
})?.quantity ?? 0;
|
|
||||||
|
|
||||||
const value = new Big(quantity).mul(marketPrice).toNumber();
|
const value = new Big(quantity).mul(marketPrice).toNumber();
|
||||||
|
|
||||||
const date = parseDate(dateString);
|
const date = parseDate(dateString);
|
||||||
const isDuplicate = orders.some((activity) => {
|
const isDuplicate = orders.some((activity) => {
|
||||||
return (
|
return (
|
||||||
activity.accountId === Account?.id &&
|
activity.accountId === Account?.id &&
|
||||||
activity.SymbolProfile.currency === assetProfile.currency &&
|
activity.SymbolProfile.currency === assetProfile.currency &&
|
||||||
activity.SymbolProfile.dataSource === assetProfile.dataSource &&
|
activity.SymbolProfile.dataSource === assetProfile.dataSource &&
|
||||||
isSameSecond(activity.date, date) &&
|
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' &&
|
||||||
activity.unitPrice === marketPrice
|
activity.unitPrice === marketPrice
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const error: ActivityError = isDuplicate
|
const error: ActivityError = isDuplicate
|
||||||
? { code: 'IS_DUPLICATE' }
|
? { code: 'IS_DUPLICATE' }
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
Account,
|
Account,
|
||||||
date,
|
date,
|
||||||
error,
|
error,
|
||||||
quantity,
|
quantity,
|
||||||
|
value,
|
||||||
|
accountId: Account?.id,
|
||||||
|
accountUserId: undefined,
|
||||||
|
comment: undefined,
|
||||||
|
createdAt: undefined,
|
||||||
|
fee: 0,
|
||||||
|
feeInBaseCurrency: 0,
|
||||||
|
id: assetProfile.id,
|
||||||
|
isDraft: false,
|
||||||
|
SymbolProfile: <SymbolProfile>(<unknown>assetProfile),
|
||||||
|
symbolProfileId: assetProfile.id,
|
||||||
|
type: 'DIVIDEND',
|
||||||
|
unitPrice: marketPrice,
|
||||||
|
updatedAt: undefined,
|
||||||
|
userId: Account?.userId,
|
||||||
|
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
value,
|
value,
|
||||||
accountId: Account?.id,
|
assetProfile.currency,
|
||||||
accountUserId: undefined,
|
userCurrency
|
||||||
comment: undefined,
|
)
|
||||||
currency: undefined,
|
};
|
||||||
createdAt: undefined,
|
});
|
||||||
fee: 0,
|
|
||||||
feeInBaseCurrency: 0,
|
|
||||||
id: assetProfile.id,
|
|
||||||
isDraft: false,
|
|
||||||
SymbolProfile: assetProfile,
|
|
||||||
symbolProfileId: assetProfile.id,
|
|
||||||
type: 'DIVIDEND',
|
|
||||||
unitPrice: marketPrice,
|
|
||||||
updatedAt: undefined,
|
|
||||||
userId: Account?.userId,
|
|
||||||
valueInBaseCurrency:
|
|
||||||
await this.exchangeRateDataService.toCurrencyAtDate(
|
|
||||||
value,
|
|
||||||
assetProfile.currency,
|
|
||||||
userCurrency,
|
|
||||||
date
|
|
||||||
)
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@ -150,16 +138,17 @@ export class ImportService {
|
|||||||
activitiesDto,
|
activitiesDto,
|
||||||
isDryRun = false,
|
isDryRun = false,
|
||||||
maxActivitiesToImport,
|
maxActivitiesToImport,
|
||||||
user
|
userCurrency,
|
||||||
|
userId
|
||||||
}: {
|
}: {
|
||||||
accountsDto: Partial<CreateAccountDto>[];
|
accountsDto: Partial<CreateAccountDto>[];
|
||||||
activitiesDto: Partial<CreateOrderDto>[];
|
activitiesDto: Partial<CreateOrderDto>[];
|
||||||
isDryRun?: boolean;
|
isDryRun?: boolean;
|
||||||
maxActivitiesToImport: number;
|
maxActivitiesToImport: number;
|
||||||
user: UserWithSettings;
|
userCurrency: string;
|
||||||
|
userId: string;
|
||||||
}): Promise<Activity[]> {
|
}): Promise<Activity[]> {
|
||||||
const accountIdMapping: { [oldAccountId: string]: string } = {};
|
const accountIdMapping: { [oldAccountId: string]: string } = {};
|
||||||
const userCurrency = user.Settings.settings.baseCurrency;
|
|
||||||
|
|
||||||
if (!isDryRun && accountsDto?.length) {
|
if (!isDryRun && accountsDto?.length) {
|
||||||
const [existingAccounts, existingPlatforms] = await Promise.all([
|
const [existingAccounts, existingPlatforms] = await Promise.all([
|
||||||
@ -182,7 +171,7 @@ export class ImportService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// If there is no account or if the account belongs to a different user then create a new account
|
// If there is no account or if the account belongs to a different user then create a new account
|
||||||
if (!accountWithSameId || accountWithSameId.userId !== user.id) {
|
if (!accountWithSameId || accountWithSameId.userId !== userId) {
|
||||||
let oldAccountId: string;
|
let oldAccountId: string;
|
||||||
const platformId = account.platformId;
|
const platformId = account.platformId;
|
||||||
|
|
||||||
@ -195,7 +184,7 @@ export class ImportService {
|
|||||||
|
|
||||||
let accountObject: Prisma.AccountCreateInput = {
|
let accountObject: Prisma.AccountCreateInput = {
|
||||||
...account,
|
...account,
|
||||||
User: { connect: { id: user.id } }
|
User: { connect: { id: userId } }
|
||||||
};
|
};
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -211,7 +200,7 @@ export class ImportService {
|
|||||||
|
|
||||||
const newAccount = await this.accountService.createAccount(
|
const newAccount = await this.accountService.createAccount(
|
||||||
accountObject,
|
accountObject,
|
||||||
user.id
|
userId
|
||||||
);
|
);
|
||||||
|
|
||||||
// Store the new to old account ID mappings for updating activities
|
// Store the new to old account ID mappings for updating activities
|
||||||
@ -224,7 +213,7 @@ export class ImportService {
|
|||||||
|
|
||||||
for (const activity of activitiesDto) {
|
for (const activity of activitiesDto) {
|
||||||
if (!activity.dataSource) {
|
if (!activity.dataSource) {
|
||||||
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(activity.type)) {
|
if (activity.type === 'ITEM' || activity.type === 'LIABILITY') {
|
||||||
activity.dataSource = DataSource.MANUAL;
|
activity.dataSource = DataSource.MANUAL;
|
||||||
} else {
|
} else {
|
||||||
activity.dataSource =
|
activity.dataSource =
|
||||||
@ -242,17 +231,16 @@ export class ImportService {
|
|||||||
|
|
||||||
const assetProfiles = await this.validateActivities({
|
const assetProfiles = await this.validateActivities({
|
||||||
activitiesDto,
|
activitiesDto,
|
||||||
maxActivitiesToImport,
|
maxActivitiesToImport
|
||||||
user
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({
|
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({
|
||||||
activitiesDto,
|
activitiesDto,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
userId: user.id
|
userId
|
||||||
});
|
});
|
||||||
|
|
||||||
const accounts = (await this.accountService.getAccounts(user.id)).map(
|
const accounts = (await this.accountService.getAccounts(userId)).map(
|
||||||
({ id, name }) => {
|
({ id, name }) => {
|
||||||
return { id, name };
|
return { id, name };
|
||||||
}
|
}
|
||||||
@ -266,18 +254,20 @@ export class ImportService {
|
|||||||
|
|
||||||
const activities: Activity[] = [];
|
const activities: Activity[] = [];
|
||||||
|
|
||||||
for (const [index, activity] of activitiesExtendedWithErrors.entries()) {
|
for (let [
|
||||||
const accountId = activity.accountId;
|
index,
|
||||||
const comment = activity.comment;
|
{
|
||||||
const currency = activity.currency;
|
accountId,
|
||||||
const date = activity.date;
|
comment,
|
||||||
const error = activity.error;
|
date,
|
||||||
let fee = activity.fee;
|
error,
|
||||||
const quantity = activity.quantity;
|
fee,
|
||||||
const SymbolProfile = activity.SymbolProfile;
|
quantity,
|
||||||
const type = activity.type;
|
SymbolProfile,
|
||||||
let unitPrice = activity.unitPrice;
|
type,
|
||||||
|
unitPrice
|
||||||
|
}
|
||||||
|
] of activitiesExtendedWithErrors.entries()) {
|
||||||
const assetProfile = assetProfiles[
|
const assetProfile = assetProfiles[
|
||||||
getAssetProfileIdentifier({
|
getAssetProfileIdentifier({
|
||||||
dataSource: SymbolProfile.dataSource,
|
dataSource: SymbolProfile.dataSource,
|
||||||
@ -293,12 +283,11 @@ export class ImportService {
|
|||||||
assetSubClass,
|
assetSubClass,
|
||||||
countries,
|
countries,
|
||||||
createdAt,
|
createdAt,
|
||||||
cusip,
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
figi,
|
figi,
|
||||||
figiComposite,
|
figiComposite,
|
||||||
figiShareClass,
|
figiShareClass,
|
||||||
holdings,
|
|
||||||
id,
|
id,
|
||||||
isin,
|
isin,
|
||||||
name,
|
name,
|
||||||
@ -329,7 +318,7 @@ export class ImportService {
|
|||||||
date
|
date
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isNumber(unitPrice)) {
|
if (!unitPrice) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`activities.${index} historical exchange rate at ${format(
|
`activities.${index} historical exchange rate at ${format(
|
||||||
date,
|
date,
|
||||||
@ -351,13 +340,12 @@ export class ImportService {
|
|||||||
if (isDryRun) {
|
if (isDryRun) {
|
||||||
order = {
|
order = {
|
||||||
comment,
|
comment,
|
||||||
currency,
|
|
||||||
date,
|
date,
|
||||||
fee,
|
fee,
|
||||||
quantity,
|
quantity,
|
||||||
type,
|
type,
|
||||||
unitPrice,
|
unitPrice,
|
||||||
Account: validatedAccount,
|
userId,
|
||||||
accountId: validatedAccount?.id,
|
accountId: validatedAccount?.id,
|
||||||
accountUserId: undefined,
|
accountUserId: undefined,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
@ -368,12 +356,11 @@ export class ImportService {
|
|||||||
assetSubClass,
|
assetSubClass,
|
||||||
countries,
|
countries,
|
||||||
createdAt,
|
createdAt,
|
||||||
cusip,
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
figi,
|
figi,
|
||||||
figiComposite,
|
figiComposite,
|
||||||
figiShareClass,
|
figiShareClass,
|
||||||
holdings,
|
|
||||||
id,
|
id,
|
||||||
isin,
|
isin,
|
||||||
name,
|
name,
|
||||||
@ -383,13 +370,11 @@ export class ImportService {
|
|||||||
symbolMapping,
|
symbolMapping,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
url,
|
url,
|
||||||
comment: assetProfile.comment,
|
comment: assetProfile.comment
|
||||||
currency: assetProfile.currency,
|
|
||||||
userId: dataSource === 'MANUAL' ? user.id : undefined
|
|
||||||
},
|
},
|
||||||
|
Account: validatedAccount,
|
||||||
symbolProfileId: undefined,
|
symbolProfileId: undefined,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date()
|
||||||
userId: user.id
|
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
if (error) {
|
if (error) {
|
||||||
@ -403,14 +388,14 @@ export class ImportService {
|
|||||||
quantity,
|
quantity,
|
||||||
type,
|
type,
|
||||||
unitPrice,
|
unitPrice,
|
||||||
|
userId,
|
||||||
accountId: validatedAccount?.id,
|
accountId: validatedAccount?.id,
|
||||||
SymbolProfile: {
|
SymbolProfile: {
|
||||||
connectOrCreate: {
|
connectOrCreate: {
|
||||||
create: {
|
create: {
|
||||||
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol,
|
symbol
|
||||||
currency: assetProfile.currency,
|
|
||||||
userId: dataSource === 'MANUAL' ? user.id : undefined
|
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
dataSource_symbol: {
|
dataSource_symbol: {
|
||||||
@ -421,14 +406,8 @@ export class ImportService {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
updateAccountBalance: false,
|
updateAccountBalance: false,
|
||||||
User: { connect: { id: user.id } },
|
User: { connect: { id: userId } }
|
||||||
userId: user.id
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (order.SymbolProfile?.symbol) {
|
|
||||||
// Update symbol that may have been assigned in createOrder()
|
|
||||||
assetProfile.symbol = order.SymbolProfile.symbol;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const value = new Big(quantity).mul(unitPrice).toNumber();
|
const value = new Big(quantity).mul(unitPrice).toNumber();
|
||||||
@ -437,21 +416,18 @@ export class ImportService {
|
|||||||
...order,
|
...order,
|
||||||
error,
|
error,
|
||||||
value,
|
value,
|
||||||
feeInBaseCurrency: await this.exchangeRateDataService.toCurrencyAtDate(
|
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
fee,
|
fee,
|
||||||
assetProfile.currency,
|
currency,
|
||||||
userCurrency,
|
userCurrency
|
||||||
date
|
|
||||||
),
|
),
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
SymbolProfile: assetProfile,
|
SymbolProfile: assetProfile,
|
||||||
valueInBaseCurrency:
|
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||||
await this.exchangeRateDataService.toCurrencyAtDate(
|
value,
|
||||||
value,
|
currency,
|
||||||
assetProfile.currency,
|
userCurrency
|
||||||
userCurrency,
|
)
|
||||||
date
|
|
||||||
)
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -468,16 +444,15 @@ export class ImportService {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
this.dataGatheringService.gatherSymbols({
|
this.dataGatheringService.gatherSymbols(
|
||||||
dataGatheringItems: uniqueActivities.map(({ date, SymbolProfile }) => {
|
uniqueActivities.map(({ date, SymbolProfile }) => {
|
||||||
return {
|
return {
|
||||||
date,
|
date,
|
||||||
dataSource: SymbolProfile.dataSource,
|
dataSource: SymbolProfile.dataSource,
|
||||||
symbol: SymbolProfile.symbol
|
symbol: SymbolProfile.symbol
|
||||||
};
|
};
|
||||||
}),
|
})
|
||||||
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
|
);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return activities;
|
return activities;
|
||||||
@ -492,13 +467,12 @@ export class ImportService {
|
|||||||
userCurrency: string;
|
userCurrency: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
}): Promise<Partial<Activity>[]> {
|
}): Promise<Partial<Activity>[]> {
|
||||||
const { activities: existingActivities } =
|
let { activities: existingActivities } = await this.orderService.getOrders({
|
||||||
await this.orderService.getOrders({
|
userCurrency,
|
||||||
userCurrency,
|
userId,
|
||||||
userId,
|
includeDrafts: true,
|
||||||
includeDrafts: true,
|
withExcludedAccounts: true
|
||||||
withExcludedAccounts: true
|
});
|
||||||
});
|
|
||||||
|
|
||||||
return activitiesDto.map(
|
return activitiesDto.map(
|
||||||
({
|
({
|
||||||
@ -545,15 +519,22 @@ export class ImportService {
|
|||||||
currency,
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol,
|
symbol,
|
||||||
activitiesCount: undefined,
|
assetClass: null,
|
||||||
assetClass: undefined,
|
assetSubClass: null,
|
||||||
assetSubClass: undefined,
|
comment: null,
|
||||||
countries: undefined,
|
countries: null,
|
||||||
createdAt: undefined,
|
createdAt: undefined,
|
||||||
holdings: undefined,
|
figi: null,
|
||||||
|
figiComposite: null,
|
||||||
|
figiShareClass: null,
|
||||||
id: undefined,
|
id: undefined,
|
||||||
sectors: undefined,
|
isin: null,
|
||||||
updatedAt: undefined
|
name: null,
|
||||||
|
scraperConfiguration: null,
|
||||||
|
sectors: null,
|
||||||
|
symbolMapping: null,
|
||||||
|
updatedAt: undefined,
|
||||||
|
url: null
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -572,12 +553,10 @@ export class ImportService {
|
|||||||
|
|
||||||
private async validateActivities({
|
private async validateActivities({
|
||||||
activitiesDto,
|
activitiesDto,
|
||||||
maxActivitiesToImport,
|
maxActivitiesToImport
|
||||||
user
|
|
||||||
}: {
|
}: {
|
||||||
activitiesDto: Partial<CreateOrderDto>[];
|
activitiesDto: Partial<CreateOrderDto>[];
|
||||||
maxActivitiesToImport: number;
|
maxActivitiesToImport: number;
|
||||||
user: UserWithSettings;
|
|
||||||
}) {
|
}) {
|
||||||
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)`);
|
||||||
@ -586,55 +565,47 @@ export class ImportService {
|
|||||||
const assetProfiles: {
|
const assetProfiles: {
|
||||||
[assetProfileIdentifier: string]: Partial<SymbolProfile>;
|
[assetProfileIdentifier: string]: Partial<SymbolProfile>;
|
||||||
} = {};
|
} = {};
|
||||||
const dataSources = await this.dataProviderService.getDataSources();
|
|
||||||
|
const uniqueActivitiesDto = uniqBy(
|
||||||
|
activitiesDto,
|
||||||
|
({ dataSource, symbol }) => {
|
||||||
|
return getAssetProfileIdentifier({ dataSource, symbol });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
for (const [
|
for (const [
|
||||||
index,
|
index,
|
||||||
{ currency, dataSource, symbol, type }
|
{ currency, dataSource, symbol }
|
||||||
] of activitiesDto.entries()) {
|
] of uniqueActivitiesDto.entries()) {
|
||||||
if (!dataSources.includes(dataSource)) {
|
if (!this.configurationService.get('DATA_SOURCES').includes(dataSource)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`activities.${index}.dataSource ("${dataSource}") is not valid`
|
`activities.${index}.dataSource ("${dataSource}") is not valid`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (dataSource !== 'MANUAL') {
|
||||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
const assetProfile = (
|
||||||
user.subscription.type === 'Basic'
|
await this.dataProviderService.getAssetProfiles([
|
||||||
) {
|
{ dataSource, symbol }
|
||||||
const dataProvider = this.dataProviderService.getDataProvider(
|
])
|
||||||
DataSource[dataSource]
|
)?.[symbol];
|
||||||
);
|
|
||||||
|
|
||||||
if (dataProvider.getDataProviderInfo().isPremium) {
|
if (!assetProfile?.name) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`activities.${index}.dataSource ("${dataSource}") is not valid`
|
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })]) {
|
if (
|
||||||
const assetProfile = {
|
assetProfile.currency !== currency &&
|
||||||
currency,
|
!this.exchangeRateDataService.hasCurrencyPair(
|
||||||
...(
|
currency,
|
||||||
await this.dataProviderService.getAssetProfiles([
|
assetProfile.currency
|
||||||
{ dataSource, symbol }
|
)
|
||||||
])
|
) {
|
||||||
)?.[symbol]
|
throw new Error(
|
||||||
};
|
`activities.${index}.currency ("${currency}") does not match with "${assetProfile.currency}" and no exchange rate is available from "${currency}" to "${assetProfile.currency}"`
|
||||||
|
);
|
||||||
if (type === 'BUY' || type === 'DIVIDEND' || type === 'SELL') {
|
|
||||||
if (!assetProfile?.name) {
|
|
||||||
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 currency of ${assetProfile.symbol} ("${assetProfile.currency}")`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] =
|
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] =
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user