Compare commits

..

1 Commits

Author SHA1 Message Date
Valentin Zickner
a91f6c783e introduce exchange rate service to fetch rates based on date query
Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2021-12-13 20:15:25 +01:00
1353 changed files with 38631 additions and 226837 deletions

16
.env Normal file
View File

@ -0,0 +1,16 @@
COMPOSE_PROJECT_NAME=ghostfolio-development
# CACHE
REDIS_HOST=localhost
REDIS_PORT=6379
# POSTGRES
POSTGRES_DB=ghostfolio-db
POSTGRES_USER=user
POSTGRES_PASSWORD=password
ACCESS_TOKEN_SALT=GHOSTFOLIO
ALPHA_VANTAGE_API_KEY=
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer
JWT_SECRET_KEY=123456
PORT=3333

View File

@ -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

View File

@ -1,16 +0,0 @@
COMPOSE_PROJECT_NAME=ghostfolio
# CACHE
REDIS_HOST=redis
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}@postgres:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>

117
.eslintrc.json Normal file
View File

@ -0,0 +1,117 @@
{
"root": true,
"ignorePatterns": ["**/*"],
"plugins": ["@nrwl/nx"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {
"@nrwl/nx/enforce-module-boundaries": [
"error",
{
"enforceBuildableLibDependency": true,
"allow": [],
"depConstraints": [
{
"sourceTag": "*",
"onlyDependOnLibsWithTags": ["*"]
}
]
}
]
}
},
{
"files": ["*.ts", "*.tsx"],
"extends": ["plugin:@nrwl/nx/typescript"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"extends": ["plugin:@nrwl/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"
}
}
]
}

1
.github/FUNDING.yml vendored
View File

@ -1 +0,0 @@
custom: ['https://www.buymeacoffee.com/ghostfolio']

View File

@ -1,53 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: '[BUG]'
labels: ''
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).
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**
<!-- A clear and concise description of what the bug is. -->
**To Reproduce**
<!-- Steps to reproduce the behavior -->
1.
2.
3.
**Expected behavior**
<!-- A clear and concise description of what you expected to happen. -->
**Screenshots**
<!-- If applicable, add screenshots to help explain your problem. -->
**Logs**
<!-- If applicable, add logs to help explain your problem. -->
**Environment**
<!-- Please complete the following information -->
- Ghostfolio Version X.Y.Z
- Cloud or Self-hosted
- Experimental Features enabled or disabled
- Browser
- OS
**Additional context**
<!-- Add any other context about the problem here. -->

View File

@ -1,69 +0,0 @@
name: Docker image CD
on:
push:
tags:
- '*.*.*'
branches:
- 'main'
pull_request:
branches:
- 'main'
workflow_dispatch:
jobs:
build_and_push:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Get Meta
id: meta
run: |
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v2
with:
registry: gitea.suda.codes
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v3
with:
context: .
# platforms: linux/amd64,linux/arm/v7,linux/arm64
platforms: linux/amd64
push: ${{ github.event_name != 'pull_request' }}
tags: |
gitea.suda.codes/sudacode/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}
gitea.suda.codes/sudacode/${{ steps.meta.outputs.REPO_NAME }}:latest
cache-from: type=local,src=${{ runner.temp }}/.buildx-cache
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

9
.gitignore vendored
View File

@ -1,14 +1,11 @@
# 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
/node_modules /node_modules
npm-debug.log
# IDEs and editors # IDEs and editors
/.idea /.idea
@ -27,15 +24,13 @@ npm-debug.log
# misc # misc
/.angular/cache /.angular/cache
.env
.env.prod
.nx/cache
.nx/workspace-data
/.sass-cache /.sass-cache
/connect.lock /connect.lock
/coverage /coverage
/dist /dist
/libpeerconnection.log /libpeerconnection.log
npm-debug.log
yarn-error.log
testem.log testem.log
/typings /typings

View File

@ -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
.nvmrc
View File

@ -1 +0,0 @@
v20

View File

@ -1,5 +1,2 @@
/.nx/cache
/.nx/workspace-data
/apps/client/src/polyfills.ts
/dist /dist
/test/import /test/import

View File

@ -1,34 +1,5 @@
{ {
"attributeGroups": [
"$ANGULAR_ELEMENT_REF",
"$ANGULAR_STRUCTURAL_DIRECTIVE",
"$DEFAULT",
"$ANGULAR_INPUT",
"$ANGULAR_TWO_WAY_BINDING",
"$ANGULAR_OUTPUT"
],
"attributeSort": "ASC",
"endOfLine": "auto", "endOfLine": "auto",
"importOrder": ["^@ghostfolio/(.*)$", "<THIRD_PARTY_MODULES>", "^[./]"],
"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,

11
.storybook/main.js Normal file
View File

@ -0,0 +1,11 @@
module.exports = {
stories: [],
addons: ['@storybook/addon-essentials']
// uncomment the property below if you want to apply some webpack config globally
// webpackFinal: async (config, { configType }) => {
// // Make whatever fine-grained changes you need that should apply to all storybook configs
// // Return the altered config
// return config;
// },
};

10
.storybook/tsconfig.json Normal file
View File

@ -0,0 +1,10 @@
{
"extends": "../tsconfig.base.json",
"exclude": [
"../**/*.spec.js",
"../**/*.spec.ts",
"../**/*.spec.tsx",
"../**/*.spec.jsx"
],
"include": ["../**/*"]
}

11
.travis.yml Normal file
View File

@ -0,0 +1,11 @@
language: node_js
git:
depth: false
node_js:
- 14
before_script:
- yarn
script:
- yarn format:check
- yarn test
- yarn build:all

25
.vscode/launch.json vendored
View File

@ -2,33 +2,32 @@
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "Debug Jest File",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/node_modules/@angular/cli/bin/ng",
"args": [ "args": [
"test", "test",
"--codeCoverage=false", "--codeCoverage=false",
"--testFile=${workspaceFolder}/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts" "--testFile=${workspaceFolder}/apps/api/src/models/portfolio.spec.ts"
], ],
"console": "internalConsole",
"cwd": "${workspaceFolder}", "cwd": "${workspaceFolder}",
"name": "Debug Jest", "console": "internalConsole"
"program": "${workspaceFolder}/node_modules/@nrwl/cli/bin/nx",
"request": "launch",
"type": "node"
}, },
{ {
"autoAttachChildProcesses": true,
"console": "integratedTerminal",
"cwd": "${workspaceFolder}/apps/api",
"envFile": "${workspaceFolder}/.env", "envFile": "${workspaceFolder}/.env",
"name": "Debug API", "type": "node",
"outFiles": ["${workspaceFolder}/dist/apps/api/**/*.js"],
"program": "${workspaceFolder}/apps/api/src/main.ts",
"request": "launch", "request": "launch",
"name": "Launch Program",
"program": "${workspaceFolder}/apps/api/src/main.ts",
"runtimeArgs": ["--nolazy", "-r", "ts-node/register"], "runtimeArgs": ["--nolazy", "-r", "ts-node/register"],
"outFiles": ["${workspaceFolder}/dist/apps/api/**/*.js"],
"autoAttachChildProcesses": true,
"skipFiles": [ "skipFiles": [
"${workspaceFolder}/node_modules/**/*.js", "${workspaceFolder}/node_modules/**/*.js",
"<node_internals>/**/*.js" "<node_internals>/**/*.js"
], ],
"type": "node" "console": "integratedTerminal"
} }
] ]
} }

View File

@ -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>",
} }

1
.yarnrc Normal file
View File

@ -0,0 +1 @@
network-timeout 600000

File diff suppressed because it is too large Load Diff

View File

@ -1,103 +0,0 @@
# 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
New functionality can be enabled using a feature flag switch from the user settings.
### Backend
Remove permission in `UserService` using `without()`
### Frontend
Use `@if (user?.settings?.isExperimentalFeatures) {}` in HTML template
## Git
### Rebase
`git rebase -i --autosquash main`
## Dependencies
### Angular
#### Upgrade (minor versions)
1. Run `npx npm-check-updates --upgrade --target "minor" --filter "/@angular.*/"`
### Nx
#### Upgrade
1. Run `npx nx migrate latest`
1. Make sure `package.json` changes make sense and then run `npm install`
1. Run `npx nx migrate --run-migrations`
### Prisma
#### Access database via GUI
Run `npm run database:gui`
https://www.prisma.io/studio
#### Synchronize schema with database for prototyping
Run `npm run database:push`
https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push
#### Create schema migration
Run `npm run prisma migrate dev --name added_job_title`
https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate

View File

@ -1,67 +1,52 @@
FROM --platform=$BUILDPLATFORM node:20-slim AS builder FROM node:14-alpine as builder
# Build application and add additional files # Build application and add additional files
WORKDIR /ghostfolio
RUN apt-get update && apt-get install -y --no-install-suggests \ WORKDIR /ghostfolio
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 ./prisma/schema.prisma prisma/schema.prisma COPY ./prisma/schema.prisma prisma/schema.prisma
RUN npm install RUN apk add --no-cache python3 g++ make openssl
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 ./angular.json angular.json
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.js jest.config.js
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:all
# 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:14-alpine
LABEL org.opencontainers.image.source="https://github.com/ghostfolio/ghostfolio" COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
ENV NODE_ENV=production
RUN apt-get update && apt-get install -y --no-install-suggests \
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 3333
USER node CMD [ "node", "main" ]
CMD [ "/ghostfolio/entrypoint.sh" ]

309
README.md
View File

@ -1,65 +1,73 @@
<div align="center"> <div align="center">
<a href="https://ghostfol.io">
<img
alt="Ghostfolio Logo"
src="https://avatars.githubusercontent.com/u/82473144?s=200"
width="100"
/>
</a>
[<img src="https://avatars.githubusercontent.com/u/82473144?s=200" width="100" alt="Ghostfolio logo">](https://ghostfol.io) <h1>Ghostfolio</h1>
<p>
# Ghostfolio <strong>Open Source Wealth Management Software made for Humans</strong>
</p>
**Open Source Wealth Management Software** <p>
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/en/blog/2021/07/hello-ghostfolio"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
[**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) | </p>
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**X**](https://x.com/ghostfolio_) <p>
<a href="#contributing">
[![Shield: Buy me a coffee](https://img.shields.io/badge/Buy%20me%20a%20coffee-Support-yellow?logo=buymeacoffee)](https://www.buymeacoffee.com/ghostfolio) <img src="https://img.shields.io/badge/contributions-welcome-orange.svg"/></a>
[![Shield: Contributions Welcome](https://img.shields.io/badge/Contributions-Welcome-limegreen.svg)](#contributing) [![Shield: Docker Pulls](https://img.shields.io/docker/pulls/ghostfolio/ghostfolio?label=Docker%20Pulls)](https://hub.docker.com/r/ghostfolio/ghostfolio) <a href="https://travis-ci.com/github/ghostfolio/ghostfolio" rel="nofollow">
[![Shield: License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-orange.svg)](https://www.gnu.org/licenses/agpl-3.0) <img src="https://travis-ci.com/ghostfolio/ghostfolio.svg?branch=main" alt="Build Status"/></a>
<a href="https://www.gnu.org/licenses/agpl-3.0" rel="nofollow">
<img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3"/></a>
</p>
</div> </div>
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. The software is designed for personal use in continuous operation. **Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of their wealth like stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions.
<div align="center"> <div align="center">
<img src="./apps/client/src/assets/images/screenshot.png" width="300">
[<img src="./apps/client/src/assets/images/video-preview.jpg" width="600" alt="Preview image of the Ghostfolio video trailer">](https://www.youtube.com/watch?v=yY6ObSQVJZk)
</div> </div>
## Ghostfolio Premium ## Ghostfolio Premium
Our official **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. Revenue is used to cover the costs of the hosting infrastructure and to fund ongoing development. Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the [Self-hosting](#self-hosting) section. If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the section [Run with Docker](#run-with-docker).
## Why Ghostfolio? ## Why Ghostfolio?
Ghostfolio is for you if you are... Ghostfolio is for you if you are...
- 💼 trading stocks, ETFs or cryptocurrencies on multiple platforms - 💼 trading stocks, ETFs or cryptocurrencies on multiple platforms
- 🏦 pursuing a buy & hold strategy - 🏦 pursuing a buy & hold strategy
- 🎯 interested in getting insights of your portfolio composition - 🎯 interested in getting insights of your portfolio composition
- 👻 valuing privacy and data ownership - 👻 valuing privacy and data ownership
- 🧘 into minimalism - 🧘 into minimalism
- 🧺 caring about diversifying your financial resources - 🧺 caring about diversifying your financial resources
- 🆓 interested in financial independence - 🆓 interested in financial independence
- 🙅 saying no to spreadsheets
- 🙅 saying no to spreadsheets in 2021
- 😎 still reading this list - 😎 still reading this list
## Features ## Features
- ✅ 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: Time-weighted rate of return (TWR) for `Today`, `YTD`, `1Y`, `5Y`, `Max`
- ✅ Various charts - ✅ Various charts
- ✅ Static analysis to identify potential risks in your portfolio - ✅ Static analysis to identify potential risks in your portfolio
- ✅ Import and export transactions
- ✅ Dark Mode - ✅ Dark Mode
- ✅ Zen Mode - ✅ Zen Mode
- ✅ Progressive Web App (PWA) with a mobile-first design - ✅ Mobile-first design
<div align="center">
<img src="./apps/client/src/assets/images/screenshot.png" width="300" alt="Image of a phone showing the Ghostfolio app open">
</div>
## Technology Stack ## Technology Stack
@ -71,241 +79,94 @@ 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 ## Run with Docker
We provide official container images hosted on [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio) for `linux/amd64`, `linux/arm/v7` and `linux/arm64`. ### Prerequisites
<div align="center"> - [Docker](https://www.docker.com/products/docker-desktop)
[<img src="./apps/client/src/assets/images/button-buy-me-a-coffee.png" width="150" alt="Buy me a coffee button"/>](https://www.buymeacoffee.com/ghostfolio) ### Setup Docker Image
</div> Run the following commands to build and start the Docker image:
### Supported Environment Variables
| Name | Type | Default Value | Description |
| ------------------------ | --------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens |
| `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key |
| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key |
| `DATABASE_URL` | `string` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on |
| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) |
| `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` |
| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database |
| `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
#### Prerequisites
- Basic knowledge of Docker
- Installation of [Docker](https://www.docker.com/products/docker-desktop)
- 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`)
#### a. Run environment
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 -f docker/docker-compose-build-local.yml build
docker-compose -f docker/docker-compose-build-local.yml up
``` ```
#### b. Build and run environment ### Setup Database
Run the following commands to build and start the Docker images: Run the following command to setup the database once Ghostfolio is running:
```bash ```bash
docker compose -f docker/docker-compose.build.yml build docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn database:setup
docker compose -f docker/docker-compose.build.yml up -d
``` ```
#### Setup ### Fetch Historical Data
1. Open http://localhost:3333 in your browser Open http://localhost:3333 in your browser and accomplish these steps:
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
#### Upgrade Version 1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
1. Click _Sign out_ and check out the _Live Demo_
1. Update the _Ghostfolio_ Docker image ### Migrate Database
- Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml` With the following command you can keep your database schema in sync after a Ghostfolio version update:
- 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
```bash docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn database:migrate
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)
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).
## Development ## Development
For detailed information on the environment setup and development process, please refer to [DEVELOPMENT.md](./DEVELOPMENT.md). ### Prerequisites
## Public API - [Docker](https://www.docker.com/products/docker-desktop)
- [Node.js](https://nodejs.org/en/download) (version 14+)
- [Yarn](https://yarnpkg.com/en/docs/install)
### Authorization: Bearer Token ### Setup
Set the header for each request as follows: 1. Run `yarn install`
1. Run `cd docker`
1. Run `docker compose up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
1. Run `cd -` to go back to the project root directory
1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data
1. Start server and client (see [_Development_](#Development))
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
1. Click _Sign out_ and check out the _Live Demo_
``` ### Start Server
"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>" }`) <ol type="a">
<li>Debug: Run <code>yarn watch:server</code> and click "Launch Program" in <a href="https://code.visualstudio.com">Visual Studio Code</a></li>
<li>Serve: Run <code>yarn start:server</code></li>
</ol>
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>`. ### Start Client
### Health Check (experimental) Run `yarn start:client`
#### Request ### Start _Storybook_
`GET http://localhost:3333/api/v1/health` Run `yarn start:storybook`
**Info:** No Bearer Token is required for health check ## Testing
#### Response Run `yarn test`
##### Success
`200 OK`
```
{
"status": "OK"
}
```
### Import Activities
#### Prerequisites
[Bearer Token](#authorization-bearer-token) for authorization
#### Request
`POST http://localhost:3333/api/v1/import`
#### Body
```
{
"activities": [
{
"currency": "USD",
"dataSource": "YAHOO",
"date": "2021-09-15T00:00:00.000Z",
"fee": 19,
"quantity": 5,
"symbol": "MSFT",
"type": "BUY",
"unitPrice": 298.58
}
]
}
```
| Field | Type | Description |
| ------------ | ------------------- | ----------------------------------------------------------------------------- |
| `accountId` | `string` (optional) | Id of the account |
| `comment` | `string` (optional) | Comment of the activity |
| `currency` | `string` | `CHF` \| `EUR` \| `USD` etc. |
| `dataSource` | `string` | `COINGECKO` \| `MANUAL` (for type `ITEM`) \| `YAHOO` |
| `date` | `string` | Date in the format `ISO-8601` |
| `fee` | `number` | Fee of the activity |
| `quantity` | `number` | Quantity of the activity |
| `symbol` | `string` | Symbol of the activity (suitable for `dataSource`) |
| `type` | `string` | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `ITEM` \| `LIABILITY` \| `SELL` |
| `unitPrice` | `number` | Price per unit of the activity |
#### Response
##### Success
`201 Created`
##### Error
`400 Bad Request`
```
{
"error": "Bad Request",
"message": [
"activities.1 is a duplicate activity"
]
}
```
### 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
Discover a variety of community projects for Ghostfolio: https://github.com/topics/ghostfolio
Are you building your own project? Add the `ghostfolio` topic to your _GitHub_ repository to get listed as well. [Learn more →](https://docs.github.com/en/articles/classifying-your-repository-with-topics)
## Contributing ## Contributing
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you. Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
Not sure what to work on? We have [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 channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg), tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you.
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
![Alt](https://repobeats.axiom.co/api/embed/281a80b2d0c4af1162866c24c803f1f18e5ed60e.svg 'Repobeats analytics image')
## License ## License
© 2021 - 2025 [Ghostfolio](https://ghostfol.io) © 2021 [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).

View File

@ -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.

323
angular.json Normal file
View File

@ -0,0 +1,323 @@
{
"version": 1,
"projects": {
"api": {
"root": "apps/api",
"sourceRoot": "apps/api/src",
"projectType": "application",
"prefix": "api",
"schematics": {},
"architect": {
"build": {
"builder": "@nrwl/node:build",
"options": {
"outputPath": "dist/apps/api",
"main": "apps/api/src/main.ts",
"tsConfig": "apps/api/tsconfig.app.json",
"assets": ["apps/api/src/assets"]
},
"configurations": {
"production": {
"generatePackageJson": true,
"optimization": true,
"extractLicenses": true,
"inspect": false,
"fileReplacements": [
{
"replace": "apps/api/src/environments/environment.ts",
"with": "apps/api/src/environments/environment.prod.ts"
}
]
}
},
"outputs": ["{options.outputPath}"]
},
"serve": {
"builder": "@nrwl/node:execute",
"options": {
"buildTarget": "api:build"
}
},
"lint": {
"builder": "@nrwl/linter:eslint",
"options": {
"lintFilePatterns": ["apps/api/**/*.ts"]
}
},
"test": {
"builder": "@nrwl/jest:jest",
"options": {
"jestConfig": "apps/api/jest.config.js",
"passWithNoTests": true
},
"outputs": ["coverage/apps/api"]
}
},
"tags": []
},
"client": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "apps/client",
"sourceRoot": "apps/client/src",
"prefix": "gf",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist/apps/client",
"index": "apps/client/src/index.html",
"main": "apps/client/src/main.ts",
"polyfills": "apps/client/src/polyfills.ts",
"tsConfig": "apps/client/tsconfig.app.json",
"assets": [
"apps/client/src/assets",
{
"glob": "assetlinks.json",
"input": "apps/client/src/assets",
"output": "./.well-known"
},
{
"glob": "CHANGELOG.md",
"input": "",
"output": "./assets"
},
{
"glob": "LICENSE",
"input": "",
"output": "./assets"
},
{
"glob": "robots.txt",
"input": "apps/client/src/assets",
"output": "./"
},
{
"glob": "sitemap.xml",
"input": "apps/client/src/assets",
"output": "./"
},
{
"glob": "**/*",
"input": "node_modules/ionicons/dist/ionicons",
"output": "./ionicons"
},
{
"glob": "**/*.js",
"input": "node_modules/ionicons/dist/",
"output": "./"
}
],
"styles": ["apps/client/src/styles.scss"],
"scripts": ["node_modules/marked/lib/marked.js"],
"vendorChunk": true,
"extractLicenses": false,
"buildOptimizer": false,
"sourceMap": true,
"optimization": false,
"namedChunks": true
},
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "apps/client/src/environments/environment.ts",
"with": "apps/client/src/environments/environment.prod.ts"
}
],
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"namedChunks": false,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true,
"budgets": [
{
"type": "initial",
"maximumWarning": "2mb",
"maximumError": "5mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "6kb",
"maximumError": "10kb"
}
]
}
},
"outputs": ["{options.outputPath}"],
"defaultConfiguration": ""
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"browserTarget": "client:build",
"proxyConfig": "apps/client/proxy.conf.json"
},
"configurations": {
"production": {
"browserTarget": "client:build:production"
}
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "client:build"
}
},
"lint": {
"builder": "@nrwl/linter:eslint",
"options": {
"lintFilePatterns": ["apps/client/**/*.ts"]
}
},
"test": {
"builder": "@nrwl/jest:jest",
"options": {
"jestConfig": "apps/client/jest.config.js",
"passWithNoTests": true
},
"outputs": ["coverage/apps/client"]
}
},
"tags": []
},
"client-e2e": {
"root": "apps/client-e2e",
"sourceRoot": "apps/client-e2e/src",
"projectType": "application",
"architect": {
"e2e": {
"builder": "@nrwl/cypress:cypress",
"options": {
"cypressConfig": "apps/client-e2e/cypress.json",
"tsConfig": "apps/client-e2e/tsconfig.e2e.json",
"devServerTarget": "client:serve"
},
"configurations": {
"production": {
"devServerTarget": "client:serve:production"
}
}
}
},
"tags": [],
"implicitDependencies": ["client"]
},
"common": {
"root": "libs/common",
"sourceRoot": "libs/common/src",
"projectType": "library",
"architect": {
"lint": {
"builder": "@nrwl/linter:eslint",
"options": {
"lintFilePatterns": ["libs/common/**/*.ts"]
}
},
"test": {
"builder": "@nrwl/jest:jest",
"outputs": ["coverage/libs/common"],
"options": {
"jestConfig": "libs/common/jest.config.js",
"passWithNoTests": true
}
}
},
"tags": []
},
"ui": {
"projectType": "library",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "libs/ui",
"sourceRoot": "libs/ui/src",
"prefix": "gf",
"architect": {
"test": {
"builder": "@nrwl/jest:jest",
"outputs": ["coverage/libs/ui"],
"options": {
"jestConfig": "libs/ui/jest.config.js",
"passWithNoTests": true
}
},
"lint": {
"builder": "@nrwl/linter:eslint",
"options": {
"lintFilePatterns": ["libs/ui/src/**/*.ts", "libs/ui/src/**/*.html"]
}
},
"storybook": {
"builder": "@nrwl/storybook:storybook",
"options": {
"uiFramework": "@storybook/angular",
"port": 4400,
"config": {
"configFolder": "libs/ui/.storybook"
}
},
"configurations": {
"ci": {
"quiet": true
}
}
},
"build-storybook": {
"builder": "@nrwl/storybook:build",
"outputs": ["{options.outputPath}"],
"options": {
"uiFramework": "@storybook/angular",
"outputPath": "dist/storybook/ui",
"config": {
"configFolder": "libs/ui/.storybook"
}
},
"configurations": {
"ci": {
"quiet": true
}
}
}
},
"tags": []
},
"ui-e2e": {
"root": "apps/ui-e2e",
"sourceRoot": "apps/ui-e2e/src",
"projectType": "application",
"architect": {
"e2e": {
"builder": "@nrwl/cypress:cypress",
"options": {
"cypressConfig": "apps/ui-e2e/cypress.json",
"devServerTarget": "ui:storybook",
"tsConfig": "apps/ui-e2e/tsconfig.json"
},
"configurations": {
"ci": {
"devServerTarget": "ui:storybook:ci"
}
}
},
"lint": {
"builder": "@nrwl/linter:eslint",
"options": {
"lintFilePatterns": ["apps/ui-e2e/**/*.{js,ts}"]
}
}
},
"tags": [],
"implicitDependencies": ["ui"]
}
}
}

22
apps/api/.eslintrc.json Normal file
View 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": {}
}
]
}

View File

@ -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: {}
}
];

16
apps/api/jest.config.js Normal file
View File

@ -0,0 +1,16 @@
module.exports = {
displayName: 'api',
preset: '../../jest.preset.js',
globals: {
'ts-jest': {
tsconfig: '<rootDir>/tsconfig.spec.json'
}
},
transform: {
'^.+\\.[tj]s$': 'ts-jest'
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../coverage/apps/api',
testTimeout: 10000,
testEnvironment: 'node'
};

View File

@ -1,18 +0,0 @@
/* eslint-disable */
export default {
displayName: 'api',
globals: {},
transform: {
'^.+\\.[tj]s$': [
'ts-jest',
{
tsconfig: '<rootDir>/tsconfig.spec.json'
}
]
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../coverage/apps/api',
testEnvironment: 'node',
preset: '../../jest.preset.js'
};

View File

@ -1,78 +0,0 @@
{
"name": "api",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/api/src",
"projectType": "application",
"prefix": "api",
"generators": {},
"targets": {
"build": {
"executor": "@nx/webpack:webpack",
"options": {
"compiler": "tsc",
"deleteOutputPath": false,
"main": "apps/api/src/main.ts",
"outputPath": "dist/apps/api",
"sourceMap": true,
"target": "node",
"tsConfig": "apps/api/tsconfig.app.json",
"webpackConfig": "apps/api/webpack.config.js"
},
"configurations": {
"production": {
"generatePackageJson": true,
"optimization": true,
"extractLicenses": true,
"inspect": false,
"fileReplacements": [
{
"replace": "apps/api/src/environments/environment.ts",
"with": "apps/api/src/environments/environment.prod.ts"
}
]
}
},
"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": {
"executor": "@nx/js:node",
"options": {
"buildTarget": "api:build"
}
},
"lint": {
"executor": "@nx/eslint:lint",
"options": {
"lintFilePatterns": ["apps/api/**/*.ts"]
}
},
"test": {
"executor": "@nx/jest:jest",
"options": {
"jestConfig": "apps/api/jest.config.ts"
},
"outputs": ["{workspaceRoot}/coverage/apps/api"]
}
},
"tags": []
}

View File

@ -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,
@ -21,6 +17,7 @@ import { AuthGuard } from '@nestjs/passport';
import { Access as AccessModel } from '@prisma/client'; import { Access as AccessModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccessModule } from './access.module';
import { AccessService } from './access.service'; import { AccessService } from './access.service';
import { CreateAccessDto } from './create-access.dto'; import { CreateAccessDto } from './create-access.dto';
@ -28,12 +25,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 +39,30 @@ 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, granteeAlias: access.GranteeUser?.alias,
id, id: access.id,
permissions, type: 'RESTRICTED_VIEW'
grantee: 'Public',
type: 'PUBLIC'
}; };
} }
);
return {
granteeAlias: '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 +70,17 @@ export class AccessController {
); );
} }
try { return this.accessService.createAccess({
return this.accessService.createAccess({ User: { connect: { id: this.request.user.id } }
alias: data.alias || undefined, });
GranteeUser: data.granteeUserId
? { connect: { id: data.granteeUserId } }
: undefined,
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<AccessModule> {
public async deleteAccess(@Param('id') id: string): Promise<AccessModel> { if (
const access = await this.accessService.access({ id }); !hasPermission(this.request.user.permissions, permissions.deleteAccess)
) {
if (!access || access.userId !== this.request.user.id) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
@ -113,7 +88,10 @@ export class AccessController {
} }
return this.accessService.deleteAccess({ return this.accessService.deleteAccess({
id id_userId: {
id,
userId: this.request.user.id
}
}); });
} }
} }

View File

@ -1,6 +1,4 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
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: [],
providers: [AccessService] providers: [AccessService, PrismaService]
}) })
export class AccessModule {} export class AccessModule {}

View File

@ -1,6 +1,5 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { AccessWithGranteeUser } from '@ghostfolio/common/types'; import { AccessWithGranteeUser } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Access, Prisma } from '@prisma/client'; import { Access, Prisma } from '@prisma/client';

View File

@ -1,16 +1 @@
import { AccessPermission } from '@prisma/client'; export class CreateAccessDto {}
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
export class CreateAccessDto {
@IsOptional()
@IsString()
alias?: string;
@IsOptional()
@IsUUID()
granteeUserId?: string;
@IsEnum(AccessPermission, { each: true })
@IsOptional()
permissions?: AccessPermission[];
}

View File

@ -1,84 +0,0 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Body,
Post,
Delete,
HttpException,
Inject,
Param,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { AccountBalance } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccountBalanceService } from './account-balance.service';
import { CreateAccountBalanceDto } from './create-account-balance.dto';
@Controller('account-balance')
export class AccountBalanceController {
public constructor(
private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService,
@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')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteAccountBalance(
@Param('id') id: string
): Promise<AccountBalance> {
const accountBalance = await this.accountBalanceService.accountBalance({
id,
userId: this.request.user.id
});
if (!accountBalance) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.accountBalanceService.deleteAccountBalance({
id: accountBalance.id,
userId: accountBalance.userId
});
}
}

View File

@ -1,16 +0,0 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common';
import { AccountBalanceController } from './account-balance.controller';
import { AccountBalanceService } from './account-balance.service';
@Module({
controllers: [AccountBalanceController],
exports: [AccountBalanceService],
imports: [ExchangeRateDataModule, PrismaModule],
providers: [AccountBalanceService, AccountService]
})
export class AccountBalanceModule {}

View File

@ -1,187 +0,0 @@
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 { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DATE_FORMAT, getSum, resetHours } from '@ghostfolio/common/helper';
import {
AccountBalancesResponse,
Filter,
HistoricalDataItem
} from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
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()
export class AccountBalanceService {
public constructor(
private readonly eventEmitter: EventEmitter2,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService
) {}
public async accountBalance(
accountBalanceWhereInput: Prisma.AccountBalanceWhereInput
): Promise<AccountBalance | null> {
return this.prismaService.accountBalance.findFirst({
include: {
Account: true
},
where: accountBalanceWhereInput
});
}
public async createOrUpdateAccountBalance({
accountId,
balance,
date,
userId
}: 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(
where: Prisma.AccountBalanceWhereUniqueInput
): Promise<AccountBalance> {
const accountBalance = await this.prismaService.accountBalance.delete({
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({
filters,
userCurrency,
userId,
withExcludedAccounts
}: {
filters?: Filter[];
userCurrency: string;
userId: string;
withExcludedAccounts?: boolean;
}): Promise<AccountBalancesResponse> {
const where: Prisma.AccountBalanceWhereInput = { userId };
const accountFilter = filters?.find(({ type }) => {
return type === 'ACCOUNT';
});
if (accountFilter) {
where.accountId = accountFilter.id;
}
if (withExcludedAccounts === false) {
where.Account = { isExcluded: false };
}
const balances = await this.prismaService.accountBalance.findMany({
where,
orderBy: {
date: 'asc'
},
select: {
Account: true,
date: true,
id: true,
value: true
}
});
return {
balances: balances.map((balance) => {
return {
...balance,
accountId: balance.Account.id,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
balance.value,
balance.Account.currency,
userCurrency
)
};
})
};
}
}

View File

@ -1,12 +0,0 @@
import { IsISO8601, IsNumber, IsUUID } from 'class-validator';
export class CreateAccountBalanceDto {
@IsUUID()
accountId: string;
@IsNumber()
balance: number;
@IsISO8601()
date: string;
}

View File

@ -1,22 +1,13 @@
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 { UserService } from '@ghostfolio/api/app/user/user.service';
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 { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { import {
AccountBalancesResponse, nullifyValuesInObject,
Accounts nullifyValuesInObjects
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/api/helper/object.helper';
import { permissions } from '@ghostfolio/common/permissions'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import type { import { Accounts } from '@ghostfolio/common/interfaces';
AccountWithValue, import { hasPermission, permissions } from '@ghostfolio/common/permissions';
RequestWithUser import type { RequestWithUser } from '@ghostfolio/common/types';
} from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
@ -28,9 +19,7 @@ import {
Param, Param,
Post, Post,
Put, Put,
Query, UseGuards
UseGuards,
UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
@ -39,24 +28,30 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccountService } from './account.service'; import { AccountService } from './account.service';
import { CreateAccountDto } from './create-account.dto'; import { CreateAccountDto } from './create-account.dto';
import { TransferBalanceDto } from './transfer-balance.dto';
import { UpdateAccountDto } from './update-account.dto'; import { UpdateAccountDto } from './update-account.dto';
@Controller('account') @Controller('account')
export class AccountController { export class AccountController {
public constructor( public constructor(
private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly 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,
private readonly userService: UserService
) {} ) {}
@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,14 +62,67 @@ 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,
userId: this.request.user.id
}
},
this.request.user.id
);
}
@Get()
@UseGuards(AuthGuard('jwt'))
public async getAllAccounts(
@Headers('impersonation-id') impersonationId
): Promise<Accounts> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
let accountsWithAggregations =
await this.portfolioService.getAccountsWithAggregations(
impersonationUserId || this.request.user.id
);
if (
impersonationUserId ||
this.userService.isRestrictedView(this.request.user)
) {
accountsWithAggregations = {
...nullifyValuesInObject(accountsWithAggregations, [
'totalBalance',
'totalValue'
]),
accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [
'balance',
'convertedBalance',
'fee',
'quantity',
'unitPrice',
'value'
])
};
}
return accountsWithAggregations;
}
@Get(':id')
@UseGuards(AuthGuard('jwt'))
public async getAccountById(@Param('id') id: string): Promise<AccountModel> {
return this.accountService.account({
id_userId: { id_userId: {
id, id,
userId: this.request.user.id userId: this.request.user.id
@ -82,69 +130,20 @@ export class AccountController {
}); });
} }
@Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getAllAccounts(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string
): Promise<Accounts> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
const filters = this.apiService.buildFiltersFromQueryParams({
filterByDataSource,
filterBySymbol
});
return this.portfolioService.getAccountsWithAggregations({
filters,
userId: impersonationUserId || this.request.user.id,
withExcludedAccounts: true
});
}
@Get(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor)
public async getAccountById(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
@Param('id') id: string
): Promise<AccountWithValue> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
const accountsWithAggregations =
await this.portfolioService.getAccountsWithAggregations({
filters: [{ id, type: 'ACCOUNT' }],
userId: impersonationUserId || this.request.user.id,
withExcludedAccounts: true
});
return accountsWithAggregations.accounts[0];
}
@Get(':id/balances')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor)
public async getAccountBalancesById(
@Param('id') id: string
): Promise<AccountBalancesResponse> {
return this.accountBalanceService.getAccountBalances({
filters: [{ id, type: 'ACCOUNT' }],
userCurrency: this.request.user.Settings.settings.baseCurrency,
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,64 +169,18 @@ export class AccountController {
} }
} }
@HasPermission(permissions.updateAccount)
@Post('transfer-balance')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async transferAccountBalance(
@Body() { accountIdFrom, accountIdTo, balance }: TransferBalanceDto
) {
const accountsOfUser = await this.accountService.getAccounts(
this.request.user.id
);
const accountFrom = accountsOfUser.find(({ id }) => {
return id === accountIdFrom;
});
const accountTo = accountsOfUser.find(({ id }) => {
return id === accountIdTo;
});
if (!accountFrom || !accountTo) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
if (accountFrom.id === accountTo.id) {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
if (accountFrom.balance < balance) {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
await this.accountService.updateAccountBalance({
accountId: accountFrom.id,
amount: -balance,
currency: accountFrom.currency,
userId: this.request.user.id
});
await this.accountService.updateAccountBalance({
accountId: accountTo.id,
amount: balance,
currency: accountFrom.currency,
userId: this.request.user.id
});
}
@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,

View File

@ -1,12 +1,11 @@
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.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AccountController } from './account.controller'; import { AccountController } from './account.controller';
@ -14,16 +13,15 @@ import { AccountService } from './account.service';
@Module({ @Module({
controllers: [AccountController], controllers: [AccountController],
exports: [AccountService],
imports: [ imports: [
AccountBalanceModule,
ApiModule,
ConfigurationModule, ConfigurationModule,
DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
ImpersonationModule, ImpersonationModule,
PortfolioModule, PortfolioModule,
PrismaModule, PrismaModule,
RedactValuesInResponseModule RedisCacheModule,
UserModule
], ],
providers: [AccountService] providers: [AccountService]
}) })

View File

@ -1,36 +1,23 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
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 { format } from 'date-fns';
import { groupBy } from 'lodash';
import { CashDetails } from './interfaces/cash-details.interface'; import { CashDetails } from './interfaces/cash-details.interface';
@Injectable() @Injectable()
export class AccountService { export class AccountService {
public constructor( public constructor(
private readonly accountBalanceService: AccountBalanceService,
private readonly eventEmitter: EventEmitter2,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService private readonly prismaService: PrismaService
) {} ) {}
public async account({ public async account(
id_userId accountWhereUniqueInput: Prisma.AccountWhereUniqueInput
}: Prisma.AccountWhereUniqueInput): Promise<Account | null> { ): Promise<Account | null> {
const [account] = await this.accounts({ return this.prismaService.account.findUnique({
where: id_userId where: accountWhereUniqueInput
}); });
return account;
} }
public async accountWithOrders( public async accountWithOrders(
@ -60,11 +47,9 @@ export class AccountService {
Platform?: Platform; Platform?: Platform;
})[] })[]
> { > {
const { include = {}, skip, take, cursor, where, orderBy } = params; const { include, skip, take, cursor, where, orderBy } = params;
include.balances = { orderBy: { date: 'desc' }, take: 1 }; return this.prismaService.account.findMany({
const accounts = await this.prismaService.account.findMany({
cursor, cursor,
include, include,
orderBy, orderBy,
@ -72,59 +57,27 @@ export class AccountService {
take, take,
where where
}); });
return accounts.map((account) => {
account = { ...account, balance: account.balances[0]?.value ?? 0 };
delete account.balances;
return account;
});
} }
public async createAccount( public async createAccount(
data: Prisma.AccountCreateInput, data: Prisma.AccountCreateInput,
aUserId: string aUserId: string
): Promise<Account> { ): Promise<Account> {
const account = await this.prismaService.account.create({ return this.prismaService.account.create({
data data
}); });
await this.accountBalanceService.createOrUpdateAccountBalance({
accountId: account.id,
balance: data.balance,
date: format(new Date(), DATE_FORMAT),
userId: aUserId
});
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: account.userId
})
);
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) {
const accounts = await this.accounts({ const accounts = await this.accounts({
include: { Order: true, Platform: true }, include: { Order: true, Platform: true },
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
@ -148,55 +101,25 @@ export class AccountService {
}); });
} }
public async getCashDetails({ public async getCashDetails(
currency, aUserId: string,
filters = [], aCurrency: string
userId, ): Promise<CashDetails> {
withExcludedAccounts = false let totalCashBalance = 0;
}: {
currency: string;
filters?: Filter[];
userId: string;
withExcludedAccounts?: boolean;
}): Promise<CashDetails> {
let totalCashBalanceInBaseCurrency = new Big(0);
const where: Prisma.AccountWhereInput = { const accounts = await this.accounts({
userId where: { userId: aUserId }
};
if (withExcludedAccounts === false) {
where.isExcluded = false;
}
const { ACCOUNT: filtersByAccount } = groupBy(filters, ({ type }) => {
return type;
}); });
if (filtersByAccount?.length > 0) { accounts.forEach((account) => {
where.id = { totalCashBalance += this.exchangeRateDataService.toCurrency(
in: filtersByAccount.map(({ id }) => { account.balance,
return id; account.currency,
}) aCurrency
};
}
const accounts = await this.accounts({ where });
for (const account of accounts) {
totalCashBalanceInBaseCurrency = totalCashBalanceInBaseCurrency.plus(
this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
currency
)
); );
} });
return { return { accounts, balance: totalCashBalance };
accounts,
balanceInBaseCurrency: totalCashBalanceInBaseCurrency.toNumber()
};
} }
public async updateAccount( public async updateAccount(
@ -207,64 +130,9 @@ export class AccountService {
aUserId: string aUserId: string
): Promise<Account> { ): Promise<Account> {
const { data, where } = params; const { data, where } = params;
return this.prismaService.account.update({
await this.accountBalanceService.createOrUpdateAccountBalance({
accountId: data.id as string,
balance: data.balance as number,
date: format(new Date(), DATE_FORMAT),
userId: aUserId
});
const account = await this.prismaService.account.update({
data, data,
where where
}); });
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: account.userId
})
);
return account;
}
public async updateAccountBalance({
accountId,
amount,
currency,
date = new Date(),
userId
}: {
accountId: string;
amount: number;
currency: string;
date?: Date;
userId: string;
}) {
const { balance, currency: currencyOfAccount } = await this.account({
id_userId: {
userId,
id: accountId
}
});
const amountInCurrencyOfAccount =
await this.exchangeRateDataService.toCurrencyAtDate(
amount,
currency,
currencyOfAccount,
date
);
if (amountInCurrencyOfAccount) {
await this.accountBalanceService.createOrUpdateAccountBalance({
accountId,
userId,
balance: new Big(balance).plus(amountInCurrencyOfAccount).toNumber(),
date: date.toISOString()
});
}
} }
} }

View File

@ -1,41 +1,20 @@
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code'; import { AccountType } from '@prisma/client';
import { IsNumber, IsString, ValidateIf } from 'class-validator';
import { Transform, TransformFnParams } from 'class-transformer';
import {
IsBoolean,
IsNumber,
IsOptional,
IsString,
ValidateIf
} from 'class-validator';
import { isString } from 'lodash';
export class CreateAccountDto { export class CreateAccountDto {
@IsString()
accountType: AccountType;
@IsNumber() @IsNumber()
balance: number; balance: number;
@IsOptional()
@IsString() @IsString()
@Transform(({ value }: TransformFnParams) =>
isString(value) ? value.trim() : value
)
comment?: string;
@IsCurrencyCode()
currency: string; currency: string;
@IsOptional()
@IsString()
id?: string;
@IsBoolean()
@IsOptional()
isExcluded?: boolean;
@IsString() @IsString()
name: string; name: string;
@IsString() @IsString()
@ValidateIf((_object, value) => value !== null) @ValidateIf((object, value) => value !== null)
platformId: string | null; platformId: string | null;
} }

View File

@ -2,5 +2,5 @@ import { Account } from '@prisma/client';
export interface CashDetails { export interface CashDetails {
accounts: Account[]; accounts: Account[];
balanceInBaseCurrency: number; balance: number;
} }

View File

@ -1,13 +0,0 @@
import { IsNumber, IsPositive, IsString } from 'class-validator';
export class TransferBalanceDto {
@IsString()
accountIdFrom: string;
@IsString()
accountIdTo: string;
@IsNumber()
@IsPositive()
balance: number;
}

View File

@ -1,40 +1,23 @@
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code'; import { AccountType } from '@prisma/client';
import { IsNumber, IsString, ValidateIf } from 'class-validator';
import { Transform, TransformFnParams } from 'class-transformer';
import {
IsBoolean,
IsNumber,
IsOptional,
IsString,
ValidateIf
} from 'class-validator';
import { isString } from 'lodash';
export class UpdateAccountDto { export class UpdateAccountDto {
@IsString()
accountType: AccountType;
@IsNumber() @IsNumber()
balance: number; balance: number;
@IsOptional()
@IsString() @IsString()
@Transform(({ value }: TransformFnParams) =>
isString(value) ? value.trim() : value
)
comment?: string;
@IsCurrencyCode()
currency: string; currency: string;
@IsString() @IsString()
id: string; id: string;
@IsBoolean()
@IsOptional()
isExcluded?: boolean;
@IsString() @IsString()
name: string; name: string;
@IsString() @IsString()
@ValidateIf((_object, value) => value !== null) @ValidateIf((object, value) => value !== null)
platformId: string | null; platformId: string | null;
} }

View File

@ -1,177 +1,142 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
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 { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.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 { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
AdminMarketDataDetails, AdminMarketDataDetails
AdminUsers,
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 { RequestWithUser } from '@ghostfolio/common/types';
MarketDataPreset,
RequestWithUser
} from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
Delete,
Get, Get,
HttpException, HttpException,
Inject, Inject,
Logger,
Param, Param,
Patch,
Post, Post,
Put, Put,
Query, UseGuards
UseGuards,
UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { DataSource, MarketData, Prisma, SymbolProfile } from '@prisma/client'; import { DataSource, MarketData } from '@prisma/client';
import { isDate, parseISO } from 'date-fns'; import { isDate, isValid } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AdminService } from './admin.service'; import { AdminService } from './admin.service';
import { UpdateAssetProfileDto } from './update-asset-profile.dto';
import { UpdateBulkMarketDataDto } from './update-bulk-market-data.dto';
import { UpdateMarketDataDto } from './update-market-data.dto';
@Controller('admin') @Controller('admin')
export class AdminController { export class AdminController {
public constructor( public constructor(
private readonly adminService: AdminService, private readonly adminService: AdminService,
private readonly apiService: ApiService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly manualService: ManualService,
private readonly marketDataService: MarketDataService,
@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')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gather7Days(): Promise<void> {
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,
await this.dataGatheringService.addJobsToQueue( permissions.accessAdminControl
assetProfileIdentifiers.map(({ dataSource, symbol }) => { )
return { ) {
data: { throw new HttpException(
dataSource, getReasonPhrase(StatusCodes.FORBIDDEN),
symbol StatusCodes.FORBIDDEN
}, );
name: GATHER_ASSET_PROFILE_PROCESS, }
opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: getAssetProfileIdentifier({ dataSource, symbol }),
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
}
};
})
);
await this.dataGatheringService.gatherProfileData();
this.dataGatheringService.gatherMax(); this.dataGatheringService.gatherMax();
return;
} }
@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
);
}
await this.dataGatheringService.addJobsToQueue( this.dataGatheringService.gatherProfileData();
assetProfileIdentifiers.map(({ dataSource, symbol }) => {
return {
data: {
dataSource,
symbol
},
name: GATHER_ASSET_PROFILE_PROCESS,
opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: getAssetProfileIdentifier({ dataSource, symbol }),
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
}
};
})
);
}
@HasPermission(permissions.accessAdminControl) return;
@Post('gather/profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherProfileDataForSymbol(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<void> {
await this.dataGatheringService.addJobToQueue({
data: {
dataSource,
symbol
},
name: GATHER_ASSET_PROFILE_PROCESS,
opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
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> {
const date = parseISO(dateString); if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const date = new Date(dateString);
if (!isDate(date)) { if (!isDate(date)) {
throw new HttpException( throw new HttpException(
@ -188,183 +153,61 @@ export class AdminController {
} }
@Get('market-data') @Get('market-data')
@HasPermission(permissions.accessAdminControl) @UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async getMarketData(): Promise<AdminMarketData> {
public async getMarketData( if (
@Query('assetSubClasses') filterByAssetSubClasses?: string, !hasPermission(
@Query('presetId') presetId?: MarketDataPreset, this.request.user.permissions,
@Query('query') filterBySearchQuery?: string, permissions.accessAdminControl
@Query('skip') skip?: number, )
@Query('sortColumn') sortColumn?: string, ) {
@Query('sortDirection') sortDirection?: Prisma.SortOrder, throw new HttpException(
@Query('take') take?: number getReasonPhrase(StatusCodes.FORBIDDEN),
): Promise<AdminMarketData> { StatusCodes.FORBIDDEN
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAssetSubClasses,
filterBySearchQuery
});
return this.adminService.getMarketData({
filters,
presetId,
sortColumn,
sortDirection,
skip: isNaN(skip) ? undefined : skip,
take: isNaN(take) ? undefined : take
});
}
/**
* @deprecated
*/
@Get('market-data/:dataSource/:symbol')
@HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getMarketDataBySymbol(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<AdminMarketDataDetails> {
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);
} }
return this.adminService.getMarketData();
} }
/** @Get('market-data/:symbol')
* @deprecated @UseGuards(AuthGuard('jwt'))
*/ public async getMarketDataBySymbol(
@HasPermission(permissions.accessAdminControl) @Param('symbol') symbol
@Post('market-data/:dataSource/:symbol') ): Promise<AdminMarketDataDetails> {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) if (
public async updateMarketData( !hasPermission(
@Body() data: UpdateBulkMarketDataDto, this.request.user.permissions,
@Param('dataSource') dataSource: DataSource, permissions.accessAdminControl
@Param('symbol') symbol: string )
) { ) {
const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map( throw new HttpException(
({ date, marketPrice }) => ({ getReasonPhrase(StatusCodes.FORBIDDEN),
dataSource, StatusCodes.FORBIDDEN
marketPrice, );
symbol, }
date: parseISO(date),
state: 'CLOSE'
})
);
return this.marketDataService.updateMany({ return this.adminService.getMarketDataBySymbol(symbol);
data: dataBulkUpdate
});
} }
/**
* @deprecated
*/
@HasPermission(permissions.accessAdminControl)
@Put('market-data/:dataSource/:symbol/:dateString')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async update(
@Param('dataSource') dataSource: DataSource,
@Param('dateString') dateString: string,
@Param('symbol') symbol: string,
@Body() data: UpdateMarketDataDto
) {
const date = parseISO(dateString);
return this.marketDataService.updateMarketData({
data: { marketPrice: data.marketPrice, state: 'CLOSE' },
where: {
dataSource_date_symbol: {
dataSource,
date,
symbol
}
}
});
}
@HasPermission(permissions.accessAdminControl)
@Post('profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async addProfileData(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<SymbolProfile | never> {
return this.adminService.addAssetProfile({
dataSource,
symbol,
currency: this.request.user.Settings.settings.baseCurrency
});
}
@Delete('profile-data/:dataSource/:symbol')
@HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteProfileData(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<void> {
return this.adminService.deleteProfileData({ dataSource, symbol });
}
@HasPermission(permissions.accessAdminControl)
@Patch('profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async patchAssetProfileData(
@Body() assetProfileData: UpdateAssetProfileDto,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<EnhancedSymbolProfile> {
return this.adminService.patchAssetProfileData({
...assetProfileData,
dataSource,
symbol
});
}
@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
});
} }
} }

View File

@ -1,39 +1,26 @@
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 { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/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 { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller'; import { AdminController } from './admin.controller';
import { AdminService } from './admin.service'; import { AdminService } from './admin.service';
import { QueueModule } from './queue/queue.module';
@Module({ @Module({
imports: [ imports: [
ApiModule,
BenchmarkModule,
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
MarketDataModule, MarketDataModule,
OrderModule,
PrismaModule, PrismaModule,
PropertyModule, PropertyModule,
QueueModule, SubscriptionModule
SubscriptionModule,
SymbolProfileModule,
TransformDataSourceInRequestModule
], ],
controllers: [AdminController], controllers: [AdminController],
providers: [AdminService], providers: [AdminService],

View File

@ -1,776 +1,170 @@
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 { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config';
import {
DEFAULT_CURRENCY,
PROPERTY_CURRENCIES,
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_IS_USER_SIGNUP_ENABLED
} from '@ghostfolio/common/config';
import {
getAssetProfileIdentifier,
getCurrencyFromSymbol,
isCurrency
} from '@ghostfolio/common/helper';
import { import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
AdminMarketDataDetails, AdminMarketDataDetails
AdminMarketDataItem,
AdminUsers,
AssetProfileIdentifier,
EnhancedSymbolProfile,
Filter
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { Injectable } from '@nestjs/common';
import { MarketDataPreset } from '@ghostfolio/common/types'; import { Property } from '@prisma/client';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import {
AssetClass,
AssetSubClass,
DataSource,
Prisma,
PrismaClient,
Property,
SymbolProfile
} from '@prisma/client';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
import { groupBy } from 'lodash';
@Injectable() @Injectable()
export class AdminService { export class AdminService {
public constructor( public constructor(
private readonly benchmarkService: BenchmarkService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService, private readonly dataGatheringService: DataGatheringService,
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
private readonly symbolProfileService: SymbolProfileService
) {} ) {}
public async addAssetProfile({
currency,
dataSource,
symbol
}: AssetProfileIdentifier & { currency?: string }): Promise<
SymbolProfile | never
> {
try {
if (dataSource === 'MANUAL') {
return this.symbolProfileService.add({
currency,
dataSource,
symbol
});
}
const assetProfiles = await this.dataProviderService.getAssetProfiles([
{ dataSource, symbol }
]);
if (!assetProfiles[symbol]?.currency) {
throw new BadRequestException(
`Asset profile not found for ${symbol} (${dataSource})`
);
}
return this.symbolProfileService.add(
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
);
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
throw new BadRequestException(
`Asset profile of ${symbol} (${dataSource}) already exists`
);
}
throw error;
}
}
public async deleteProfileData({
dataSource,
symbol
}: AssetProfileIdentifier) {
await this.marketDataService.deleteMany({ 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, dataGatheringProgress:
settings, await this.dataGatheringService.getDataGatheringProgress(),
transactionCount, exchangeRates: this.exchangeRateDataService
userCount, .getCurrencies()
version: environment.version .filter((currency) => {
return currency !== baseCurrency;
})
.map((currency) => {
return {
label1: baseCurrency,
label2: currency,
value: this.exchangeRateDataService.toCurrency(
1,
baseCurrency,
currency
)
};
}),
lastDataGathering: await this.getLastDataGathering(),
settings: await this.propertyService.get(),
transactionCount: await this.prismaService.order.count(),
userCount: await this.prismaService.user.count(),
users: await this.getUsersWithAnalytics()
}; };
} }
public async getMarketData({ public async getMarketData(): Promise<AdminMarketData> {
filters, return {
presetId, marketData: await (
sortColumn, await this.dataGatheringService.getSymbolsMax()
sortDirection, ).map((symbol) => {
skip, return symbol;
take = Number.MAX_SAFE_INTEGER })
}: { };
filters?: Filter[];
presetId?: MarketDataPreset;
skip?: number;
sortColumn?: string;
sortDirection?: Prisma.SortOrder;
take?: number;
}): Promise<AdminMarketData> {
let orderBy: Prisma.Enumerable<Prisma.SymbolProfileOrderByWithRelationInput> =
[{ symbol: 'asc' }];
const where: Prisma.SymbolProfileWhereInput = {};
if (presetId === 'BENCHMARKS') {
const benchmarkAssetProfiles =
await this.benchmarkService.getBenchmarkAssetProfiles();
where.id = {
in: benchmarkAssetProfiles.map(({ id }) => {
return id;
})
};
} else if (presetId === 'CURRENCIES') {
return this.getMarketDataForCurrencies();
} else if (
presetId === 'ETF_WITHOUT_COUNTRIES' ||
presetId === 'ETF_WITHOUT_SECTORS'
) {
filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }];
}
const searchQuery = filters.find(({ type }) => {
return type === 'SEARCH_QUERY';
})?.id;
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
filters,
({ type }) => {
return type;
}
);
const marketDataItems = await this.prismaService.marketData.groupBy({
_count: true,
by: ['dataSource', 'symbol']
});
if (filtersByAssetSubClass) {
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
}
if (searchQuery) {
where.OR = [
{ id: { mode: 'insensitive', startsWith: searchQuery } },
{ isin: { mode: 'insensitive', startsWith: searchQuery } },
{ name: { mode: 'insensitive', startsWith: searchQuery } },
{ symbol: { mode: 'insensitive', startsWith: searchQuery } }
];
}
if (sortColumn) {
orderBy = [{ [sortColumn]: sortDirection }];
if (sortColumn === 'activitiesCount') {
orderBy = {
Order: {
_count: sortDirection
}
};
}
}
const extendedPrismaClient = this.getExtendedPrismaClient();
try {
const symbolProfileResult = await Promise.all([
extendedPrismaClient.symbolProfile.findMany({
orderBy,
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: {
dataSource: true,
marketPrice: true,
symbol: true
},
where: {
dataSource: {
in: assetProfiles.map(({ dataSource }) => {
return dataSource;
})
},
symbol: {
in: assetProfiles.map(({ symbol }) => {
return symbol;
})
}
}
});
const lastMarketPriceMap = new Map<string, number>();
for (const { dataSource, marketPrice, symbol } of lastMarketPrices) {
lastMarketPriceMap.set(
getAssetProfileIdentifier({ dataSource, symbol }),
marketPrice
);
}
let marketData: AdminMarketDataItem[] = await Promise.all(
assetProfiles.map(
async ({
_count,
assetClass,
assetSubClass,
comment,
countries,
currency,
dataSource,
id,
isUsedByUsersWithSubscription,
name,
Order,
sectors,
symbol,
SymbolProfileOverrides
}) => {
let countriesCount = countries ? Object.keys(countries).length : 0;
const lastMarketPrice = lastMarketPriceMap.get(
getAssetProfileIdentifier({ dataSource, symbol })
);
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
let sectorsCount = sectors ? Object.keys(sectors).length : 0;
if (SymbolProfileOverrides) {
assetClass = SymbolProfileOverrides.assetClass ?? assetClass;
assetSubClass =
SymbolProfileOverrides.assetSubClass ?? assetSubClass;
if (
(
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
};
} finally {
await extendedPrismaClient.$disconnect();
Logger.debug('Disconnect extended prisma client', 'AdminService');
}
} }
public async getMarketDataBySymbol({ public async getMarketDataBySymbol(
dataSource, aSymbol: string
symbol ): Promise<AdminMarketDataDetails> {
}: AssetProfileIdentifier): Promise<AdminMarketDataDetails> { return {
let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0; marketData: await this.marketDataService.marketDataItems({
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([
this.symbolProfileService.getSymbolProfiles([
{
dataSource,
symbol
}
]),
this.marketDataService.marketDataItems({
orderBy: { orderBy: {
date: 'asc' date: 'asc'
}, },
where: { where: {
dataSource, symbol: aSymbol
symbol
} }
}) })
]);
if (assetProfile) {
assetProfile.dataProviderInfo = this.dataProviderService
.getDataProvider(assetProfile.dataSource)
.getDataProviderInfo();
}
return {
marketData,
assetProfile: assetProfile ?? {
activitiesCount,
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({
assetClass,
assetSubClass,
comment,
countries,
currency,
dataSource,
holdings,
name,
scraperConfiguration,
sectors,
symbol,
symbolMapping,
url
}: AssetProfileIdentifier & Prisma.SymbolProfileUpdateInput) {
const symbolProfileOverrides = {
assetClass: assetClass as AssetClass,
assetSubClass: assetSubClass as AssetSubClass,
name: name as string,
url: url as string
};
const updatedSymbolProfile: AssetProfileIdentifier &
Prisma.SymbolProfileUpdateInput = {
comment,
countries,
currency,
dataSource,
holdings,
scraperConfiguration,
sectors,
symbol,
symbolMapping,
...(dataSource === 'MANUAL'
? { assetClass, assetSubClass, name, url }
: {
SymbolProfileOverrides: {
upsert: {
create: symbolProfileOverrides,
update: symbolProfileOverrides
}
}
})
};
await this.symbolProfileService.updateSymbolProfile(updatedSymbolProfile);
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
{
dataSource,
symbol
}
]);
return symbolProfile;
}
public async putSetting(key: string, value: string) { public async putSetting(key: string, value: string) {
let response: Property; let response: Property;
if (value) { if (value === '') {
response = await this.propertyService.put({ key, value });
} else {
response = await this.propertyService.delete({ key }); response = await this.propertyService.delete({ key });
} else {
response = await this.propertyService.put({ key, value });
} }
if (key === PROPERTY_IS_READ_ONLY_MODE && value === 'true') { if (key === PROPERTY_CURRENCIES) {
await this.putSetting(PROPERTY_IS_USER_SIGNUP_ENABLED, 'false');
} else if (key === PROPERTY_CURRENCIES) {
await this.exchangeRateDataService.initialize(); await this.exchangeRateDataService.initialize();
await this.dataGatheringService.reset();
} }
return response; return response;
} }
private async countUsersWithAnalytics() { private async getLastDataGathering() {
let where: Prisma.UserWhereInput; const lastDataGathering =
await this.dataGatheringService.getLastDataGathering();
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (lastDataGathering) {
where = { return lastDataGathering;
NOT: {
Analytics: null
}
};
} }
return this.prismaService.user.count({ const dataGatheringInProgress =
where await this.dataGatheringService.getIsInProgress();
});
}
private getExtendedPrismaClient() { if (dataGatheringInProgress) {
Logger.debug('Connect extended prisma client', 'AdminService'); return 'IN_PROGRESS';
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> {
const currencyPairs = this.exchangeRateDataService.getCurrencyPairs();
const [lastMarketPrices, marketDataItems] = await Promise.all([
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( return undefined;
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 marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
return {
activitiesCount,
currency,
dataSource,
lastMarketPrice,
marketDataItemCount,
symbol,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
countriesCount: 0,
date: dateOfFirstActivity,
id: undefined,
name: symbol,
sectorsCount: 0
};
}
);
const marketData = await Promise.all(marketDataPromise);
return { marketData, count: marketData.length };
} }
private async getUsersWithAnalytics({ private async getUsersWithAnalytics(): Promise<AdminData['users']> {
skip,
take
}: {
skip?: number;
take?: number;
}): Promise<AdminUsers['users']> {
let orderBy: Prisma.UserOrderByWithRelationInput = {
createdAt: 'desc'
};
let where: Prisma.UserWhereInput;
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
orderBy = {
Analytics: {
lastRequestAt: 'desc'
}
};
where = {
NOT: {
Analytics: null
}
};
}
const usersWithAnalytics = await this.prismaService.user.findMany({ const usersWithAnalytics = await this.prismaService.user.findMany({
orderBy, orderBy: {
skip, Analytics: {
take, updatedAt: 'desc'
where, }
},
select: { select: {
_count: { _count: {
select: { Account: true, Order: true } select: { Account: true, Order: true }
}, },
alias: true,
Analytics: { Analytics: {
select: { select: {
activityCount: true, activityCount: 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,
where: {
NOT: {
Analytics: null
}
} }
}); });
return usersWithAnalytics.map( return usersWithAnalytics.map(
({ _count, Analytics, createdAt, id, role, Subscription }) => { ({ _count, alias, Analytics, createdAt, id, Subscription }) => {
const daysSinceRegistration = const daysSinceRegistration =
differenceInDays(new Date(), createdAt) + 1; differenceInDays(new Date(), createdAt) + 1;
const engagement = Analytics const engagement = Analytics.activityCount / daysSinceRegistration;
? Analytics.activityCount / daysSinceRegistration
: undefined;
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 {
alias,
createdAt, createdAt,
engagement, engagement,
id, id,
role,
subscription, subscription,
accountCount: _count.Account || 0, accountCount: _count.Account || 0,
country: Analytics?.country, lastActivity: Analytics.updatedAt,
dailyApiRequests: Analytics?.dataProviderGhostfolioDailyRequests || 0,
lastActivity: Analytics?.updatedAt,
transactionCount: _count.Order || 0 transactionCount: _count.Order || 0
}; };
} }

View File

@ -1,56 +0,0 @@
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 { permissions } from '@ghostfolio/common/permissions';
import {
Controller,
Delete,
Get,
Param,
Query,
UseGuards
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { JobStatus } from 'bull';
import { QueueService } from './queue.service';
@Controller('admin/queue')
export class QueueController {
public constructor(private readonly queueService: QueueService) {}
@Delete('job')
@HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteJobs(
@Query('status') filterByStatus?: string
): Promise<void> {
const status = (filterByStatus?.split(',') as JobStatus[]) ?? undefined;
return this.queueService.deleteJobs({ status });
}
@Get('job')
@HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getJobs(
@Query('status') filterByStatus?: string
): Promise<AdminJobs> {
const status = (filterByStatus?.split(',') as JobStatus[]) ?? undefined;
return this.queueService.getJobs({ status });
}
@Delete('job/:id')
@HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteJob(@Param('id') id: string): Promise<void> {
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);
}
}

View File

@ -1,14 +0,0 @@
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 { Module } from '@nestjs/common';
import { QueueController } from './queue.controller';
import { QueueService } from './queue.service';
@Module({
controllers: [QueueController],
imports: [DataGatheringModule, PortfolioSnapshotQueueModule],
providers: [QueueService]
})
export class QueueModule {}

View File

@ -1,91 +0,0 @@
import {
DATA_GATHERING_QUEUE,
PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE,
QUEUE_JOB_STATUS_LIST
} from '@ghostfolio/common/config';
import { AdminJobs } from '@ghostfolio/common/interfaces';
import { InjectQueue } from '@nestjs/bull';
import { Injectable } from '@nestjs/common';
import { JobStatus, Queue } from 'bull';
@Injectable()
export class QueueService {
public constructor(
@InjectQueue(DATA_GATHERING_QUEUE)
private readonly dataGatheringQueue: Queue,
@InjectQueue(PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE)
private readonly portfolioSnapshotQueue: Queue
) {}
public async deleteJob(aId: string) {
let job = await this.dataGatheringQueue.getJob(aId);
if (!job) {
job = await this.portfolioSnapshotQueue.getJob(aId);
}
return job?.remove();
}
public async deleteJobs({
status = QUEUE_JOB_STATUS_LIST
}: {
status?: JobStatus[];
}) {
for (const statusItem of status) {
const queueStatus = statusItem === 'waiting' ? 'wait' : statusItem;
await this.dataGatheringQueue.clean(300, queueStatus);
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({
limit = 1000,
status = QUEUE_JOB_STATUS_LIST
}: {
limit?: number;
status?: JobStatus[];
}): Promise<AdminJobs> {
const [dataGatheringJobs, portfolioSnapshotJobs] = await Promise.all([
this.dataGatheringQueue.getJobs(status),
this.portfolioSnapshotQueue.getJobs(status)
]);
const jobsWithState = await Promise.all(
[...dataGatheringJobs, ...portfolioSnapshotJobs]
.filter((job) => {
return job;
})
.slice(0, limit)
.map(async (job) => {
return {
attemptsMade: job.attemptsMade + 1,
data: job.data,
finishedOn: job.finishedOn,
id: job.id,
name: job.name,
opts: job.opts,
stacktrace: job.stacktrace,
state: await job.getState(),
timestamp: job.timestamp
};
})
);
return {
jobs: jobsWithState
};
}
}

View File

@ -1,58 +0,0 @@
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
import { AssetClass, AssetSubClass, Prisma } from '@prisma/client';
import {
IsArray,
IsEnum,
IsObject,
IsOptional,
IsString,
IsUrl
} from 'class-validator';
export class UpdateAssetProfileDto {
@IsEnum(AssetClass, { each: true })
@IsOptional()
assetClass?: AssetClass;
@IsEnum(AssetSubClass, { each: true })
@IsOptional()
assetSubClass?: AssetSubClass;
@IsString()
@IsOptional()
comment?: string;
@IsArray()
@IsOptional()
countries?: Prisma.InputJsonArray;
@IsCurrencyCode()
@IsOptional()
currency?: string;
@IsString()
@IsOptional()
name?: string;
@IsObject()
@IsOptional()
scraperConfiguration?: Prisma.InputJsonObject;
@IsArray()
@IsOptional()
sectors?: Prisma.InputJsonArray;
@IsObject()
@IsOptional()
symbolMapping?: {
[dataProvider: string]: string;
};
@IsOptional()
@IsUrl({
protocols: ['https'],
require_protocol: true
})
url?: string;
}

View File

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

View File

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

View File

@ -1,18 +1,26 @@
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { Controller } from '@nestjs/common'; import { Controller } from '@nestjs/common';
import { RedisCacheService } from './redis-cache/redis-cache.service';
@Controller() @Controller()
export class AppController { export class AppController {
public constructor( public constructor(
private readonly exchangeRateDataService: ExchangeRateDataService private readonly dataGatheringService: DataGatheringService,
private readonly redisCacheService: RedisCacheService
) { ) {
this.initialize(); this.initialize();
} }
private async initialize() { private async initialize() {
try { this.redisCacheService.reset();
await this.exchangeRateDataService.initialize();
} catch {} const isDataGatheringInProgress =
await this.dataGatheringService.getIsInProgress();
if (isDataGatheringInProgress) {
// Prepare for automatical data gathering, if hung up in progress state
await this.dataGatheringService.reset();
}
} }
} }

View File

@ -1,133 +1,73 @@
import { EventsModule } from '@ghostfolio/api/events/events.module'; import { join } from 'path';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { CronService } from '@ghostfolio/api/services/cron.service';
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 { 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 {
DEFAULT_LANGUAGE_CODE,
SUPPORTED_LANGUAGE_CODES
} from '@ghostfolio/common/config';
import { BullModule } from '@nestjs/bull'; import { AuthDeviceModule } from '@ghostfolio/api/app/auth-device/auth-device.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { CronService } from '@ghostfolio/api/services/cron.service';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { 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 { 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 { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.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 { ExportModule } from './export/export.module'; import { ExportModule } from './export/export.module';
import { HealthModule } from './health/health.module';
import { ImportModule } from './import/import.module'; import { ImportModule } from './import/import.module';
import { InfoModule } from './info/info.module'; import { InfoModule } from './info/info.module';
import { LogoModule } from './logo/logo.module';
import { OrderModule } from './order/order.module'; import { OrderModule } from './order/order.module';
import { PlatformModule } from './platform/platform.module';
import { PortfolioModule } from './portfolio/portfolio.module'; import { PortfolioModule } from './portfolio/portfolio.module';
import { RedisCacheModule } from './redis-cache/redis-cache.module';
import { 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 { 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,
BullModule.forRoot({
redis: {
db: parseInt(process.env.REDIS_DB ?? '0', 10),
host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT ?? '6379', 10),
password: process.env.REDIS_PASSWORD
}
}),
CacheModule, CacheModule,
ConfigModule.forRoot(), ConfigModule.forRoot(),
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
EventEmitterModule.forRoot(),
EventsModule,
ExchangeRateModule,
ExchangeRateDataModule, ExchangeRateDataModule,
ExportModule, ExportModule,
GhostfolioModule,
HealthModule,
ImportModule, ImportModule,
InfoModule, InfoModule,
LogoModule,
MarketDataModule,
OrderModule, OrderModule,
PlatformModule,
PortfolioModule, PortfolioModule,
PortfolioSnapshotQueueModule,
PrismaModule, PrismaModule,
PropertyModule,
PublicModule,
RedisCacheModule, RedisCacheModule,
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
ServeStaticModule.forRoot({ ServeStaticModule.forRoot({
exclude: ['/api*', '/sitemap.xml'],
rootPath: join(__dirname, '..', 'client'),
serveStaticOptions: { serveStaticOptions: {
setHeaders: (res) => { /*etag: false // Disable etag header to fix PWA
if (res.req?.path === '/') { setHeaders: (res, path) => {
let languageCode = DEFAULT_LANGUAGE_CODE; if (path.includes('ngsw.json')) {
// Disable cache (https://stackoverflow.com/questions/22632593/how-to-disable-webpage-caching-in-expressjs-nodejs/39775595)
try { // https://gertjans.home.xs4all.nl/javascript/cache-control.html#no-cache
const code = res.req.headers['accept-language'] res.set('Cache-Control', 'no-cache, no-store, must-revalidate');
.split(',')[0]
.split('-')[0];
if (SUPPORTED_LANGUAGE_CODES.includes(code)) {
languageCode = code;
}
} catch {}
res.set('Location', `/${languageCode}`);
res.statusCode = StatusCodes.MOVED_PERMANENTLY;
} }
} }*/
} },
rootPath: join(__dirname, '..', 'client'),
exclude: ['/api*']
}), }),
SitemapModule,
SubscriptionModule, SubscriptionModule,
SymbolModule, SymbolModule,
TagsModule,
TwitterBotModule,
UserModule UserModule
], ],
controllers: [AppController],
providers: [CronService] providers: [CronService]
}) })
export class AppModule {} export class AppModule {}

View File

@ -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'])
};
}
}

View File

@ -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 {}

View File

@ -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 });
} }
} }

View File

@ -1,7 +1,7 @@
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 { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
@ -11,9 +11,8 @@ import { JwtModule } from '@nestjs/jwt';
JwtModule.register({ JwtModule.register({
secret: process.env.JWT_SECRET_KEY, secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '180 days' } signOptions: { expiresIn: '180 days' }
}), })
PrismaModule
], ],
providers: [AuthDeviceService] providers: [AuthDeviceService, ConfigurationService, PrismaService]
}) })
export class AuthDeviceModule {} export class AuthDeviceModule {}

View File

@ -1,11 +1,14 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/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

View File

@ -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
);
}
}
}

View File

@ -1,9 +1,5 @@
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { OAuthResponse } from '@ghostfolio/common/interfaces';
import { import {
Body, Body,
Controller, Controller,
@ -13,13 +9,10 @@ import {
Post, Post,
Req, Req,
Res, Res,
UseGuards, UseGuards
Version,
VERSION_NEUTRAL
} from '@nestjs/common'; } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Request, Response } from 'express'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { import {
@ -35,32 +28,11 @@ export class AuthController {
private readonly webAuthService: WebAuthService private readonly webAuthService: WebAuthService
) {} ) {}
/**
* @deprecated
*/
@Get('anonymous/:accessToken') @Get('anonymous/:accessToken')
public async accessTokenLoginGet( public async accessTokenLogin(@Param('accessToken') accessToken: string) {
@Param('accessToken') accessToken: string
): Promise<OAuthResponse> {
try {
const authToken =
await this.authService.validateAnonymousLogin(accessToken);
return { authToken };
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
}
@Post('anonymous')
public async accessTokenLogin(
@Body() body: { accessToken: string }
): Promise<OAuthResponse> {
try { try {
const authToken = await this.authService.validateAnonymousLogin( const authToken = await this.authService.validateAnonymousLogin(
body.accessToken accessToken
); );
return { authToken }; return { authToken };
} catch { } catch {
@ -79,58 +51,32 @@ export class AuthController {
@Get('google/callback') @Get('google/callback')
@UseGuards(AuthGuard('google')) @UseGuards(AuthGuard('google'))
@Version(VERSION_NEUTRAL) public googleLoginCallback(@Req() req, @Res() res) {
public googleLoginCallback(
@Req() request: Request,
@Res() response: Response
) {
// Handles the Google OAuth2 callback // Handles the Google OAuth2 callback
const jwt: string = (request.user as any).jwt; const jwt: string = req.user.jwt;
if (jwt) { if (jwt) {
response.redirect( res.redirect(`${this.configurationService.get('ROOT_URL')}/auth/${jwt}`);
`${this.configurationService.get(
'ROOT_URL'
)}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}`
);
} else { } else {
response.redirect( res.redirect(`${this.configurationService.get('ROOT_URL')}/auth`);
`${this.configurationService.get(
'ROOT_URL'
)}/${DEFAULT_LANGUAGE_CODE}/auth`
);
}
}
@Post('internet-identity')
public async internetIdentityLogin(
@Body() body: { principalId: string }
): Promise<OAuthResponse> {
try {
const authToken = await this.authService.validateInternetIdentityLogin(
body.principalId
);
return { authToken };
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
} }
} }
@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')

View File

@ -2,15 +2,11 @@ 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 { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.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';
@ -19,23 +15,20 @@ import { JwtStrategy } from './jwt.strategy';
@Module({ @Module({
controllers: [AuthController], controllers: [AuthController],
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' }
}), }),
PrismaModule,
PropertyModule,
SubscriptionModule, SubscriptionModule,
UserModule UserModule
], ],
providers: [ providers: [
ApiKeyService,
ApiKeyStrategy,
AuthDeviceService, AuthDeviceService,
AuthService, AuthService,
ConfigurationService,
GoogleStrategy, GoogleStrategy,
JwtStrategy, JwtStrategy,
PrismaService,
WebAuthService WebAuthService
] ]
}) })

View File

@ -1,10 +1,7 @@
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.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 { ValidateOAuthLoginParams } from './interfaces/interfaces'; import { ValidateOAuthLoginParams } from './interfaces/interfaces';
@ -13,11 +10,10 @@ export class AuthService {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly propertyService: PropertyService,
private readonly userService: UserService private readonly userService: UserService
) {} ) {}
public async validateAnonymousLogin(accessToken: string): Promise<string> { public async validateAnonymousLogin(accessToken: string) {
return new Promise(async (resolve, reject) => { return new Promise(async (resolve, reject) => {
try { try {
const hashedAccessToken = this.userService.createAccessToken( const hashedAccessToken = this.userService.createAccessToken(
@ -30,7 +26,7 @@ export class AuthService {
}); });
if (user) { if (user) {
const jwt = this.jwtService.sign({ const jwt: string = this.jwtService.sign({
id: user.id id: user.id
}); });
@ -44,42 +40,6 @@ export class AuthService {
}); });
} }
public async validateInternetIdentityLogin(principalId: string) {
try {
const provider: Provider = 'INTERNET_IDENTITY';
let [user] = await this.userService.users({
where: { provider, thirdPartyId: principalId }
});
if (!user) {
const isUserSignupEnabled =
await this.propertyService.isUserSignupEnabled();
if (!isUserSignupEnabled || true) {
throw new Error('Sign up forbidden');
}
// Create new user if not found
user = await this.userService.createUser({
data: {
provider,
thirdPartyId: principalId
}
});
}
return this.jwtService.sign({
id: user.id
});
} catch (error) {
throw new InternalServerErrorException(
'validateInternetIdentityLogin',
error.message
);
}
}
public async validateOAuthLogin({ public async validateOAuthLogin({
provider, provider,
thirdPartyId thirdPartyId
@ -90,30 +50,20 @@ export class AuthService {
}); });
if (!user) { if (!user) {
const isUserSignupEnabled =
await this.propertyService.isUserSignupEnabled();
if (!isUserSignupEnabled) {
throw new Error('Sign up forbidden');
}
// Create new user if not found // Create new user if not found
user = await this.userService.createUser({ user = await this.userService.createUser({
data: { provider,
provider, thirdPartyId
thirdPartyId
}
}); });
} }
return this.jwtService.sign({ const jwt: string = this.jwtService.sign({
id: user.id id: user.id
}); });
} catch (error) {
throw new InternalServerErrorException( return jwt;
'validateOAuthLogin', } catch (err) {
error.message throw new InternalServerErrorException('validateOAuthLogin', err.message);
);
} }
} }
} }

View File

@ -1,9 +1,8 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/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,26 +19,30 @@ 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);
done(error, false); done(error, false);
} }
} }

View File

@ -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 {

View File

@ -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
*/ */

View File

@ -1,73 +1,40 @@
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.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config'; import { Injectable, UnauthorizedException } from '@nestjs/common';
import { hasRole } from '@ghostfolio/common/permissions';
import { HttpException, Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
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()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly userService: UserService private readonly userService: UserService
) { ) {
super({ super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
passReqToCallback: true,
secretOrKey: configurationService.get('JWT_SECRET_KEY') secretOrKey: configurationService.get('JWT_SECRET_KEY')
}); });
} }
public async validate(request: Request, { id }: { id: string }) { public async validate({ id }: { id: string }) {
try { try {
const timezone = request.headers[HEADER_KEY_TIMEZONE.toLowerCase()];
const user = await this.userService.user({ id }); const user = await this.userService.user({ id });
if (user) { if (user) {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { await this.prismaService.analytics.upsert({
if (hasRole(user, 'INACTIVE')) { create: { User: { connect: { id: user.id } } },
throw new HttpException( update: { activityCount: { increment: 1 }, updatedAt: new Date() },
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), where: { userId: user.id }
StatusCodes.TOO_MANY_REQUESTS });
);
}
const country =
countriesAndTimezones.getCountryForTimezone(timezone)?.id;
await this.prismaService.analytics.upsert({
create: { country, User: { connect: { id: user.id } } },
update: {
country,
activityCount: { increment: 1 },
lastRequestAt: new Date()
},
where: { userId: user.id }
});
}
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);
} }
} }
} }

View File

@ -1,9 +1,8 @@
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto'; import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/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() {
@ -55,7 +54,7 @@ export class WebAuthService {
rpName: 'Ghostfolio', rpName: 'Ghostfolio',
rpID: this.rpID, rpID: this.rpID,
userID: user.id, userID: user.id,
userName: '', userName: user.alias,
timeout: 60000, timeout: 60000,
attestationType: 'indirect', attestationType: 'indirect',
authenticatorSelection: { authenticatorSelection: {
@ -65,7 +64,7 @@ export class WebAuthService {
} }
}; };
const options = await generateRegistrationOptions(opts); const options = generateRegistrationOptions(opts);
await this.userService.updateUser({ await this.userService.updateUser({
data: { data: {
@ -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;
@ -88,20 +88,14 @@ export class WebAuthService {
let verification: VerifiedRegistrationResponse; let verification: VerifiedRegistrationResponse;
try { try {
const opts: VerifyRegistrationResponseOpts = { const opts: VerifyRegistrationResponseOpts = {
credential,
expectedChallenge, expectedChallenge,
expectedOrigin: this.expectedOrigin, expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID, expectedRPID: this.rpID
response: {
clientExtensionResults: credential.clientExtensionResults,
id: credential.id,
rawId: credential.rawId,
response: credential.response,
type: 'public-key'
}
}; };
verification = await verifyRegistrationResponse(opts); verification = await verifyRegistrationResponse(opts);
} catch (error) { } catch (error) {
Logger.error(error, 'WebAuthService'); Logger.error(error);
throw new InternalServerErrorException(error.message); throw new InternalServerErrorException(error.message);
} }
@ -123,8 +117,8 @@ export class WebAuthService {
*/ */
existingDevice = await this.deviceService.createAuthDevice({ existingDevice = await this.deviceService.createAuthDevice({
counter, counter,
credentialId: Buffer.from(credentialID), credentialPublicKey,
credentialPublicKey: Buffer.from(credentialPublicKey), credentialId: credentialID,
User: { connect: { id: user.id } } User: { connect: { id: user.id } }
}); });
} }
@ -158,7 +152,7 @@ export class WebAuthService {
userVerification: 'preferred' userVerification: 'preferred'
}; };
const options = await generateAuthenticationOptions(opts); const options = generateAuthenticationOptions(opts);
await this.userService.updateUser({ await this.userService.updateUser({
data: { data: {
@ -187,6 +181,7 @@ export class WebAuthService {
let verification: VerifiedAuthenticationResponse; let verification: VerifiedAuthenticationResponse;
try { try {
const opts: VerifyAuthenticationResponseOpts = { const opts: VerifyAuthenticationResponseOpts = {
credential,
authenticator: { authenticator: {
credentialID: device.credentialId, credentialID: device.credentialId,
credentialPublicKey: device.credentialPublicKey, credentialPublicKey: device.credentialPublicKey,
@ -194,18 +189,11 @@ export class WebAuthService {
}, },
expectedChallenge: `${user.authChallenge}`, expectedChallenge: `${user.authChallenge}`,
expectedOrigin: this.expectedOrigin, expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID, expectedRPID: this.rpID
response: {
clientExtensionResults: credential.clientExtensionResults,
id: credential.id,
rawId: credential.rawId,
response: credential.response,
type: 'public-key'
}
}; };
verification = await verifyAuthenticationResponse(opts); verification = verifyAuthenticationResponse(opts);
} catch (error) { } catch (error) {
Logger.error(error, 'WebAuthService'); Logger.error(error);
throw new InternalServerErrorException({ error: error.message }); throw new InternalServerErrorException({ error: error.message });
} }

View File

@ -1,19 +1,25 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
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 type { RequestWithUser } from '@ghostfolio/common/types';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { Controller, Inject, Post, UseGuards } from '@nestjs/common';
import { permissions } from '@ghostfolio/common/permissions'; import { REQUEST } from '@nestjs/core';
import { Controller, Post, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
@Controller('cache') @Controller('cache')
export class CacheController { export class CacheController {
public constructor(private readonly redisCacheService: RedisCacheService) {} public constructor(
private readonly cacheService: CacheService,
private readonly redisCacheService: RedisCacheService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {
this.redisCacheService.reset();
}
@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(); this.redisCacheService.reset();
return this.cacheService.flush();
} }
} }

View File

@ -1,11 +1,30 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileModule } from '@ghostfolio/api/services/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({
imports: [
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
RedisCacheModule,
SymbolProfileModule
],
controllers: [CacheController], controllers: [CacheController],
imports: [RedisCacheModule] providers: [
CacheService,
ConfigurationService,
DataGatheringService,
PrismaService
]
}) })
export class CacheModule {} export class CacheModule {}

15
apps/api/src/app/cache/cache.service.ts vendored Normal file
View File

@ -0,0 +1,15 @@
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { Injectable } from '@nestjs/common';
@Injectable()
export class CacheService {
public constructor(
private readonly dataGaterhingService: DataGatheringService
) {}
public async flush(): Promise<void> {
await this.dataGaterhingService.reset();
return;
}
}

View File

@ -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 };
}
}

View File

@ -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 {}

View File

@ -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 portfolios 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');
}
}

View File

@ -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 });
}
}

View File

@ -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 {}

View File

@ -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
});
}
}

View File

@ -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 {}

View File

@ -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
};
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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[];
}

View File

@ -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 });
}
}

View File

@ -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 {}

View File

@ -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]);
});
}
}

View File

@ -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
});
}
}

View File

@ -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 {}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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 {}

View File

@ -1,10 +0,0 @@
import { IsOptional, IsString } from 'class-validator';
export class CreateTagDto {
@IsString()
name: string;
@IsOptional()
@IsString()
userId?: string;
}

View File

@ -1,115 +0,0 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Delete,
Get,
HttpException,
Inject,
Param,
Post,
Put,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Tag } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreateTagDto } from './create-tag.dto';
import { UpdateTagDto } from './update-tag.dto';
@Controller('tags')
export class TagsController {
public constructor(
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly tagService: TagService
) {}
@Post()
@UseGuards(AuthGuard('jwt'))
public async createTag(@Body() data: CreateTagDto): Promise<Tag> {
const canCreateOwnTag = hasPermission(
this.request.user.permissions,
permissions.createOwnTag
);
const canCreateTag = hasPermission(
this.request.user.permissions,
permissions.createTag
);
if (!canCreateOwnTag && !canCreateTag) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
if (canCreateOwnTag && !canCreateTag) {
if (data.userId !== this.request.user.id) {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
}
return this.tagService.createTag(data);
}
@Delete(':id')
@HasPermission(permissions.deleteTag)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteTag(@Param('id') id: string) {
const originalTag = await this.tagService.getTag({
id
});
if (!originalTag) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.tagService.deleteTag({ id });
}
@Get()
@HasPermission(permissions.readTags)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getTags() {
return this.tagService.getTagsWithActivityCount();
}
@HasPermission(permissions.updateTag)
@Put(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateTag(@Param('id') id: string, @Body() data: UpdateTagDto) {
const originalTag = await this.tagService.getTag({
id
});
if (!originalTag) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.tagService.updateTag({
data: {
...data
},
where: {
id
}
});
}
}

View File

@ -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 {}

View File

@ -1,13 +0,0 @@
import { IsOptional, IsString } from 'class-validator';
export class UpdateTagDto {
@IsString()
id: string;
@IsString()
name: string;
@IsOptional()
@IsString()
userId?: string;
}

View File

@ -1,45 +0,0 @@
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import {
Controller,
Get,
HttpException,
Param,
UseGuards
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { ExchangeRateService } from './exchange-rate.service';
@Controller('exchange-rate')
export class ExchangeRateController {
public constructor(
private readonly exchangeRateService: ExchangeRateService
) {}
@Get(':symbol/:dateString')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getExchangeRate(
@Param('dateString') dateString: string,
@Param('symbol') symbol: string
): Promise<IDataProviderHistoricalResponse> {
const date = parseISO(dateString);
const exchangeRate = await this.exchangeRateService.getExchangeRate({
date,
symbol
});
if (exchangeRate) {
return { marketPrice: exchangeRate };
}
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
}

View File

@ -1,14 +0,0 @@
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { Module } from '@nestjs/common';
import { ExchangeRateController } from './exchange-rate.controller';
import { ExchangeRateService } from './exchange-rate.service';
@Module({
controllers: [ExchangeRateController],
exports: [ExchangeRateService],
imports: [ExchangeRateDataModule],
providers: [ExchangeRateService]
})
export class ExchangeRateModule {}

View File

@ -1,27 +0,0 @@
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { Injectable } from '@nestjs/common';
@Injectable()
export class ExchangeRateService {
public constructor(
private readonly exchangeRateDataService: ExchangeRateDataService
) {}
public async getExchangeRate({
date,
symbol
}: {
date: Date;
symbol: string;
}): Promise<number> {
const [currency1, currency2] = symbol.split('-');
return this.exchangeRateDataService.toCurrencyAtDate(
1,
currency1,
currency2,
date
);
}
}

View File

@ -1,9 +1,6 @@
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, 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,29 +9,14 @@ 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(): Promise<Export> {
@Query('accounts') filterByAccounts?: string, return await this.exportService.export({
@Query('activityIds') activityIds?: string[],
@Query('assetClasses') filterByAssetClasses?: string,
@Query('tags') filterByTags?: string
): Promise<Export> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByTags
});
return this.exportService.export({
activityIds,
filters,
userCurrency: this.request.user.Settings.settings.baseCurrency,
userId: this.request.user.id userId: this.request.user.id
}); });
} }

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