This commit is contained in:
1
.env.dev
1
.env.dev
@ -22,4 +22,3 @@ JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
|
||||
# For more info, see: https://nx.dev/concepts/inferred-tasks
|
||||
NX_ADD_PLUGINS=false
|
||||
|
||||
NX_NATIVE_COMMAND_RUNNER=false
|
||||
|
42
.github/workflows/build-code.yml
vendored
Normal file
42
.github/workflows/build-code.yml
vendored
Normal file
@ -0,0 +1,42 @@
|
||||
name: Build code
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node_version:
|
||||
- 22
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Use Node.js ${{ matrix.node_version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node_version }}
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Check code style
|
||||
run: npm run lint
|
||||
|
||||
- name: Check formatting
|
||||
run: npm run format:check
|
||||
|
||||
- name: Execute tests
|
||||
run: npm test
|
||||
|
||||
- name: Build application
|
||||
run: npm run build:production
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -27,8 +27,10 @@ npm-debug.log
|
||||
|
||||
# misc
|
||||
/.angular/cache
|
||||
.cursor/rules/nx-rules.mdc
|
||||
.env
|
||||
.env.prod
|
||||
.github/instructions/nx.instructions.md
|
||||
.nx/cache
|
||||
.nx/workspace-data
|
||||
/.sass-cache
|
||||
|
284
CHANGELOG.md
284
CHANGELOG.md
@ -5,6 +5,270 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for generating a new _Security Token_ via the user’s account access panel
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the search results of the assistant to only display categories with content
|
||||
- Renamed `Account` to `account` in the `Order` database schema
|
||||
- Improved the language localization for German (`de`)
|
||||
|
||||
## 2.175.0 - 2025-06-28
|
||||
|
||||
### Added
|
||||
|
||||
- Set up the language localization for the static portfolio analysis rule: _Asset Class Cluster Risks_ (Equity)
|
||||
- Set up the language localization for the static portfolio analysis rule: _Asset Class Cluster Risks_ (Fixed Income)
|
||||
- Set up the language localization for the static portfolio analysis rule: _Currency Cluster Risks_ (Investment)
|
||||
- Set up the language localization for the static portfolio analysis rule: _Currency Cluster Risks_ (Investment: Base Currency)
|
||||
|
||||
### Changed
|
||||
|
||||
- Extended the selector handling of the scraper configuration for more use cases
|
||||
- Extended the _AI_ service by an access to _OpenRouter_ (experimental)
|
||||
- Changed `node main` to `exec node main` in the `entrypoint.sh` file to improve the container signal handling
|
||||
- Renamed `Account` to `account` in the `AccountBalance` database schema
|
||||
- Improved the language localization for Catalan (`ca`)
|
||||
- Improved the language localization for Dutch (`nl`)
|
||||
- Improved the language localization for Español (`es`)
|
||||
- Improved the language localization for German (`de`)
|
||||
- Improved the language localization for Turkish (`tr`)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with the locale in the scraper configuration
|
||||
|
||||
## 2.174.0 - 2025-06-24
|
||||
|
||||
### Added
|
||||
|
||||
- Set up the language localization for the static portfolio analysis rule: _Account Cluster Risks_ (Current Investment)
|
||||
- Extended the data providers management of the admin control panel by the online status
|
||||
|
||||
### Changed
|
||||
|
||||
- Migrated the `@ghostfolio/ui/value` component to control flow
|
||||
- Renamed `Platform` to `platform` in the `Account` database schema
|
||||
- Refactored the health check endpoint for data enhancers
|
||||
- Refactored the health check endpoint for data providers
|
||||
- Improved the language localization for French (`fr`)
|
||||
- Improved the language localization for German (`de`)
|
||||
- Refreshed the cryptocurrencies list
|
||||
|
||||
## 2.173.0 - 2025-06-21
|
||||
|
||||
### Added
|
||||
|
||||
- Set up `open-color` for CSS variable usage
|
||||
|
||||
### Changed
|
||||
|
||||
- Simplified the data providers management of the admin control panel
|
||||
- Migrated the `@ghostfolio/ui/assistant` component to control flow
|
||||
- Migrated the `@ghostfolio/ui/value` component to control flow
|
||||
- Renamed `GranteeUser` to `granteeUser` in the `Access` database schema
|
||||
- Improved the language localization for French (`fr`)
|
||||
- Improved the language localization for German (`de`)
|
||||
- Upgraded `class-validator` from version `0.14.1` to `0.14.2`
|
||||
- Upgraded `prisma` from version `6.9.0` to `6.10.1`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue in the `HtmlTemplateMiddleware` related to incorrect variable resolution
|
||||
- Eliminated the _Unsupported route path_ warning of the `LegacyRouteConverter` on startup
|
||||
|
||||
## 2.172.0 - 2025-06-19
|
||||
|
||||
### Added
|
||||
|
||||
- Set up the language localization for the static portfolio analysis rule: _Account Cluster Risks_ (Single Account)
|
||||
- Included the admin control panel in the quick links of the assistant
|
||||
|
||||
### Changed
|
||||
|
||||
- Adapted the options of the date range selector in the assistant dynamically based on the user’s first activity
|
||||
- Switched the data provider service to `OnModuleInit`, ensuring (currency) quotes are fetched only once
|
||||
- Migrated the `@ghostfolio/ui/assistant` component to control flow
|
||||
- Migrated the `@ghostfolio/ui/value` component to control flow
|
||||
- Improved the language localization for Chinese (`zh`)
|
||||
- Improved the language localization for Español (`es`)
|
||||
- Improved the language localization for German (`de`)
|
||||
- Improved the language localization for Portuguese (`pt`)
|
||||
|
||||
## 2.171.0 - 2025-06-15
|
||||
|
||||
### Added
|
||||
|
||||
- Added the current holdings as default options of the symbol search in the create or update activity dialog
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the style of the assistant
|
||||
- Reused the value component in the data providers management of the admin control panel
|
||||
- Set the market state of exchange rate symbols to `open` in the _Financial Modeling Prep_ service
|
||||
- Restructured the content of the pricing page
|
||||
- Migrated the `@ghostfolio/ui/assistant` component to control flow
|
||||
- Migrated the `@ghostfolio/ui/value` component to control flow
|
||||
- Migrated the `HtmlTemplateMiddleware` to use `@Injectable()`
|
||||
- Renamed `User` to `user` in the database schema
|
||||
- Improved the language localization for Catalan (`ca`)
|
||||
- Improved the language localization for Español (`es`)
|
||||
- Improved the language localization for French (`fr`)
|
||||
- Improved the language localization for German (`de`)
|
||||
- Improved the language localization for Italian (`it`)
|
||||
- Improved the language localization for Polish (`pl`)
|
||||
- Improved the language localization for Portuguese (`pt`)
|
||||
- Improved the language localization for Turkish (`tr`)
|
||||
- Upgraded the _Stripe_ dependencies
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed a date offset issue with account balances
|
||||
- Fixed missing `/.well-known/assetlinks.json` for TWA
|
||||
|
||||
## 2.170.0 - 2025-06-11
|
||||
|
||||
### Added
|
||||
|
||||
- Included quick links in the search results of the assistant
|
||||
- Added a skeleton loader to the changelog page
|
||||
- Extended the content of the _Self-Hosting_ section by information about additional data providers on the Frequently Asked Questions (FAQ) page
|
||||
|
||||
### Changed
|
||||
|
||||
- Renamed `ApiKey` to `apiKeys` in the `User` database schema
|
||||
- Improved the language localization for French (`fr`)
|
||||
- Improved the language localization for Portuguese (`pt`)
|
||||
- Upgraded `@keyv/redis` from version `4.3.4` to `4.4.0`
|
||||
- Upgraded `prisma` from version `6.8.2` to `6.9.0`
|
||||
- Upgraded `zone.js` from version `0.15.0` to `0.15.1`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Restricted the date range change permission in the _Zen Mode_
|
||||
|
||||
## 2.169.0 - 2025-06-08
|
||||
|
||||
### Changed
|
||||
|
||||
- Renamed the asset profile icon component to entity logo component and moved to `@ghostfolio/ui`
|
||||
- Renamed `Account` to `accounts` in the `User` database schema
|
||||
- Improved the cache verification in the health check endpoint (experimental)
|
||||
- Improved the language localization for Catalan (`ca`)
|
||||
- Improved the language localization for French (`fr`)
|
||||
- Improved the language localization for Polish (`pl`)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Handled an exception in the get keys function of the _Redis_ cache service
|
||||
- Fixed missing `/.well-known/assetlinks.json` for TWA
|
||||
|
||||
## 2.168.0 - 2025-06-07
|
||||
|
||||
### Added
|
||||
|
||||
- Added a background gradient to the sidebar navigation
|
||||
|
||||
### Changed
|
||||
|
||||
- Migrated the `i18n` service to use `@Injectable()`
|
||||
- Improved the language localization for German (`de`)
|
||||
- Upgraded `nestjs` from version `11.1.0` to `11.1.3`
|
||||
|
||||
## 2.167.0 - 2025-06-07
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for column sorting to the markets overview
|
||||
- Added support for column sorting to the watchlist
|
||||
- Set up the language localization for the static portfolio analysis rule: _Emergency Fund_ (Setup)
|
||||
- Set up the language localization for the static portfolio analysis rule: _Fees_ (Fee Ratio)
|
||||
|
||||
### Changed
|
||||
|
||||
- Extended the symbol search component by default options
|
||||
- Renamed `Tag` to `tags` in the `User` database schema
|
||||
- Improved the language localization for German (`de`)
|
||||
- Improved the language localization for Spanish (`es`)
|
||||
- Improved the language localization for Turkish (`tr`)
|
||||
- Upgraded `ng-extract-i18n-merge` from version `2.15.0` to `2.15.1`
|
||||
- Upgraded `Nx` from version `20.8.1` to `21.1.2`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue where the import button was not correctly enabled in the import activities dialog
|
||||
- Fixed an issue with empty account balances in the import activities dialog
|
||||
- Fixed an issue in the annualized performance calculation
|
||||
|
||||
## 2.166.0 - 2025-06-05
|
||||
|
||||
### Added
|
||||
|
||||
- Added support to create custom tags in the create or update activity dialog (experimental)
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the style of the card components
|
||||
- Improved the style of the system message
|
||||
- Improved the language localization for German (`de`)
|
||||
- Improved the language localization for Spanish (`es`)
|
||||
- Improved the language localization for Turkish (`tr`)
|
||||
- Improved the language localization for Ukrainian (`uk`)
|
||||
- Upgraded the _Stripe_ dependencies
|
||||
- Upgraded `ngx-stripe` from version `19.0.0` to `19.7.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Respected the filter by holding when deleting activities on the portfolio activities page
|
||||
- Respected the filter by holding when exporting activities on the portfolio activities page
|
||||
- Fixed an exception with currencies in the historical market data editor of the admin control panel
|
||||
|
||||
## 2.165.0 - 2025-05-31
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the content of the _General_ section by the performance calculation method on the Frequently Asked Questions (FAQ) page
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the _Live Demo_ setup by syncing activities based on tags
|
||||
- Renamed `orders` to `activities` in the `Tag` database schema
|
||||
- Modularized the cron service
|
||||
- Refreshed the cryptocurrencies list
|
||||
- Improved the language localization for Catalan (`ca`)
|
||||
- Improved the language localization for Dutch (`nl`)
|
||||
- Improved the language localization for Polish (`pl`)
|
||||
- Improved the language localization for Spanish (`es`)
|
||||
- Upgraded `big.js` from version `6.2.2` to `7.0.1`
|
||||
- Upgraded `ng-extract-i18n-merge` from version `2.14.3` to `2.15.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Changed the investment value to take the currency effects into account in the holding detail dialog
|
||||
|
||||
## 2.164.0 - 2025-05-28
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the language localization for Dutch (`nl`)
|
||||
- Improved the language localization for French (`fr`)
|
||||
- Improved the language localization for Polish (`pl`)
|
||||
- Improved the language localization for Spanish (`es`)
|
||||
- Upgraded `Node.js` from version `20` to `22` (`Dockerfile`)
|
||||
- Upgraded `yahoo-finance2` from version `3.3.4` to `3.3.5`
|
||||
|
||||
## 2.163.0 - 2025-05-26
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the language localization for Italian (`it`)
|
||||
- Improved the language localization for Turkish (`tr`)
|
||||
- Upgraded `yahoo-finance2` from version `3.3.3` to `3.3.4`
|
||||
|
||||
## 2.162.1 - 2025-05-24
|
||||
|
||||
### Added
|
||||
@ -26,12 +290,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Improved the language localization for Catalan (`ca`)
|
||||
- Improved the language localization for Chinese (`zh`)
|
||||
- Improved the language localization for Dutch (`nl`)
|
||||
- Improved the language localization for Español (`es`)
|
||||
- Improved the language localization for French (`fr`)
|
||||
- Improved the language localization for German (`de`)
|
||||
- Improved the language localization for Italian (`it`)
|
||||
- Improved the language localization for Polish (`pl`)
|
||||
- Improved the language localization for Portuguese (`pt`)
|
||||
- Improved the language localization for Spanish (`es`)
|
||||
- Upgraded `countup.js` from version `2.8.0` to `2.8.2`
|
||||
- Upgraded `nestjs` from version `10.4.15` to `11.0.12`
|
||||
- Upgraded `prisma` from version `6.7.0` to `6.8.2`
|
||||
@ -88,7 +352,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the language localization for Français (`fr`)
|
||||
- Improved the language localization for French (`fr`)
|
||||
- Upgraded `bootstrap` from version `4.6.0` to `4.6.2`
|
||||
|
||||
### Fixed
|
||||
@ -129,7 +393,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Improved the error message of the currency code validation
|
||||
- Tightened the currency code validation by requiring uppercase letters
|
||||
- Respected the watcher count for the delete asset profiles checkbox in the historical market data table of the admin control panel
|
||||
- Improved the language localization for Français (`fr`)
|
||||
- Improved the language localization for French (`fr`)
|
||||
- Upgraded `ngx-skeleton-loader` from version `10.0.0` to `11.0.0`
|
||||
- Upgraded `Nx` from version `20.8.0` to `20.8.1`
|
||||
|
||||
@ -229,7 +493,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Changed
|
||||
|
||||
- Improved the check for duplicates in the preview step of the activities import (allow different comments)
|
||||
- Improved the language localization for Français (`fr`)
|
||||
- Improved the language localization for French (`fr`)
|
||||
- Improved the language localization for German (`de`)
|
||||
- Improved the language localization for Polish (`pl`)
|
||||
- Upgraded `ng-extract-i18n-merge` from version `2.14.1` to `2.14.3`
|
||||
@ -3684,7 +3948,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- Added the language localization for Français (`fr`)
|
||||
- Added the language localization for French (`fr`)
|
||||
- Extended the landing page by a global heat map of subscribers
|
||||
- Added support for the thousand separator in the global heat map component
|
||||
|
||||
@ -3713,7 +3977,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- Added support for the dividend timeline grouped by year
|
||||
- Added support for the investment timeline grouped by year
|
||||
- Set up the language localization for Français (`fr`)
|
||||
- Set up the language localization for French (`fr`)
|
||||
|
||||
### Changed
|
||||
|
||||
@ -3822,7 +4086,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Changed
|
||||
|
||||
- Improved the value redaction interceptor (including `comment`)
|
||||
- Improved the language localization for Español (`es`)
|
||||
- Improved the language localization for Spanish (`es`)
|
||||
- Upgraded `cheerio` from version `1.0.0-rc.6` to `1.0.0-rc.12`
|
||||
- Upgraded `prisma` from version `4.6.1` to `4.7.1`
|
||||
|
||||
@ -4051,7 +4315,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Changed
|
||||
|
||||
- Improved the usage of the value component in the admin control panel
|
||||
- Improved the language localization for Español (`es`)
|
||||
- Improved the language localization for Spanish (`es`)
|
||||
|
||||
### Fixed
|
||||
|
||||
@ -4073,7 +4337,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- Set up the language localization for Español (`es`)
|
||||
- Set up the language localization for Spanish (`es`)
|
||||
- Added support for sectors in mutual funds
|
||||
|
||||
## 1.198.0 - 25.09.2022
|
||||
@ -5856,7 +6120,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Changed the navigation to always show the portfolio page
|
||||
- Migrated the data type of currencies from `enum` to `string` in the database
|
||||
- Supported unlimited currencies (instead of `CHF`, `EUR`, `GBP` and `USD`)
|
||||
- Respected the accounts' currencies in the exchange rate service
|
||||
- Respected the accounts’ currencies in the exchange rate service
|
||||
|
||||
### Fixed
|
||||
|
||||
|
@ -5,7 +5,7 @@
|
||||
### Prerequisites
|
||||
|
||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||
- [Node.js](https://nodejs.org/en/download) (version 20+)
|
||||
- [Node.js](https://nodejs.org/en/download) (version 22+)
|
||||
- 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`)
|
||||
|
||||
@ -30,7 +30,13 @@ Run `npm run start:server`
|
||||
|
||||
### Start Client
|
||||
|
||||
Run `npm run start:client` and open https://localhost:4200/en in your browser
|
||||
#### English (Default)
|
||||
|
||||
Run `npm run start:client` and open https://localhost:4200/en in your browser.
|
||||
|
||||
#### Other Languages
|
||||
|
||||
To start the client in a different language, such as German (`de`), adapt the `start:client` script in the `package.json` file by changing `--configuration=development-en` to `--configuration=development-de`. Then, run `npm run start:client` and open https://localhost:4200/de in your browser.
|
||||
|
||||
### Start _Storybook_
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
FROM --platform=$BUILDPLATFORM node:20-slim AS builder
|
||||
FROM --platform=$BUILDPLATFORM node:22-slim AS builder
|
||||
|
||||
# Build application and add additional files
|
||||
WORKDIR /ghostfolio
|
||||
@ -33,24 +33,25 @@ COPY ./nx.json nx.json
|
||||
COPY ./replace.build.mjs replace.build.mjs
|
||||
COPY ./tsconfig.base.json tsconfig.base.json
|
||||
|
||||
ENV NX_DAEMON=false
|
||||
RUN npm run build:production
|
||||
|
||||
# Prepare the dist image with additional node_modules
|
||||
WORKDIR /ghostfolio/dist/apps/api
|
||||
# package.json was generated by the build process, however the original
|
||||
# package-lock.json needs to be used to ensure the same versions
|
||||
# package-lock.json needs to be used to ensure the same versions
|
||||
COPY ./package-lock.json /ghostfolio/dist/apps/api/package-lock.json
|
||||
|
||||
RUN npm install
|
||||
COPY prisma /ghostfolio/dist/apps/api/prisma
|
||||
|
||||
# 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
|
||||
RUN npm run database:generate-typings
|
||||
|
||||
# Image to run, copy everything needed from builder
|
||||
FROM node:20-slim
|
||||
FROM node:22-slim
|
||||
LABEL org.opencontainers.image.source="https://github.com/ghostfolio/ghostfolio"
|
||||
ENV NODE_ENV=production
|
||||
|
||||
|
@ -37,20 +37,20 @@ export class AccessController {
|
||||
public async getAllAccesses(): Promise<Access[]> {
|
||||
const accessesWithGranteeUser = await this.accessService.accesses({
|
||||
include: {
|
||||
GranteeUser: true
|
||||
granteeUser: true
|
||||
},
|
||||
orderBy: { granteeUserId: 'asc' },
|
||||
where: { userId: this.request.user.id }
|
||||
});
|
||||
|
||||
return accessesWithGranteeUser.map(
|
||||
({ alias, GranteeUser, id, permissions }) => {
|
||||
if (GranteeUser) {
|
||||
({ alias, granteeUser, id, permissions }) => {
|
||||
if (granteeUser) {
|
||||
return {
|
||||
alias,
|
||||
id,
|
||||
permissions,
|
||||
grantee: GranteeUser?.id,
|
||||
grantee: granteeUser?.id,
|
||||
type: 'PRIVATE'
|
||||
};
|
||||
}
|
||||
@ -85,11 +85,11 @@ export class AccessController {
|
||||
try {
|
||||
return this.accessService.createAccess({
|
||||
alias: data.alias || undefined,
|
||||
GranteeUser: data.granteeUserId
|
||||
granteeUser: data.granteeUserId
|
||||
? { connect: { id: data.granteeUserId } }
|
||||
: undefined,
|
||||
permissions: data.permissions,
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
user: { connect: { id: this.request.user.id } }
|
||||
});
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
|
@ -13,7 +13,7 @@ export class AccessService {
|
||||
): Promise<AccessWithGranteeUser | null> {
|
||||
return this.prismaService.access.findFirst({
|
||||
include: {
|
||||
GranteeUser: true
|
||||
granteeUser: true
|
||||
},
|
||||
where: accessWhereInput
|
||||
});
|
||||
|
@ -30,7 +30,7 @@ export class AccountBalanceService {
|
||||
): Promise<AccountBalance | null> {
|
||||
return this.prismaService.accountBalance.findFirst({
|
||||
include: {
|
||||
Account: true
|
||||
account: true
|
||||
},
|
||||
where: accountBalanceWhereInput
|
||||
});
|
||||
@ -46,7 +46,7 @@ export class AccountBalanceService {
|
||||
}): Promise<AccountBalance> {
|
||||
const accountBalance = await this.prismaService.accountBalance.upsert({
|
||||
create: {
|
||||
Account: {
|
||||
account: {
|
||||
connect: {
|
||||
id_userId: {
|
||||
userId,
|
||||
@ -154,7 +154,7 @@ export class AccountBalanceService {
|
||||
}
|
||||
|
||||
if (withExcludedAccounts === false) {
|
||||
where.Account = { isExcluded: false };
|
||||
where.account = { isExcluded: false };
|
||||
}
|
||||
|
||||
const balances = await this.prismaService.accountBalance.findMany({
|
||||
@ -163,7 +163,7 @@ export class AccountBalanceService {
|
||||
date: 'asc'
|
||||
},
|
||||
select: {
|
||||
Account: true,
|
||||
account: true,
|
||||
date: true,
|
||||
id: true,
|
||||
value: true
|
||||
@ -174,10 +174,10 @@ export class AccountBalanceService {
|
||||
balances: balances.map((balance) => {
|
||||
return {
|
||||
...balance,
|
||||
accountId: balance.Account.id,
|
||||
accountId: balance.account.id,
|
||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
balance.value,
|
||||
balance.Account.currency,
|
||||
balance.account.currency,
|
||||
userCurrency
|
||||
)
|
||||
};
|
||||
|
@ -152,8 +152,8 @@ export class AccountController {
|
||||
return this.accountService.createAccount(
|
||||
{
|
||||
...data,
|
||||
Platform: { connect: { id: platformId } },
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
platform: { connect: { id: platformId } },
|
||||
user: { connect: { id: this.request.user.id } }
|
||||
},
|
||||
this.request.user.id
|
||||
);
|
||||
@ -163,7 +163,7 @@ export class AccountController {
|
||||
return this.accountService.createAccount(
|
||||
{
|
||||
...data,
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
user: { connect: { id: this.request.user.id } }
|
||||
},
|
||||
this.request.user.id
|
||||
);
|
||||
@ -250,8 +250,8 @@ export class AccountController {
|
||||
{
|
||||
data: {
|
||||
...data,
|
||||
Platform: { connect: { id: platformId } },
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
platform: { connect: { id: platformId } },
|
||||
user: { connect: { id: this.request.user.id } }
|
||||
},
|
||||
where: {
|
||||
id_userId: {
|
||||
@ -270,10 +270,10 @@ export class AccountController {
|
||||
{
|
||||
data: {
|
||||
...data,
|
||||
Platform: originalAccount.platformId
|
||||
platform: originalAccount.platformId
|
||||
? { disconnect: true }
|
||||
: undefined,
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
user: { connect: { id: this.request.user.id } }
|
||||
},
|
||||
where: {
|
||||
id_userId: {
|
||||
|
@ -64,7 +64,7 @@ export class AccountService {
|
||||
(Account & {
|
||||
activities?: Order[];
|
||||
balances?: AccountBalance[];
|
||||
Platform?: Platform;
|
||||
platform?: Platform;
|
||||
})[]
|
||||
> {
|
||||
const { include = {}, skip, take, cursor, where, orderBy } = params;
|
||||
@ -140,7 +140,10 @@ export class AccountService {
|
||||
|
||||
public async getAccounts(aUserId: string): Promise<Account[]> {
|
||||
const accounts = await this.accounts({
|
||||
include: { activities: true, Platform: true },
|
||||
include: {
|
||||
activities: true,
|
||||
platform: true
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
where: { userId: aUserId }
|
||||
});
|
||||
|
@ -3,6 +3,7 @@ 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 { DemoService } from '@ghostfolio/api/services/demo/demo.service';
|
||||
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
|
||||
import {
|
||||
@ -16,7 +17,8 @@ import {
|
||||
AdminData,
|
||||
AdminMarketData,
|
||||
AdminUsers,
|
||||
EnhancedSymbolProfile
|
||||
EnhancedSymbolProfile,
|
||||
ScraperConfiguration
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import type {
|
||||
@ -55,6 +57,7 @@ export class AdminController {
|
||||
private readonly adminService: AdminService,
|
||||
private readonly apiService: ApiService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly demoService: DemoService,
|
||||
private readonly manualService: ManualService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
@ -66,6 +69,13 @@ export class AdminController {
|
||||
return this.adminService.get({ user: this.request.user });
|
||||
}
|
||||
|
||||
@Get('demo-user/sync')
|
||||
@HasPermission(permissions.syncDemoUserAccount)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async syncDemoUserAccount(): Promise<Prisma.BatchPayload> {
|
||||
return this.demoService.syncDemoUserAccount();
|
||||
}
|
||||
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Post('gather')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@ -213,13 +223,12 @@ export class AdminController {
|
||||
@Post('market-data/:dataSource/:symbol/test')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async testMarketData(
|
||||
@Body() data: { scraperConfiguration: string },
|
||||
@Body() data: { scraperConfiguration: ScraperConfiguration },
|
||||
@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);
|
||||
const price = await this.manualService.test(data.scraperConfiguration);
|
||||
|
||||
if (price) {
|
||||
return { price };
|
||||
|
@ -4,6 +4,7 @@ import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { DemoModule } from '@ghostfolio/api/services/demo/demo.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
@ -24,6 +25,7 @@ import { QueueModule } from './queue/queue.module';
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
DemoModule,
|
||||
ExchangeRateDataModule,
|
||||
MarketDataModule,
|
||||
OrderModule,
|
||||
|
@ -648,7 +648,7 @@ export class AdminService {
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
where = {
|
||||
NOT: {
|
||||
Analytics: null
|
||||
analytics: null
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -674,7 +674,7 @@ export class AdminService {
|
||||
select: {
|
||||
activities: {
|
||||
where: {
|
||||
User: {
|
||||
user: {
|
||||
subscriptions: {
|
||||
some: {
|
||||
expiresAt: {
|
||||
@ -806,13 +806,13 @@ export class AdminService {
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
orderBy = {
|
||||
Analytics: {
|
||||
analytics: {
|
||||
lastRequestAt: 'desc'
|
||||
}
|
||||
};
|
||||
where = {
|
||||
NOT: {
|
||||
Analytics: null
|
||||
analytics: null
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -824,9 +824,9 @@ export class AdminService {
|
||||
where,
|
||||
select: {
|
||||
_count: {
|
||||
select: { Account: true, activities: true }
|
||||
select: { accounts: true, activities: true }
|
||||
},
|
||||
Analytics: {
|
||||
analytics: {
|
||||
select: {
|
||||
activityCount: true,
|
||||
country: true,
|
||||
@ -852,11 +852,11 @@ export class AdminService {
|
||||
});
|
||||
|
||||
return usersWithAnalytics.map(
|
||||
({ _count, Analytics, createdAt, id, role, subscriptions }) => {
|
||||
({ _count, analytics, createdAt, id, role, subscriptions }) => {
|
||||
const daysSinceRegistration =
|
||||
differenceInDays(new Date(), createdAt) + 1;
|
||||
const engagement = Analytics
|
||||
? Analytics.activityCount / daysSinceRegistration
|
||||
const engagement = analytics
|
||||
? analytics.activityCount / daysSinceRegistration
|
||||
: undefined;
|
||||
|
||||
const subscription =
|
||||
@ -871,11 +871,11 @@ export class AdminService {
|
||||
id,
|
||||
role,
|
||||
subscription,
|
||||
accountCount: _count.Account || 0,
|
||||
accountCount: _count.accounts || 0,
|
||||
activityCount: _count.activities || 0,
|
||||
country: Analytics?.country,
|
||||
dailyApiRequests: Analytics?.dataProviderGhostfolioDailyRequests || 0,
|
||||
lastActivity: Analytics?.updatedAt
|
||||
country: analytics?.country,
|
||||
dailyApiRequests: analytics?.dataProviderGhostfolioDailyRequests || 0,
|
||||
lastActivity: analytics?.updatedAt
|
||||
};
|
||||
}
|
||||
);
|
||||
|
@ -1,20 +1,21 @@
|
||||
import { EventsModule } from '@ghostfolio/api/events/events.module';
|
||||
import { HtmlTemplateMiddleware } from '@ghostfolio/api/middlewares/html-template.middleware';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { CronService } from '@ghostfolio/api/services/cron.service';
|
||||
import { CronModule } from '@ghostfolio/api/services/cron/cron.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 { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
|
||||
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 { Module } from '@nestjs/common';
|
||||
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
@ -37,6 +38,7 @@ 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 { SitemapModule } from './endpoints/sitemap/sitemap.module';
|
||||
import { TagsModule } from './endpoints/tags/tags.module';
|
||||
import { WatchlistModule } from './endpoints/watchlist/watchlist.module';
|
||||
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
|
||||
@ -49,7 +51,6 @@ import { OrderModule } from './order/order.module';
|
||||
import { PlatformModule } from './platform/platform.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 { SymbolModule } from './symbol/symbol.module';
|
||||
import { UserModule } from './user/user.module';
|
||||
@ -78,6 +79,7 @@ import { UserModule } from './user/user.module';
|
||||
CacheModule,
|
||||
ConfigModule.forRoot(),
|
||||
ConfigurationModule,
|
||||
CronModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
EventEmitterModule.forRoot(),
|
||||
@ -101,7 +103,7 @@ import { UserModule } from './user/user.module';
|
||||
RedisCacheModule,
|
||||
ScheduleModule.forRoot(),
|
||||
ServeStaticModule.forRoot({
|
||||
exclude: ['/api/*wildcard', '/sitemap.xml'],
|
||||
exclude: ['/.well-known/*wildcard', '/api/*wildcard', '/sitemap.xml'],
|
||||
rootPath: join(__dirname, '..', 'client'),
|
||||
serveStaticOptions: {
|
||||
setHeaders: (res) => {
|
||||
@ -124,14 +126,21 @@ import { UserModule } from './user/user.module';
|
||||
}
|
||||
}
|
||||
}),
|
||||
ServeStaticModule.forRoot({
|
||||
rootPath: join(__dirname, '..', 'client', '.well-known'),
|
||||
serveRoot: '/.well-known'
|
||||
}),
|
||||
SitemapModule,
|
||||
SubscriptionModule,
|
||||
SymbolModule,
|
||||
TagsModule,
|
||||
TwitterBotModule,
|
||||
UserModule,
|
||||
WatchlistModule
|
||||
],
|
||||
providers: [CronService]
|
||||
providers: [I18nService]
|
||||
})
|
||||
export class AppModule {}
|
||||
export class AppModule implements NestModule {
|
||||
public configure(consumer: MiddlewareConsumer) {
|
||||
consumer.apply(HtmlTemplateMiddleware).forRoutes('*wildcard');
|
||||
}
|
||||
}
|
||||
|
@ -36,7 +36,7 @@ export class ApiKeyStrategy extends PassportStrategy(
|
||||
}
|
||||
|
||||
await this.prismaService.analytics.upsert({
|
||||
create: { User: { connect: { id: user.id } } },
|
||||
create: { user: { connect: { id: user.id } } },
|
||||
update: {
|
||||
activityCount: { increment: 1 },
|
||||
lastRequestAt: new Date()
|
||||
|
@ -1,7 +1,11 @@
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config';
|
||||
import {
|
||||
DEFAULT_CURRENCY,
|
||||
DEFAULT_LANGUAGE_CODE,
|
||||
HEADER_KEY_TIMEZONE
|
||||
} from '@ghostfolio/common/config';
|
||||
import { hasRole } from '@ghostfolio/common/permissions';
|
||||
|
||||
import { HttpException, Injectable } from '@nestjs/common';
|
||||
@ -42,7 +46,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
countriesAndTimezones.getCountryForTimezone(timezone)?.id;
|
||||
|
||||
await this.prismaService.analytics.upsert({
|
||||
create: { country, User: { connect: { id: user.id } } },
|
||||
create: { country, user: { connect: { id: user.id } } },
|
||||
update: {
|
||||
country,
|
||||
activityCount: { increment: 1 },
|
||||
@ -52,6 +56,14 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
});
|
||||
}
|
||||
|
||||
if (!user.Settings.settings.baseCurrency) {
|
||||
user.Settings.settings.baseCurrency = DEFAULT_CURRENCY;
|
||||
}
|
||||
|
||||
if (!user.Settings.settings.language) {
|
||||
user.Settings.settings.language = DEFAULT_LANGUAGE_CODE;
|
||||
}
|
||||
|
||||
return user;
|
||||
} else {
|
||||
throw new HttpException(
|
||||
|
@ -131,7 +131,7 @@ export class WebAuthService {
|
||||
counter,
|
||||
credentialId: Buffer.from(credentialId),
|
||||
credentialPublicKey: Buffer.from(credentialPublicKey),
|
||||
User: { connect: { id: user.id } }
|
||||
user: { connect: { id: user.id } }
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -1,10 +1,6 @@
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||
import {
|
||||
DEFAULT_CURRENCY,
|
||||
DEFAULT_LANGUAGE_CODE
|
||||
} from '@ghostfolio/common/config';
|
||||
import { AiPromptResponse } from '@ghostfolio/common/interfaces';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import type { AiPromptMode, RequestWithUser } from '@ghostfolio/common/types';
|
||||
@ -53,10 +49,8 @@ export class AiController {
|
||||
filters,
|
||||
mode,
|
||||
impersonationId: undefined,
|
||||
languageCode:
|
||||
this.request.user.Settings.settings.language ?? DEFAULT_LANGUAGE_CODE,
|
||||
userCurrency:
|
||||
this.request.user.Settings.settings.baseCurrency ?? DEFAULT_CURRENCY,
|
||||
languageCode: this.request.user.Settings.settings.language,
|
||||
userCurrency: this.request.user.Settings.settings.baseCurrency,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
|
@ -12,10 +12,12 @@ import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.mo
|
||||
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 { I18nModule } from '@ghostfolio/api/services/i18n/i18n.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';
|
||||
|
||||
@ -32,11 +34,13 @@ import { AiService } from './ai.service';
|
||||
ConfigurationModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
I18nModule,
|
||||
ImpersonationModule,
|
||||
MarketDataModule,
|
||||
OrderModule,
|
||||
PortfolioSnapshotQueueModule,
|
||||
PrismaModule,
|
||||
PropertyModule,
|
||||
RedisCacheModule,
|
||||
SymbolProfileModule,
|
||||
UserModule
|
||||
|
@ -1,12 +1,41 @@
|
||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import {
|
||||
PROPERTY_API_KEY_OPENROUTER,
|
||||
PROPERTY_OPENROUTER_MODEL
|
||||
} from '@ghostfolio/common/config';
|
||||
import { Filter } from '@ghostfolio/common/interfaces';
|
||||
import type { AiPromptMode } from '@ghostfolio/common/types';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
|
||||
import { generateText } from 'ai';
|
||||
|
||||
@Injectable()
|
||||
export class AiService {
|
||||
public constructor(private readonly portfolioService: PortfolioService) {}
|
||||
public constructor(
|
||||
private readonly portfolioService: PortfolioService,
|
||||
private readonly propertyService: PropertyService
|
||||
) {}
|
||||
|
||||
public async generateText({ prompt }: { prompt: string }) {
|
||||
const openRouterApiKey = (await this.propertyService.getByKey(
|
||||
PROPERTY_API_KEY_OPENROUTER
|
||||
)) as string;
|
||||
|
||||
const openRouterModel = (await this.propertyService.getByKey(
|
||||
PROPERTY_OPENROUTER_MODEL
|
||||
)) as string;
|
||||
|
||||
const openRouterService = createOpenRouter({
|
||||
apiKey: openRouterApiKey
|
||||
});
|
||||
|
||||
return generateText({
|
||||
prompt,
|
||||
model: openRouterService.chat(openRouterModel)
|
||||
});
|
||||
}
|
||||
|
||||
public async getPrompt({
|
||||
filters,
|
||||
@ -30,7 +59,7 @@ export class AiService {
|
||||
});
|
||||
|
||||
const holdingsTable = [
|
||||
'| Name | Symbol | Currency | Asset Class | Asset Sub Class | Allocation in Percentage |',
|
||||
'| Name | Symbol | Currency | Asset Class | Asset Sub Class | Allocation in Percentage |',
|
||||
'| --- | --- | --- | --- | --- | --- |',
|
||||
...Object.values(holdings)
|
||||
.sort((a, b) => {
|
||||
|
@ -15,6 +15,7 @@ import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.s
|
||||
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 { I18nModule } from '@ghostfolio/api/services/i18n/i18n.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';
|
||||
@ -35,6 +36,7 @@ import { BenchmarksService } from './benchmarks.service';
|
||||
ConfigurationModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
I18nModule,
|
||||
ImpersonationModule,
|
||||
MarketDataModule,
|
||||
OrderModule,
|
||||
|
@ -85,7 +85,7 @@ export class MarketDataController {
|
||||
{ dataSource, symbol }
|
||||
]);
|
||||
|
||||
if (!assetProfile) {
|
||||
if (!assetProfile && !isCurrency(getCurrencyFromSymbol(symbol))) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
@ -103,7 +103,7 @@ export class MarketDataController {
|
||||
);
|
||||
|
||||
const canUpsertOwnAssetProfile =
|
||||
assetProfile.userId === this.request.user.id &&
|
||||
assetProfile?.userId === this.request.user.id &&
|
||||
hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.createMarketDataOfOwnAssetProfile
|
||||
|
@ -12,6 +12,7 @@ import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors
|
||||
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.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 { I18nModule } from '@ghostfolio/api/services/i18n/i18n.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';
|
||||
@ -29,6 +30,7 @@ import { PublicController } from './public.controller';
|
||||
BenchmarkModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
I18nModule,
|
||||
ImpersonationModule,
|
||||
MarketDataModule,
|
||||
OrderModule,
|
||||
|
49
apps/api/src/app/endpoints/sitemap/sitemap.controller.ts
Normal file
49
apps/api/src/app/endpoints/sitemap/sitemap.controller.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import {
|
||||
DATE_FORMAT,
|
||||
getYesterday,
|
||||
interpolate
|
||||
} from '@ghostfolio/common/helper';
|
||||
|
||||
import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common';
|
||||
import { format } from 'date-fns';
|
||||
import { Response } from 'express';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
import { SitemapService } from './sitemap.service';
|
||||
|
||||
@Controller('sitemap.xml')
|
||||
export class SitemapController {
|
||||
public sitemapXml = '';
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly sitemapService: SitemapService
|
||||
) {
|
||||
try {
|
||||
this.sitemapXml = readFileSync(
|
||||
join(__dirname, 'assets', 'sitemap.xml'),
|
||||
'utf8'
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@Get()
|
||||
@Version(VERSION_NEUTRAL)
|
||||
public getSitemapXml(@Res() response: Response) {
|
||||
const currentDate = format(getYesterday(), DATE_FORMAT);
|
||||
|
||||
response.setHeader('content-type', 'application/xml');
|
||||
response.send(
|
||||
interpolate(this.sitemapXml, {
|
||||
currentDate,
|
||||
personalFinanceTools: this.configurationService.get(
|
||||
'ENABLE_FEATURE_SUBSCRIPTION'
|
||||
)
|
||||
? this.sitemapService.getPersonalFinanceTools({ currentDate })
|
||||
: ''
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
@ -1,11 +1,14 @@
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { SitemapController } from './sitemap.controller';
|
||||
import { SitemapService } from './sitemap.service';
|
||||
|
||||
@Module({
|
||||
controllers: [SitemapController],
|
||||
imports: [ConfigurationModule]
|
||||
imports: [ConfigurationModule, I18nModule],
|
||||
providers: [SitemapService]
|
||||
})
|
||||
export class SitemapModule {}
|
47
apps/api/src/app/endpoints/sitemap/sitemap.service.ts
Normal file
47
apps/api/src/app/endpoints/sitemap/sitemap.service.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
|
||||
import { SUPPORTED_LANGUAGE_CODES } from '@ghostfolio/common/config';
|
||||
import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class SitemapService {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly i18nService: I18nService
|
||||
) {}
|
||||
|
||||
public getPersonalFinanceTools({ currentDate }: { currentDate: string }) {
|
||||
const rootUrl = this.configurationService.get('ROOT_URL');
|
||||
|
||||
return personalFinanceTools
|
||||
.map(({ alias, key }) => {
|
||||
return SUPPORTED_LANGUAGE_CODES.map((languageCode) => {
|
||||
const resourcesPath = this.i18nService.getTranslation({
|
||||
languageCode,
|
||||
id: 'routes.resources'
|
||||
});
|
||||
|
||||
const personalFinanceToolsPath = this.i18nService.getTranslation({
|
||||
languageCode,
|
||||
id: 'routes.resources.personalFinanceTools'
|
||||
});
|
||||
|
||||
const openSourceAlternativeToPath = this.i18nService.getTranslation({
|
||||
languageCode,
|
||||
id: 'routes.resources.personalFinanceTools.openSourceAlternativeTo'
|
||||
});
|
||||
|
||||
return [
|
||||
' <url>',
|
||||
` <loc>${rootUrl}/${languageCode}/${resourcesPath}/${personalFinanceToolsPath}/${openSourceAlternativeToPath}-${alias ?? key}</loc>`,
|
||||
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
|
||||
' </url>'
|
||||
].join('\n');
|
||||
});
|
||||
})
|
||||
.flat()
|
||||
.join('\n');
|
||||
}
|
||||
}
|
@ -1,9 +1,17 @@
|
||||
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 { Export } from '@ghostfolio/common/interfaces';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
import { Controller, Get, Inject, Query, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Inject,
|
||||
Query,
|
||||
UseGuards,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@ -19,16 +27,21 @@ export class ExportController {
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async export(
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('activityIds') filterByActivityIds?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('dataSource') filterByDataSource?: string,
|
||||
@Query('symbol') filterBySymbol?: string,
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<Export> {
|
||||
const activityIds = filterByActivityIds?.split(',') ?? [];
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByDataSource,
|
||||
filterBySymbol,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
|
||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
|
||||
|
||||
@ -9,8 +10,14 @@ import { ExportController } from './export.controller';
|
||||
import { ExportService } from './export.service';
|
||||
|
||||
@Module({
|
||||
imports: [AccountModule, ApiModule, OrderModule, TagModule],
|
||||
controllers: [ExportController],
|
||||
imports: [
|
||||
AccountModule,
|
||||
ApiModule,
|
||||
OrderModule,
|
||||
TagModule,
|
||||
TransformDataSourceInRequestModule
|
||||
],
|
||||
providers: [ExportService]
|
||||
})
|
||||
export class ExportModule {}
|
||||
|
@ -48,7 +48,7 @@ export class ExportService {
|
||||
await this.accountService.accounts({
|
||||
include: {
|
||||
balances: true,
|
||||
Platform: true
|
||||
platform: true
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc'
|
||||
@ -72,7 +72,7 @@ export class ExportService {
|
||||
id,
|
||||
isExcluded,
|
||||
name,
|
||||
Platform: platform,
|
||||
platform,
|
||||
platformId
|
||||
}) => {
|
||||
if (platformId) {
|
||||
|
@ -1,4 +1,8 @@
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
||||
import {
|
||||
DataEnhancerHealthResponse,
|
||||
DataProviderHealthResponse
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
|
||||
import {
|
||||
Controller,
|
||||
@ -37,23 +41,30 @@ export class HealthController {
|
||||
}
|
||||
|
||||
@Get('data-enhancer/:name')
|
||||
public async getHealthOfDataEnhancer(@Param('name') name: string) {
|
||||
public async getHealthOfDataEnhancer(
|
||||
@Param('name') name: string,
|
||||
@Res() response: Response
|
||||
): Promise<Response<DataEnhancerHealthResponse>> {
|
||||
const hasResponse =
|
||||
await this.healthService.hasResponseFromDataEnhancer(name);
|
||||
|
||||
if (hasResponse !== true) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE),
|
||||
StatusCodes.SERVICE_UNAVAILABLE
|
||||
);
|
||||
if (hasResponse) {
|
||||
return response.status(HttpStatus.OK).json({
|
||||
status: getReasonPhrase(StatusCodes.OK)
|
||||
});
|
||||
} else {
|
||||
return response
|
||||
.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.json({ status: getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE) });
|
||||
}
|
||||
}
|
||||
|
||||
@Get('data-provider/:dataSource')
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async getHealthOfDataProvider(
|
||||
@Param('dataSource') dataSource: DataSource
|
||||
) {
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Res() response: Response
|
||||
): Promise<Response<DataProviderHealthResponse>> {
|
||||
if (!DataSource[dataSource]) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
@ -64,11 +75,14 @@ export class HealthController {
|
||||
const hasResponse =
|
||||
await this.healthService.hasResponseFromDataProvider(dataSource);
|
||||
|
||||
if (hasResponse !== true) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE),
|
||||
StatusCodes.SERVICE_UNAVAILABLE
|
||||
);
|
||||
if (hasResponse) {
|
||||
return response
|
||||
.status(HttpStatus.OK)
|
||||
.json({ status: getReasonPhrase(StatusCodes.OK) });
|
||||
} else {
|
||||
return response
|
||||
.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.json({ status: getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -6,5 +6,5 @@ import { IsArray, IsOptional } from 'class-validator';
|
||||
export class CreateAccountWithBalancesDto extends CreateAccountDto {
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
balances?: AccountBalance;
|
||||
balances?: AccountBalance[];
|
||||
}
|
||||
|
@ -71,7 +71,7 @@ export class ImportController {
|
||||
const activities = await this.importService.import({
|
||||
isDryRun,
|
||||
maxActivitiesToImport,
|
||||
accountsDto: importData.accounts ?? [],
|
||||
accountsWithBalancesDto: importData.accounts ?? [],
|
||||
activitiesDto: importData.activities,
|
||||
user: this.request.user
|
||||
});
|
||||
|
@ -28,9 +28,11 @@ import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
|
||||
import { Big } from 'big.js';
|
||||
import { endOfToday, isAfter, isSameSecond, parseISO } from 'date-fns';
|
||||
import { uniqBy } from 'lodash';
|
||||
import { omit, uniqBy } from 'lodash';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { ImportDataDto } from './import-data.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ImportService {
|
||||
public constructor(
|
||||
@ -69,14 +71,14 @@ export class ImportService {
|
||||
]);
|
||||
|
||||
const accounts = activities
|
||||
.filter(({ Account }) => {
|
||||
return !!Account;
|
||||
.filter(({ account }) => {
|
||||
return !!account;
|
||||
})
|
||||
.map(({ Account }) => {
|
||||
return Account;
|
||||
.map(({ account }) => {
|
||||
return account;
|
||||
});
|
||||
|
||||
const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;
|
||||
const account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;
|
||||
|
||||
return await Promise.all(
|
||||
Object.entries(dividends).map(async ([dateString, { marketPrice }]) => {
|
||||
@ -90,7 +92,7 @@ export class ImportService {
|
||||
const date = parseDate(dateString);
|
||||
const isDuplicate = activities.some((activity) => {
|
||||
return (
|
||||
activity.accountId === Account?.id &&
|
||||
activity.accountId === account?.id &&
|
||||
activity.SymbolProfile.currency === assetProfile.currency &&
|
||||
activity.SymbolProfile.dataSource === assetProfile.dataSource &&
|
||||
isSameSecond(activity.date, date) &&
|
||||
@ -106,12 +108,12 @@ export class ImportService {
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
Account,
|
||||
account,
|
||||
date,
|
||||
error,
|
||||
quantity,
|
||||
value,
|
||||
accountId: Account?.id,
|
||||
accountId: account?.id,
|
||||
accountUserId: undefined,
|
||||
comment: undefined,
|
||||
currency: undefined,
|
||||
@ -127,7 +129,7 @@ export class ImportService {
|
||||
unitPrice: marketPrice,
|
||||
unitPriceInAssetProfileCurrency: marketPrice,
|
||||
updatedAt: undefined,
|
||||
userId: Account?.userId,
|
||||
userId: account?.userId,
|
||||
valueInBaseCurrency: value
|
||||
};
|
||||
})
|
||||
@ -138,14 +140,14 @@ export class ImportService {
|
||||
}
|
||||
|
||||
public async import({
|
||||
accountsDto,
|
||||
accountsWithBalancesDto,
|
||||
activitiesDto,
|
||||
isDryRun = false,
|
||||
maxActivitiesToImport,
|
||||
user
|
||||
}: {
|
||||
accountsDto: Partial<CreateAccountDto>[];
|
||||
activitiesDto: Partial<CreateOrderDto>[];
|
||||
accountsWithBalancesDto: ImportDataDto['accounts'];
|
||||
activitiesDto: ImportDataDto['activities'];
|
||||
isDryRun?: boolean;
|
||||
maxActivitiesToImport: number;
|
||||
user: UserWithSettings;
|
||||
@ -153,12 +155,12 @@ export class ImportService {
|
||||
const accountIdMapping: { [oldAccountId: string]: string } = {};
|
||||
const userCurrency = user.Settings.settings.baseCurrency;
|
||||
|
||||
if (!isDryRun && accountsDto?.length) {
|
||||
if (!isDryRun && accountsWithBalancesDto?.length) {
|
||||
const [existingAccounts, existingPlatforms] = await Promise.all([
|
||||
this.accountService.accounts({
|
||||
where: {
|
||||
id: {
|
||||
in: accountsDto.map(({ id }) => {
|
||||
in: accountsWithBalancesDto.map(({ id }) => {
|
||||
return id;
|
||||
})
|
||||
}
|
||||
@ -167,14 +169,19 @@ export class ImportService {
|
||||
this.platformService.getPlatforms()
|
||||
]);
|
||||
|
||||
for (const account of accountsDto) {
|
||||
for (const accountWithBalances of accountsWithBalancesDto) {
|
||||
// Check if there is any existing account with the same ID
|
||||
const accountWithSameId = existingAccounts.find((existingAccount) => {
|
||||
return existingAccount.id === account.id;
|
||||
return existingAccount.id === accountWithBalances.id;
|
||||
});
|
||||
|
||||
// If there is no account or if the account belongs to a different user then create a new account
|
||||
if (!accountWithSameId || accountWithSameId.userId !== user.id) {
|
||||
const account: CreateAccountDto = omit(
|
||||
accountWithBalances,
|
||||
'balances'
|
||||
);
|
||||
|
||||
let oldAccountId: string;
|
||||
const platformId = account.platformId;
|
||||
|
||||
@ -187,7 +194,10 @@ export class ImportService {
|
||||
|
||||
let accountObject: Prisma.AccountCreateInput = {
|
||||
...account,
|
||||
User: { connect: { id: user.id } }
|
||||
balances: {
|
||||
create: accountWithBalances.balances ?? []
|
||||
},
|
||||
user: { connect: { id: user.id } }
|
||||
};
|
||||
|
||||
if (
|
||||
@ -197,7 +207,7 @@ export class ImportService {
|
||||
) {
|
||||
accountObject = {
|
||||
...accountObject,
|
||||
Platform: { connect: { id: platformId } }
|
||||
platform: { connect: { id: platformId } }
|
||||
};
|
||||
}
|
||||
|
||||
@ -251,7 +261,7 @@ export class ImportService {
|
||||
);
|
||||
|
||||
if (isDryRun) {
|
||||
accountsDto.forEach(({ id, name }) => {
|
||||
accountsWithBalancesDto.forEach(({ id, name }) => {
|
||||
accounts.push({ id, name });
|
||||
});
|
||||
}
|
||||
@ -386,7 +396,7 @@ export class ImportService {
|
||||
}
|
||||
},
|
||||
updateAccountBalance: false,
|
||||
User: { connect: { id: user.id } },
|
||||
user: { connect: { id: user.id } },
|
||||
userId: user.id
|
||||
});
|
||||
|
||||
|
@ -133,11 +133,11 @@ export class InfoService {
|
||||
AND: [
|
||||
{
|
||||
NOT: {
|
||||
Analytics: null
|
||||
analytics: null
|
||||
}
|
||||
},
|
||||
{
|
||||
Analytics: {
|
||||
analytics: {
|
||||
lastRequestAt: {
|
||||
gt: subDays(new Date(), aDays)
|
||||
}
|
||||
@ -216,7 +216,7 @@ export class InfoService {
|
||||
AND: [
|
||||
{
|
||||
NOT: {
|
||||
Analytics: null
|
||||
analytics: null
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@ -9,7 +9,7 @@ export interface Activities {
|
||||
}
|
||||
|
||||
export interface Activity extends Order {
|
||||
Account?: AccountWithPlatform;
|
||||
account?: AccountWithPlatform;
|
||||
error?: ActivityError;
|
||||
feeInAssetProfileCurrency: number;
|
||||
feeInBaseCurrency: number;
|
||||
|
@ -53,14 +53,19 @@ export class OrderController {
|
||||
@Delete()
|
||||
@HasPermission(permissions.deleteOrder)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async deleteOrders(
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('dataSource') filterByDataSource?: string,
|
||||
@Query('symbol') filterBySymbol?: string,
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<number> {
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByDataSource,
|
||||
filterBySymbol,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
@ -209,7 +214,7 @@ export class OrderController {
|
||||
}
|
||||
}
|
||||
},
|
||||
User: { connect: { id: this.request.user.id } },
|
||||
user: { connect: { id: this.request.user.id } },
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
@ -264,7 +269,7 @@ export class OrderController {
|
||||
data: {
|
||||
...data,
|
||||
date,
|
||||
Account: {
|
||||
account: {
|
||||
connect: {
|
||||
id_userId: { id: accountId, userId: this.request.user.id }
|
||||
}
|
||||
@ -282,7 +287,7 @@ export class OrderController {
|
||||
name: data.symbol
|
||||
}
|
||||
},
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
user: { connect: { id: this.request.user.id } }
|
||||
},
|
||||
where: {
|
||||
id
|
||||
|
@ -100,10 +100,10 @@ export class OrderService {
|
||||
userId: string;
|
||||
}
|
||||
): Promise<Order> {
|
||||
let Account: Prisma.AccountCreateNestedOneWithoutActivitiesInput;
|
||||
let account: Prisma.AccountCreateNestedOneWithoutActivitiesInput;
|
||||
|
||||
if (data.accountId) {
|
||||
Account = {
|
||||
account = {
|
||||
connect: {
|
||||
id_userId: {
|
||||
userId: data.userId,
|
||||
@ -179,7 +179,7 @@ export class OrderService {
|
||||
const order = await this.prismaService.order.create({
|
||||
data: {
|
||||
...orderData,
|
||||
Account,
|
||||
account,
|
||||
isDraft,
|
||||
tags: {
|
||||
connect: tags.map(({ id }) => {
|
||||
@ -475,8 +475,8 @@ export class OrderService {
|
||||
|
||||
if (withExcludedAccounts === false) {
|
||||
where.OR = [
|
||||
{ Account: null },
|
||||
{ Account: { NOT: { isExcluded: true } } }
|
||||
{ account: null },
|
||||
{ account: { NOT: { isExcluded: true } } }
|
||||
];
|
||||
}
|
||||
|
||||
@ -487,10 +487,9 @@ export class OrderService {
|
||||
take,
|
||||
where,
|
||||
include: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
Account: {
|
||||
account: {
|
||||
include: {
|
||||
Platform: true
|
||||
platform: true
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
@ -650,8 +649,8 @@ export class OrderService {
|
||||
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)) {
|
||||
delete data.SymbolProfile.connect;
|
||||
|
||||
if (data.Account?.connect?.id_userId?.id === null) {
|
||||
data.Account = { disconnect: true };
|
||||
if (data.account?.connect?.id_userId?.id === null) {
|
||||
data.account = { disconnect: true };
|
||||
}
|
||||
} else {
|
||||
delete data.SymbolProfile.update;
|
||||
|
@ -63,7 +63,7 @@ describe('PortfolioCalculator', () => {
|
||||
|
||||
beforeAll(() => {
|
||||
activityDtos = loadActivityExportFile(
|
||||
join(__dirname, '../../../../../../../test/import/ok-btceur.json')
|
||||
join(__dirname, '../../../../../../../test/import/ok/btceur.json')
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -63,7 +63,7 @@ describe('PortfolioCalculator', () => {
|
||||
|
||||
beforeAll(() => {
|
||||
activityDtos = loadActivityExportFile(
|
||||
join(__dirname, '../../../../../../../test/import/ok-btcusd.json')
|
||||
join(__dirname, '../../../../../../../test/import/ok/btcusd.json')
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -65,7 +65,7 @@ describe('PortfolioCalculator', () => {
|
||||
activityDtos = loadActivityExportFile(
|
||||
join(
|
||||
__dirname,
|
||||
'../../../../../../../test/import/ok-novn-buy-and-sell-partially.json'
|
||||
'../../../../../../../test/import/ok/novn-buy-and-sell-partially.json'
|
||||
)
|
||||
);
|
||||
});
|
||||
|
@ -65,7 +65,7 @@ describe('PortfolioCalculator', () => {
|
||||
activityDtos = loadActivityExportFile(
|
||||
join(
|
||||
__dirname,
|
||||
'../../../../../../../test/import/ok-novn-buy-and-sell.json'
|
||||
'../../../../../../../test/import/ok/novn-buy-and-sell.json'
|
||||
)
|
||||
);
|
||||
});
|
||||
|
@ -13,6 +13,7 @@ import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.mo
|
||||
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 { I18nModule } from '@ghostfolio/api/services/i18n/i18n.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';
|
||||
@ -39,6 +40,7 @@ import { RulesService } from './rules.service';
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
I18nModule,
|
||||
ImpersonationModule,
|
||||
MarketDataModule,
|
||||
OrderModule,
|
||||
|
@ -23,6 +23,7 @@ import { RegionalMarketClusterRiskNorthAmerica } from '@ghostfolio/api/models/ru
|
||||
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||
import {
|
||||
@ -31,7 +32,7 @@ import {
|
||||
} from '@ghostfolio/common/calculation-helper';
|
||||
import {
|
||||
DEFAULT_CURRENCY,
|
||||
EMERGENCY_FUND_TAG_ID,
|
||||
TAG_ID_EMERGENCY_FUND,
|
||||
UNKNOWN_KEY
|
||||
} from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, getSum, parseDate } from '@ghostfolio/common/helper';
|
||||
@ -105,6 +106,7 @@ export class PortfolioService {
|
||||
private readonly calculatorFactory: PortfolioCalculatorFactory,
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly i18nService: I18nService,
|
||||
private readonly impersonationService: ImpersonationService,
|
||||
private readonly orderService: OrderService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
@ -156,7 +158,10 @@ export class PortfolioService {
|
||||
const [accounts, details] = await Promise.all([
|
||||
this.accountService.accounts({
|
||||
where,
|
||||
include: { activities: true, Platform: true },
|
||||
include: {
|
||||
activities: true,
|
||||
platform: true
|
||||
},
|
||||
orderBy: { name: 'asc' }
|
||||
}),
|
||||
this.getDetails({
|
||||
@ -542,7 +547,7 @@ export class PortfolioService {
|
||||
}
|
||||
|
||||
if (filters?.length === 0 || isFilteredByAccount || isFilteredByCash) {
|
||||
const cashPositions = await this.getCashPositions({
|
||||
const cashPositions = this.getCashPositions({
|
||||
cashDetails,
|
||||
userCurrency,
|
||||
value: filteredValueInBaseCurrency
|
||||
@ -564,10 +569,10 @@ export class PortfolioService {
|
||||
|
||||
if (
|
||||
filters?.length === 1 &&
|
||||
filters[0].id === EMERGENCY_FUND_TAG_ID &&
|
||||
filters[0].id === TAG_ID_EMERGENCY_FUND &&
|
||||
filters[0].type === 'TAG'
|
||||
) {
|
||||
const emergencyFundCashPositions = await this.getCashPositions({
|
||||
const emergencyFundCashPositions = this.getCashPositions({
|
||||
cashDetails,
|
||||
userCurrency,
|
||||
value: filteredValueInBaseCurrency
|
||||
@ -663,7 +668,7 @@ export class PortfolioService {
|
||||
grossPerformancePercentWithCurrencyEffect: undefined,
|
||||
grossPerformanceWithCurrencyEffect: undefined,
|
||||
historicalData: [],
|
||||
investment: undefined,
|
||||
investmentInBaseCurrencyWithCurrencyEffect: undefined,
|
||||
marketPrice: undefined,
|
||||
marketPriceMax: undefined,
|
||||
marketPriceMin: undefined,
|
||||
@ -853,7 +858,8 @@ export class PortfolioService {
|
||||
grossPerformanceWithCurrencyEffect:
|
||||
position.grossPerformanceWithCurrencyEffect?.toNumber(),
|
||||
historicalData: historicalDataArray,
|
||||
investment: position.investment?.toNumber(),
|
||||
investmentInBaseCurrencyWithCurrencyEffect:
|
||||
position.investmentWithCurrencyEffect?.toNumber(),
|
||||
netPerformance: position.netPerformance?.toNumber(),
|
||||
netPerformancePercent: position.netPerformancePercentage?.toNumber(),
|
||||
netPerformancePercentWithCurrencyEffect:
|
||||
@ -952,7 +958,7 @@ export class PortfolioService {
|
||||
grossPerformancePercentWithCurrencyEffect: undefined,
|
||||
grossPerformanceWithCurrencyEffect: undefined,
|
||||
historicalData: historicalDataArray,
|
||||
investment: 0,
|
||||
investmentInBaseCurrencyWithCurrencyEffect: 0,
|
||||
netPerformance: undefined,
|
||||
netPerformancePercent: undefined,
|
||||
netPerformancePercentWithCurrencyEffect: undefined,
|
||||
@ -1254,10 +1260,14 @@ export class PortfolioService {
|
||||
[
|
||||
new AccountClusterRiskCurrentInvestment(
|
||||
this.exchangeRateDataService,
|
||||
this.i18nService,
|
||||
userSettings.language,
|
||||
accounts
|
||||
),
|
||||
new AccountClusterRiskSingleAccount(
|
||||
this.exchangeRateDataService,
|
||||
this.i18nService,
|
||||
userSettings.language,
|
||||
accounts
|
||||
)
|
||||
],
|
||||
@ -1270,10 +1280,14 @@ export class PortfolioService {
|
||||
[
|
||||
new AssetClassClusterRiskEquity(
|
||||
this.exchangeRateDataService,
|
||||
this.i18nService,
|
||||
userSettings.language,
|
||||
Object.values(holdings)
|
||||
),
|
||||
new AssetClassClusterRiskFixedIncome(
|
||||
this.exchangeRateDataService,
|
||||
this.i18nService,
|
||||
userSettings.language,
|
||||
Object.values(holdings)
|
||||
)
|
||||
],
|
||||
@ -1286,11 +1300,15 @@ export class PortfolioService {
|
||||
[
|
||||
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
|
||||
this.exchangeRateDataService,
|
||||
Object.values(holdings)
|
||||
this.i18nService,
|
||||
Object.values(holdings),
|
||||
userSettings.language
|
||||
),
|
||||
new CurrencyClusterRiskCurrentInvestment(
|
||||
this.exchangeRateDataService,
|
||||
Object.values(holdings)
|
||||
this.i18nService,
|
||||
Object.values(holdings),
|
||||
userSettings.language
|
||||
)
|
||||
],
|
||||
userSettings
|
||||
@ -1318,6 +1336,8 @@ export class PortfolioService {
|
||||
[
|
||||
new EmergencyFundSetup(
|
||||
this.exchangeRateDataService,
|
||||
this.i18nService,
|
||||
userSettings.language,
|
||||
this.getTotalEmergencyFund({
|
||||
userSettings,
|
||||
emergencyFundHoldingsValueInBaseCurrency:
|
||||
@ -1331,6 +1351,8 @@ export class PortfolioService {
|
||||
[
|
||||
new FeeRatioInitialInvestment(
|
||||
this.exchangeRateDataService,
|
||||
this.i18nService,
|
||||
userSettings.language,
|
||||
summary.committedFunds,
|
||||
summary.fees
|
||||
)
|
||||
@ -1532,7 +1554,7 @@ export class PortfolioService {
|
||||
return { markets, marketsAdvanced };
|
||||
}
|
||||
|
||||
private async getCashPositions({
|
||||
private getCashPositions({
|
||||
cashDetails,
|
||||
userCurrency,
|
||||
value
|
||||
@ -1654,7 +1676,7 @@ export class PortfolioService {
|
||||
const emergencyFundHoldings = Object.values(holdings).filter(({ tags }) => {
|
||||
return (
|
||||
tags?.some(({ id }) => {
|
||||
return id === EMERGENCY_FUND_TAG_ID;
|
||||
return id === TAG_ID_EMERGENCY_FUND;
|
||||
}) ?? false
|
||||
);
|
||||
});
|
||||
@ -1861,7 +1883,7 @@ export class PortfolioService {
|
||||
const nonExcludedActivities: Activity[] = [];
|
||||
|
||||
for (const activity of activities) {
|
||||
if (activity.Account?.isExcluded) {
|
||||
if (activity.account?.isExcluded) {
|
||||
excludedActivities.push(activity);
|
||||
} else {
|
||||
nonExcludedActivities.push(activity);
|
||||
@ -2098,14 +2120,14 @@ export class PortfolioService {
|
||||
|
||||
let currentAccounts: (Account & {
|
||||
Order?: Order[];
|
||||
Platform?: Platform;
|
||||
platform?: Platform;
|
||||
})[] = [];
|
||||
|
||||
if (filters.length === 0) {
|
||||
currentAccounts = await this.accountService.getAccounts(userId);
|
||||
} else if (filters.length === 1 && filters[0].type === 'ACCOUNT') {
|
||||
currentAccounts = await this.accountService.accounts({
|
||||
include: { Platform: true },
|
||||
include: { platform: true },
|
||||
where: { id: filters[0].id }
|
||||
});
|
||||
} else {
|
||||
@ -2122,7 +2144,7 @@ export class PortfolioService {
|
||||
);
|
||||
|
||||
currentAccounts = await this.accountService.accounts({
|
||||
include: { Platform: true },
|
||||
include: { platform: true },
|
||||
where: { id: { in: accountIds } }
|
||||
});
|
||||
}
|
||||
@ -2147,18 +2169,18 @@ export class PortfolioService {
|
||||
)
|
||||
};
|
||||
|
||||
if (platforms[account.Platform?.id || UNKNOWN_KEY]?.valueInBaseCurrency) {
|
||||
platforms[account.Platform?.id || UNKNOWN_KEY].valueInBaseCurrency +=
|
||||
if (platforms[account.platformId || UNKNOWN_KEY]?.valueInBaseCurrency) {
|
||||
platforms[account.platformId || UNKNOWN_KEY].valueInBaseCurrency +=
|
||||
this.exchangeRateDataService.toCurrency(
|
||||
account.balance,
|
||||
account.currency,
|
||||
userCurrency
|
||||
);
|
||||
} else {
|
||||
platforms[account.Platform?.id || UNKNOWN_KEY] = {
|
||||
platforms[account.platformId || UNKNOWN_KEY] = {
|
||||
balance: account.balance,
|
||||
currency: account.currency,
|
||||
name: account.Platform?.name,
|
||||
name: account.platform?.name,
|
||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
account.balance,
|
||||
account.currency,
|
||||
@ -2168,7 +2190,7 @@ export class PortfolioService {
|
||||
}
|
||||
|
||||
for (const {
|
||||
Account,
|
||||
account,
|
||||
quantity,
|
||||
SymbolProfile,
|
||||
type
|
||||
@ -2179,28 +2201,28 @@ export class PortfolioService {
|
||||
(portfolioItemsNow[SymbolProfile.symbol]?.marketPriceInBaseCurrency ??
|
||||
0);
|
||||
|
||||
if (accounts[Account?.id || UNKNOWN_KEY]?.valueInBaseCurrency) {
|
||||
accounts[Account?.id || UNKNOWN_KEY].valueInBaseCurrency +=
|
||||
if (accounts[account?.id || UNKNOWN_KEY]?.valueInBaseCurrency) {
|
||||
accounts[account?.id || UNKNOWN_KEY].valueInBaseCurrency +=
|
||||
currentValueOfSymbolInBaseCurrency;
|
||||
} else {
|
||||
accounts[Account?.id || UNKNOWN_KEY] = {
|
||||
accounts[account?.id || UNKNOWN_KEY] = {
|
||||
balance: 0,
|
||||
currency: Account?.currency,
|
||||
currency: account?.currency,
|
||||
name: account.name,
|
||||
valueInBaseCurrency: currentValueOfSymbolInBaseCurrency
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
platforms[Account?.Platform?.id || UNKNOWN_KEY]?.valueInBaseCurrency
|
||||
platforms[account?.platformId || UNKNOWN_KEY]?.valueInBaseCurrency
|
||||
) {
|
||||
platforms[Account?.Platform?.id || UNKNOWN_KEY].valueInBaseCurrency +=
|
||||
platforms[account?.platformId || UNKNOWN_KEY].valueInBaseCurrency +=
|
||||
currentValueOfSymbolInBaseCurrency;
|
||||
} else {
|
||||
platforms[Account?.Platform?.id || UNKNOWN_KEY] = {
|
||||
platforms[account?.platformId || UNKNOWN_KEY] = {
|
||||
balance: 0,
|
||||
currency: Account?.currency,
|
||||
name: account.Platform?.name,
|
||||
currency: account?.currency,
|
||||
name: account.platform?.name,
|
||||
valueInBaseCurrency: currentValueOfSymbolInBaseCurrency
|
||||
};
|
||||
}
|
||||
|
@ -5,17 +5,28 @@ import { AssetProfileIdentifier, Filter } from '@ghostfolio/common/interfaces';
|
||||
import { CACHE_MANAGER, Cache } from '@nestjs/cache-manager';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { createHash } from 'crypto';
|
||||
import Keyv from 'keyv';
|
||||
import ms from 'ms';
|
||||
|
||||
@Injectable()
|
||||
export class RedisCacheService {
|
||||
private client: Keyv;
|
||||
|
||||
public constructor(
|
||||
@Inject(CACHE_MANAGER) private readonly cache: Cache,
|
||||
private readonly configurationService: ConfigurationService
|
||||
) {
|
||||
const client = cache.stores[0];
|
||||
this.client = cache.stores[0];
|
||||
|
||||
client.on('error', (error) => {
|
||||
this.client.deserialize = (value) => {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
this.client.on('error', (error) => {
|
||||
Logger.error(error, 'RedisCacheService');
|
||||
});
|
||||
}
|
||||
@ -28,28 +39,13 @@ export class RedisCacheService {
|
||||
const keys: string[] = [];
|
||||
const prefix = aPrefix;
|
||||
|
||||
this.cache.stores[0].deserialize = (value) => {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (error: any) {
|
||||
if (error instanceof SyntaxError) {
|
||||
Logger.debug(
|
||||
`Failed to parse json, returning the value as String: ${value}`,
|
||||
'RedisCacheService'
|
||||
);
|
||||
|
||||
return value;
|
||||
} else {
|
||||
throw error;
|
||||
try {
|
||||
for await (const [key] of this.client.iterator({})) {
|
||||
if ((prefix && key.startsWith(prefix)) || !prefix) {
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for await (const [key] of this.cache.stores[0].iterator({})) {
|
||||
if ((prefix && key.startsWith(prefix)) || !prefix) {
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return keys;
|
||||
}
|
||||
@ -79,12 +75,22 @@ export class RedisCacheService {
|
||||
}
|
||||
|
||||
public async isHealthy() {
|
||||
const testKey = '__health_check__';
|
||||
const testValue = Date.now().toString();
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
this.getKeys(),
|
||||
(async () => {
|
||||
await this.set(testKey, testValue, ms('1 second'));
|
||||
const result = await this.get(testKey);
|
||||
|
||||
if (result !== testValue) {
|
||||
throw new Error('Redis health check failed: value mismatch');
|
||||
}
|
||||
})(),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error('Redis health check timeout')),
|
||||
() => reject(new Error('Redis health check failed: timeout')),
|
||||
ms('2 seconds')
|
||||
)
|
||||
)
|
||||
@ -92,7 +98,13 @@ export class RedisCacheService {
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
Logger.error(error?.message, 'RedisCacheService');
|
||||
|
||||
return false;
|
||||
} finally {
|
||||
try {
|
||||
await this.remove(testKey);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,80 +0,0 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import {
|
||||
DATE_FORMAT,
|
||||
getYesterday,
|
||||
interpolate
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools';
|
||||
|
||||
import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common';
|
||||
import { format } from 'date-fns';
|
||||
import { Response } from 'express';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
@Controller('sitemap.xml')
|
||||
export class SitemapController {
|
||||
public sitemapXml = '';
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService
|
||||
) {
|
||||
try {
|
||||
this.sitemapXml = readFileSync(
|
||||
join(__dirname, 'assets', 'sitemap.xml'),
|
||||
'utf8'
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@Get()
|
||||
@Version(VERSION_NEUTRAL)
|
||||
public async getSitemapXml(@Res() response: Response): Promise<void> {
|
||||
const currentDate = format(getYesterday(), DATE_FORMAT);
|
||||
|
||||
response.setHeader('content-type', 'application/xml');
|
||||
response.send(
|
||||
interpolate(this.sitemapXml, {
|
||||
currentDate,
|
||||
personalFinanceTools: this.configurationService.get(
|
||||
'ENABLE_FEATURE_SUBSCRIPTION'
|
||||
)
|
||||
? personalFinanceTools
|
||||
.map(({ alias, key }) => {
|
||||
return [
|
||||
'<url>',
|
||||
` <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-${alias ?? key}</loc>`,
|
||||
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
|
||||
'</url>',
|
||||
'<url>',
|
||||
` <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-${alias ?? key}</loc>`,
|
||||
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
|
||||
'</url>',
|
||||
'<url>',
|
||||
` <loc>https://ghostfol.io/es/recursos/personal-finance-tools/alternativa-de-software-libre-a-${alias ?? key}</loc>`,
|
||||
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
|
||||
'</url>',
|
||||
'<url>',
|
||||
` <loc>https://ghostfol.io/fr/ressources/personal-finance-tools/alternative-open-source-a-${alias ?? key}</loc>`,
|
||||
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
|
||||
'</url>',
|
||||
'<url>',
|
||||
` <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-${alias ?? key}</loc>`,
|
||||
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
|
||||
'</url>',
|
||||
'<url>',
|
||||
` <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-${alias ?? key}</loc>`,
|
||||
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
|
||||
'</url>',
|
||||
'<url>',
|
||||
` <loc>https://ghostfol.io/pt/recursos/personal-finance-tools/alternativa-de-software-livre-ao-${alias ?? key}</loc>`,
|
||||
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`,
|
||||
'</url>'
|
||||
].join('\n');
|
||||
})
|
||||
.join('\n')
|
||||
: ''
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
@ -32,7 +32,7 @@ export class SubscriptionService {
|
||||
this.stripe = new Stripe(
|
||||
this.configurationService.get('STRIPE_SECRET_KEY'),
|
||||
{
|
||||
apiVersion: '2024-09-30.acacia'
|
||||
apiVersion: '2025-05-28.basil'
|
||||
}
|
||||
);
|
||||
}
|
||||
@ -61,7 +61,7 @@ export class SubscriptionService {
|
||||
|
||||
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
|
||||
cancel_url: `${this.configurationService.get('ROOT_URL')}/${
|
||||
user.Settings?.settings?.language ?? DEFAULT_LANGUAGE_CODE
|
||||
user.Settings.settings.language
|
||||
}/account`,
|
||||
client_reference_id: user.id,
|
||||
line_items: [
|
||||
|
6
apps/api/src/app/user/update-own-access-token.dto.ts
Normal file
6
apps/api/src/app/user/update-own-access-token.dto.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class UpdateOwnAccessTokenDto {
|
||||
@IsString()
|
||||
accessToken: string;
|
||||
}
|
@ -33,6 +33,7 @@ import { merge, size } from 'lodash';
|
||||
|
||||
import { DeleteOwnUserDto } from './delete-own-user.dto';
|
||||
import { UserItem } from './interfaces/user-item.interface';
|
||||
import { UpdateOwnAccessTokenDto } from './update-own-access-token.dto';
|
||||
import { UpdateUserSettingDto } from './update-user-setting.dto';
|
||||
import { UserService } from './user.service';
|
||||
|
||||
@ -53,24 +54,12 @@ export class UserController {
|
||||
public async deleteOwnUser(
|
||||
@Body() data: DeleteOwnUserDto
|
||||
): Promise<UserModel> {
|
||||
const hashedAccessToken = this.userService.createAccessToken({
|
||||
password: data.accessToken,
|
||||
salt: this.configurationService.get('ACCESS_TOKEN_SALT')
|
||||
});
|
||||
|
||||
const [user] = await this.userService.users({
|
||||
where: { accessToken: hashedAccessToken, id: this.request.user.id }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
const user = await this.validateAccessToken(
|
||||
data.accessToken,
|
||||
this.request.user.id
|
||||
);
|
||||
|
||||
return this.userService.deleteUser({
|
||||
accessToken: hashedAccessToken,
|
||||
id: user.id
|
||||
});
|
||||
}
|
||||
@ -94,20 +83,24 @@ export class UserController {
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Post(':id/access-token')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async generateAccessToken(
|
||||
public async updateUserAccessToken(
|
||||
@Param('id') id: string
|
||||
): Promise<AccessTokenResponse> {
|
||||
const { accessToken, hashedAccessToken } =
|
||||
this.userService.generateAccessToken({
|
||||
userId: id
|
||||
});
|
||||
return this.rotateUserAccessToken(id);
|
||||
}
|
||||
|
||||
await this.prismaService.user.update({
|
||||
data: { accessToken: hashedAccessToken },
|
||||
where: { id }
|
||||
});
|
||||
@HasPermission(permissions.updateOwnAccessToken)
|
||||
@Post('access-token')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async updateOwnAccessToken(
|
||||
@Body() data: UpdateOwnAccessTokenDto
|
||||
): Promise<AccessTokenResponse> {
|
||||
const user = await this.validateAccessToken(
|
||||
data.accessToken,
|
||||
this.request.user.id
|
||||
);
|
||||
|
||||
return { accessToken };
|
||||
return this.rotateUserAccessToken(user.id);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ -189,4 +182,43 @@ export class UserController {
|
||||
userId: this.request.user.id
|
||||
});
|
||||
}
|
||||
|
||||
private async rotateUserAccessToken(
|
||||
userId: string
|
||||
): Promise<AccessTokenResponse> {
|
||||
const { accessToken, hashedAccessToken } =
|
||||
this.userService.generateAccessToken({
|
||||
userId
|
||||
});
|
||||
|
||||
await this.prismaService.user.update({
|
||||
data: { accessToken: hashedAccessToken },
|
||||
where: { id: userId }
|
||||
});
|
||||
|
||||
return { accessToken };
|
||||
}
|
||||
|
||||
private async validateAccessToken(
|
||||
accessToken: string,
|
||||
userId: string
|
||||
): Promise<UserModel> {
|
||||
const hashedAccessToken = this.userService.createAccessToken({
|
||||
password: accessToken,
|
||||
salt: this.configurationService.get('ACCESS_TOKEN_SALT')
|
||||
});
|
||||
|
||||
const [user] = await this.userService.users({
|
||||
where: { accessToken: hashedAccessToken, id: userId }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
|
||||
@ -16,6 +17,7 @@ import { UserService } from './user.service';
|
||||
exports: [UserService],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
I18nModule,
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET_KEY,
|
||||
signOptions: { expiresIn: '30 days' }
|
||||
|
@ -52,11 +52,10 @@ import { sortBy, without } from 'lodash';
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
private i18nService = new I18nService();
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
private readonly i18nService: I18nService,
|
||||
private readonly orderService: OrderService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly propertyService: PropertyService,
|
||||
@ -96,16 +95,16 @@ export class UserService {
|
||||
}
|
||||
|
||||
public async getUser(
|
||||
{ Account, id, permissions, Settings, subscription }: UserWithSettings,
|
||||
{ accounts, id, permissions, Settings, subscription }: UserWithSettings,
|
||||
aLocale = locale
|
||||
): Promise<IUser> {
|
||||
const userData = await Promise.all([
|
||||
this.prismaService.access.findMany({
|
||||
include: {
|
||||
User: true
|
||||
user: true
|
||||
},
|
||||
orderBy: { alias: 'asc' },
|
||||
where: { GranteeUser: { id } }
|
||||
where: { granteeUserId: id }
|
||||
}),
|
||||
this.prismaService.order.count({
|
||||
where: { userId: id }
|
||||
@ -142,6 +141,7 @@ export class UserService {
|
||||
}
|
||||
|
||||
return {
|
||||
accounts,
|
||||
activitiesCount,
|
||||
id,
|
||||
permissions,
|
||||
@ -155,7 +155,6 @@ export class UserService {
|
||||
permissions: accessItem.permissions
|
||||
};
|
||||
}),
|
||||
accounts: Account,
|
||||
dateOfFirstActivity: firstActivity?.date ?? new Date(),
|
||||
settings: {
|
||||
...(Settings.settings as UserSettings),
|
||||
@ -182,8 +181,8 @@ export class UserService {
|
||||
const {
|
||||
Access,
|
||||
accessToken,
|
||||
Account,
|
||||
Analytics,
|
||||
accounts,
|
||||
analytics,
|
||||
authChallenge,
|
||||
createdAt,
|
||||
id,
|
||||
@ -196,10 +195,10 @@ export class UserService {
|
||||
} = await this.prismaService.user.findUnique({
|
||||
include: {
|
||||
Access: true,
|
||||
Account: {
|
||||
include: { Platform: true }
|
||||
accounts: {
|
||||
include: { platform: true }
|
||||
},
|
||||
Analytics: true,
|
||||
analytics: true,
|
||||
Settings: true,
|
||||
subscriptions: true
|
||||
},
|
||||
@ -209,7 +208,7 @@ export class UserService {
|
||||
const user: UserWithSettings = {
|
||||
Access,
|
||||
accessToken,
|
||||
Account,
|
||||
accounts,
|
||||
authChallenge,
|
||||
createdAt,
|
||||
id,
|
||||
@ -218,9 +217,9 @@ export class UserService {
|
||||
Settings: Settings as UserWithSettings['Settings'],
|
||||
thirdPartyId,
|
||||
updatedAt,
|
||||
activityCount: Analytics?.activityCount,
|
||||
activityCount: analytics?.activityCount,
|
||||
dataProviderGhostfolioDailyRequests:
|
||||
Analytics?.dataProviderGhostfolioDailyRequests
|
||||
analytics?.dataProviderGhostfolioDailyRequests
|
||||
};
|
||||
|
||||
if (user?.Settings) {
|
||||
@ -260,28 +259,41 @@ export class UserService {
|
||||
|
||||
(user.Settings.settings as UserSettings).xRayRules = {
|
||||
AccountClusterRiskCurrentInvestment:
|
||||
new AccountClusterRiskCurrentInvestment(undefined, {}).getSettings(
|
||||
user.Settings.settings
|
||||
),
|
||||
new AccountClusterRiskCurrentInvestment(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{}
|
||||
).getSettings(user.Settings.settings),
|
||||
AccountClusterRiskSingleAccount: new AccountClusterRiskSingleAccount(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{}
|
||||
).getSettings(user.Settings.settings),
|
||||
AssetClassClusterRiskEquity: new AssetClassClusterRiskEquity(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
).getSettings(user.Settings.settings),
|
||||
AssetClassClusterRiskFixedIncome: new AssetClassClusterRiskFixedIncome(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
).getSettings(user.Settings.settings),
|
||||
CurrencyClusterRiskBaseCurrencyCurrentInvestment:
|
||||
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
).getSettings(user.Settings.settings),
|
||||
CurrencyClusterRiskCurrentInvestment:
|
||||
new CurrencyClusterRiskCurrentInvestment(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
).getSettings(user.Settings.settings),
|
||||
@ -298,10 +310,14 @@ export class UserService {
|
||||
undefined
|
||||
).getSettings(user.Settings.settings),
|
||||
EmergencyFundSetup: new EmergencyFundSetup(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
).getSettings(user.Settings.settings),
|
||||
FeeRatioInitialInvestment: new FeeRatioInitialInvestment(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
@ -338,6 +354,11 @@ export class UserService {
|
||||
|
||||
let currentPermissions = getPermissions(user.role);
|
||||
|
||||
if (user.provider === 'ANONYMOUS') {
|
||||
currentPermissions.push(permissions.deleteOwnUser);
|
||||
currentPermissions.push(permissions.updateOwnAccessToken);
|
||||
}
|
||||
|
||||
if (!(user.Settings.settings as UserSettings).isExperimentalFeatures) {
|
||||
// currentPermissions = without(
|
||||
// currentPermissions,
|
||||
@ -372,7 +393,7 @@ export class UserService {
|
||||
frequency = 6;
|
||||
}
|
||||
|
||||
if (Analytics?.activityCount % frequency === 1) {
|
||||
if (analytics?.activityCount % frequency === 1) {
|
||||
currentPermissions.push(permissions.enableSubscriptionInterstitial);
|
||||
}
|
||||
|
||||
@ -411,6 +432,10 @@ export class UserService {
|
||||
user.subscription.offer.durationExtension = undefined;
|
||||
user.subscription.offer.label = undefined;
|
||||
}
|
||||
|
||||
if (hasRole(user, Role.ADMIN)) {
|
||||
currentPermissions.push(permissions.syncDemoUserAccount);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
|
||||
@ -433,11 +458,11 @@ export class UserService {
|
||||
}
|
||||
}
|
||||
|
||||
if (!environment.production && role === 'ADMIN') {
|
||||
if (!environment.production && hasRole(user, Role.ADMIN)) {
|
||||
currentPermissions.push(permissions.impersonateAllUsers);
|
||||
}
|
||||
|
||||
user.Account = sortBy(user.Account, ({ name }) => {
|
||||
user.accounts = sortBy(user.accounts, ({ name }) => {
|
||||
return name.toLowerCase();
|
||||
});
|
||||
user.permissions = currentPermissions.sort();
|
||||
@ -474,7 +499,7 @@ export class UserService {
|
||||
const user = await this.prismaService.user.create({
|
||||
data: {
|
||||
...data,
|
||||
Account: {
|
||||
accounts: {
|
||||
create: {
|
||||
currency: DEFAULT_CURRENCY,
|
||||
name: this.i18nService.getTranslation({
|
||||
@ -496,7 +521,7 @@ export class UserService {
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
await this.prismaService.analytics.create({
|
||||
data: {
|
||||
User: { connect: { id: user.id } }
|
||||
user: { connect: { id: user.id } }
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -591,7 +616,7 @@ export class UserService {
|
||||
const { settings } = await this.prismaService.settings.upsert({
|
||||
create: {
|
||||
settings: userSettings as unknown as Prisma.JsonObject,
|
||||
User: {
|
||||
user: {
|
||||
connect: {
|
||||
id: userId
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -4,12 +4,10 @@
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
|
||||
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
|
||||
<!--
|
||||
<url>
|
||||
<loc>https://ghostfol.io/ca</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
-->
|
||||
<url>
|
||||
<loc>https://ghostfol.io/ca</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -317,7 +315,7 @@
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/fr/a-propos/changelog</loc>
|
||||
<loc>https://ghostfol.io/fr/a-propos/journal-des-modifications</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
@ -383,7 +381,7 @@
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/informazioni-su/changelog</loc>
|
||||
<loc>https://ghostfol.io/it/informazioni-su/registro-delle-modifiche</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
@ -454,10 +452,6 @@
|
||||
<loc>https://ghostfol.io/nl/over</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/over/changelog</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/over/licentie</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -470,6 +464,10 @@
|
||||
<loc>https://ghostfol.io/nl/over/privacybeleid</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/over/wijzigingslogboek</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/prijzen</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -494,12 +492,6 @@
|
||||
<loc>https://ghostfol.io/pl/cennik</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<!--
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pl/faq</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
-->
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pl/funkcje</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -584,12 +576,10 @@
|
||||
<loc>https://ghostfol.io/tr</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<!--
|
||||
<url>
|
||||
<loc>https://ghostfol.io/uk</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
-->
|
||||
<url>
|
||||
<loc>https://ghostfol.io/zh</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
|
@ -1,7 +1,8 @@
|
||||
import {
|
||||
DEFAULT_HOST,
|
||||
DEFAULT_PORT,
|
||||
STORYBOOK_PATH
|
||||
STORYBOOK_PATH,
|
||||
SUPPORTED_LANGUAGE_CODES
|
||||
} from '@ghostfolio/common/config';
|
||||
|
||||
import {
|
||||
@ -18,7 +19,6 @@ import helmet from 'helmet';
|
||||
|
||||
import { AppModule } from './app/app.module';
|
||||
import { environment } from './environments/environment';
|
||||
import { HtmlTemplateMiddleware } from './middlewares/html-template.middleware';
|
||||
|
||||
async function bootstrap() {
|
||||
const configApp = await NestFactory.create(AppModule);
|
||||
@ -44,7 +44,15 @@ async function bootstrap() {
|
||||
defaultVersion: '1',
|
||||
type: VersioningType.URI
|
||||
});
|
||||
app.setGlobalPrefix('api', { exclude: ['sitemap.xml'] });
|
||||
app.setGlobalPrefix('api', {
|
||||
exclude: [
|
||||
'sitemap.xml',
|
||||
...SUPPORTED_LANGUAGE_CODES.map((languageCode) => {
|
||||
// Exclude language-specific routes with an optional wildcard
|
||||
return `/${languageCode}{/*wildcard}`;
|
||||
})
|
||||
]
|
||||
});
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
forbidNonWhitelisted: true,
|
||||
@ -77,8 +85,6 @@ async function bootstrap() {
|
||||
});
|
||||
}
|
||||
|
||||
app.use(HtmlTemplateMiddleware);
|
||||
|
||||
const HOST = configService.get<string>('HOST') || DEFAULT_HOST;
|
||||
const PORT = configService.get<number>('PORT') || DEFAULT_PORT;
|
||||
|
||||
|
@ -7,30 +7,14 @@ import {
|
||||
} from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, interpolate } from '@ghostfolio/common/helper';
|
||||
|
||||
import { Injectable, Logger, NestMiddleware } from '@nestjs/common';
|
||||
import { format } from 'date-fns';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
import * as fs from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const i18nService = new I18nService();
|
||||
|
||||
let indexHtmlMap: { [languageCode: string]: string } = {};
|
||||
|
||||
const title = 'Ghostfolio';
|
||||
|
||||
try {
|
||||
indexHtmlMap = SUPPORTED_LANGUAGE_CODES.reduce(
|
||||
(map, languageCode) => ({
|
||||
...map,
|
||||
[languageCode]: fs.readFileSync(
|
||||
join(__dirname, '..', 'client', languageCode, 'index.html'),
|
||||
'utf8'
|
||||
)
|
||||
}),
|
||||
{}
|
||||
);
|
||||
} catch {}
|
||||
|
||||
const locales = {
|
||||
'/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt': {
|
||||
featureGraphicPath: 'assets/images/blog/ghostfolio-x-sackgeld.png',
|
||||
@ -94,71 +78,93 @@ const locales = {
|
||||
}
|
||||
};
|
||||
|
||||
const isFileRequest = (filename: string) => {
|
||||
if (filename === '/assets/LICENSE') {
|
||||
return true;
|
||||
} else if (
|
||||
filename.includes('auth/ey') ||
|
||||
filename.includes(
|
||||
'personal-finance-tools/open-source-alternative-to-de.fi'
|
||||
) ||
|
||||
filename.includes(
|
||||
'personal-finance-tools/open-source-alternative-to-markets.sh'
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
@Injectable()
|
||||
export class HtmlTemplateMiddleware implements NestMiddleware {
|
||||
private indexHtmlMap: { [languageCode: string]: string } = {};
|
||||
|
||||
public constructor(private readonly i18nService: I18nService) {
|
||||
try {
|
||||
this.indexHtmlMap = SUPPORTED_LANGUAGE_CODES.reduce(
|
||||
(map, languageCode) => ({
|
||||
...map,
|
||||
[languageCode]: fs.readFileSync(
|
||||
join(__dirname, '..', 'client', languageCode, 'index.html'),
|
||||
'utf8'
|
||||
)
|
||||
}),
|
||||
{}
|
||||
);
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
'Failed to initialize index HTML map',
|
||||
error,
|
||||
'HTMLTemplateMiddleware'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return filename.split('.').pop() !== filename;
|
||||
};
|
||||
public use(request: Request, response: Response, next: NextFunction) {
|
||||
const path = request.originalUrl.replace(/\/$/, '');
|
||||
let languageCode = path.substr(1, 2);
|
||||
|
||||
export const HtmlTemplateMiddleware = async (
|
||||
request: Request,
|
||||
response: Response,
|
||||
next: NextFunction
|
||||
) => {
|
||||
const path = request.originalUrl.replace(/\/$/, '');
|
||||
let languageCode = path.substr(1, 2);
|
||||
if (!SUPPORTED_LANGUAGE_CODES.includes(languageCode)) {
|
||||
languageCode = DEFAULT_LANGUAGE_CODE;
|
||||
}
|
||||
|
||||
if (!SUPPORTED_LANGUAGE_CODES.includes(languageCode)) {
|
||||
languageCode = DEFAULT_LANGUAGE_CODE;
|
||||
}
|
||||
const currentDate = format(new Date(), DATE_FORMAT);
|
||||
const rootUrl = process.env.ROOT_URL || environment.rootUrl;
|
||||
|
||||
const currentDate = format(new Date(), DATE_FORMAT);
|
||||
const rootUrl = process.env.ROOT_URL || environment.rootUrl;
|
||||
|
||||
if (
|
||||
path.startsWith('/api/') ||
|
||||
path.startsWith(STORYBOOK_PATH) ||
|
||||
isFileRequest(path) ||
|
||||
!environment.production
|
||||
) {
|
||||
// Skip
|
||||
next();
|
||||
} else {
|
||||
const indexHtml = interpolate(indexHtmlMap[languageCode], {
|
||||
currentDate,
|
||||
languageCode,
|
||||
path,
|
||||
rootUrl,
|
||||
description: i18nService.getTranslation({
|
||||
if (
|
||||
path.startsWith('/api/') ||
|
||||
path.startsWith(STORYBOOK_PATH) ||
|
||||
this.isFileRequest(path) ||
|
||||
!environment.production
|
||||
) {
|
||||
// Skip
|
||||
next();
|
||||
} else {
|
||||
const indexHtml = interpolate(this.indexHtmlMap[languageCode], {
|
||||
currentDate,
|
||||
languageCode,
|
||||
id: 'metaDescription'
|
||||
}),
|
||||
featureGraphicPath:
|
||||
locales[path]?.featureGraphicPath ?? 'assets/cover.png',
|
||||
keywords: i18nService.getTranslation({
|
||||
languageCode,
|
||||
id: 'metaKeywords'
|
||||
}),
|
||||
title:
|
||||
locales[path]?.title ??
|
||||
`${title} – ${i18nService.getTranslation({
|
||||
path,
|
||||
rootUrl,
|
||||
description: this.i18nService.getTranslation({
|
||||
languageCode,
|
||||
id: 'slogan'
|
||||
})}`
|
||||
});
|
||||
id: 'metaDescription'
|
||||
}),
|
||||
featureGraphicPath:
|
||||
locales[path]?.featureGraphicPath ?? 'assets/cover.png',
|
||||
keywords: this.i18nService.getTranslation({
|
||||
languageCode,
|
||||
id: 'metaKeywords'
|
||||
}),
|
||||
title:
|
||||
locales[path]?.title ??
|
||||
`${title} – ${this.i18nService.getTranslation({
|
||||
languageCode,
|
||||
id: 'slogan'
|
||||
})}`
|
||||
});
|
||||
|
||||
return response.send(indexHtml);
|
||||
return response.send(indexHtml);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private isFileRequest(filename: string) {
|
||||
if (filename === '/assets/LICENSE') {
|
||||
return true;
|
||||
} else if (
|
||||
filename.includes('auth/ey') ||
|
||||
filename.includes(
|
||||
'personal-finance-tools/open-source-alternative-to-de.fi'
|
||||
) ||
|
||||
filename.includes(
|
||||
'personal-finance-tools/open-source-alternative-to-markets.sh'
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return filename.split('.').pop() !== filename;
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||
import { groupBy } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
PortfolioPosition,
|
||||
@ -14,28 +15,28 @@ import { RuleInterface } from './interfaces/rule.interface';
|
||||
|
||||
export abstract class Rule<T extends RuleSettings> implements RuleInterface<T> {
|
||||
private key: string;
|
||||
private name: string;
|
||||
private languageCode: string;
|
||||
|
||||
public constructor(
|
||||
protected exchangeRateDataService: ExchangeRateDataService,
|
||||
{
|
||||
key,
|
||||
name
|
||||
languageCode = DEFAULT_LANGUAGE_CODE
|
||||
}: {
|
||||
key: string;
|
||||
name: string;
|
||||
languageCode?: string; // TODO: Make mandatory
|
||||
}
|
||||
) {
|
||||
this.key = key;
|
||||
this.name = name;
|
||||
this.languageCode = languageCode;
|
||||
}
|
||||
|
||||
public getKey() {
|
||||
return this.key;
|
||||
}
|
||||
|
||||
public getName() {
|
||||
return this.name;
|
||||
public getLanguageCode() {
|
||||
return this.languageCode;
|
||||
}
|
||||
|
||||
public groupCurrentHoldingsByAttribute(
|
||||
@ -73,5 +74,7 @@ export abstract class Rule<T extends RuleSettings> implements RuleInterface<T> {
|
||||
PortfolioReportRule['configuration']
|
||||
>;
|
||||
|
||||
public abstract getName(): string;
|
||||
|
||||
public abstract getSettings(aUserSettings: UserSettings): T;
|
||||
}
|
||||
|
@ -1,22 +1,23 @@
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { Rule } from '@ghostfolio/api/models/rule';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import {
|
||||
PortfolioDetails,
|
||||
PortfolioPosition,
|
||||
UserSettings
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
|
||||
import { PortfolioDetails, UserSettings } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Account } from '@prisma/client';
|
||||
|
||||
export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
private accounts: PortfolioDetails['accounts'];
|
||||
|
||||
public constructor(
|
||||
protected exchangeRateDataService: ExchangeRateDataService,
|
||||
private i18nService: I18nService,
|
||||
languageCode: string,
|
||||
accounts: PortfolioDetails['accounts']
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
key: AccountClusterRiskCurrentInvestment.name,
|
||||
name: 'Investment'
|
||||
languageCode,
|
||||
key: AccountClusterRiskCurrentInvestment.name
|
||||
});
|
||||
|
||||
this.accounts = accounts;
|
||||
@ -24,54 +25,62 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
|
||||
public evaluate(ruleSettings: Settings) {
|
||||
const accounts: {
|
||||
[symbol: string]: Pick<PortfolioPosition, 'name'> & {
|
||||
[symbol: string]: Pick<Account, 'name'> & {
|
||||
investment: number;
|
||||
};
|
||||
} = {};
|
||||
|
||||
for (const [accountId, account] of Object.entries(this.accounts)) {
|
||||
accounts[accountId] = {
|
||||
name: account.name,
|
||||
investment: account.valueInBaseCurrency
|
||||
investment: account.valueInBaseCurrency,
|
||||
name: account.name
|
||||
};
|
||||
}
|
||||
|
||||
let maxItem: (typeof accounts)[0];
|
||||
let maxAccount: (typeof accounts)[0];
|
||||
let totalInvestment = 0;
|
||||
|
||||
for (const account of Object.values(accounts)) {
|
||||
if (!maxItem) {
|
||||
maxItem = account;
|
||||
if (!maxAccount) {
|
||||
maxAccount = account;
|
||||
}
|
||||
|
||||
// Calculate total investment
|
||||
totalInvestment += account.investment;
|
||||
|
||||
// Find maximum
|
||||
if (account.investment > maxItem?.investment) {
|
||||
maxItem = account;
|
||||
if (account.investment > maxAccount?.investment) {
|
||||
maxAccount = account;
|
||||
}
|
||||
}
|
||||
|
||||
const maxInvestmentRatio = maxItem?.investment / totalInvestment || 0;
|
||||
const maxInvestmentRatio = maxAccount?.investment / totalInvestment || 0;
|
||||
|
||||
if (maxInvestmentRatio > ruleSettings.thresholdMax) {
|
||||
return {
|
||||
evaluation: `Over ${
|
||||
ruleSettings.thresholdMax * 100
|
||||
}% of your current investment is at ${maxItem.name} (${(
|
||||
maxInvestmentRatio * 100
|
||||
).toPrecision(3)}%)`,
|
||||
evaluation: this.i18nService.getTranslation({
|
||||
id: 'rule.accountClusterRiskCurrentInvestment.false',
|
||||
languageCode: this.getLanguageCode(),
|
||||
placeholders: {
|
||||
maxAccountName: maxAccount.name,
|
||||
maxInvestmentRatio: (maxInvestmentRatio * 100).toPrecision(3),
|
||||
thresholdMax: ruleSettings.thresholdMax * 100
|
||||
}
|
||||
}),
|
||||
value: false
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
evaluation: `The major part of your current investment is at ${
|
||||
maxItem.name
|
||||
} (${(maxInvestmentRatio * 100).toPrecision(3)}%) and does not exceed ${
|
||||
ruleSettings.thresholdMax * 100
|
||||
}%`,
|
||||
evaluation: this.i18nService.getTranslation({
|
||||
id: 'rule.accountClusterRiskCurrentInvestment.true',
|
||||
languageCode: this.getLanguageCode(),
|
||||
placeholders: {
|
||||
maxAccountName: maxAccount.name,
|
||||
maxInvestmentRatio: (maxInvestmentRatio * 100).toPrecision(3),
|
||||
thresholdMax: ruleSettings.thresholdMax * 100
|
||||
}
|
||||
}),
|
||||
value: true
|
||||
};
|
||||
}
|
||||
@ -88,6 +97,13 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
};
|
||||
}
|
||||
|
||||
public getName() {
|
||||
return this.i18nService.getTranslation({
|
||||
id: 'rule.accountClusterRiskCurrentInvestment',
|
||||
languageCode: this.getLanguageCode()
|
||||
});
|
||||
}
|
||||
|
||||
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
|
||||
return {
|
||||
baseCurrency,
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { Rule } from '@ghostfolio/api/models/rule';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
|
||||
import { PortfolioDetails, UserSettings } from '@ghostfolio/common/interfaces';
|
||||
|
||||
export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
|
||||
@ -8,11 +9,13 @@ export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
|
||||
|
||||
public constructor(
|
||||
protected exchangeRateDataService: ExchangeRateDataService,
|
||||
private i18nService: I18nService,
|
||||
languageCode: string,
|
||||
accounts: PortfolioDetails['accounts']
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
key: AccountClusterRiskSingleAccount.name,
|
||||
name: 'Single Account'
|
||||
languageCode,
|
||||
key: AccountClusterRiskSingleAccount.name
|
||||
});
|
||||
|
||||
this.accounts = accounts;
|
||||
@ -23,13 +26,22 @@ export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
|
||||
|
||||
if (accounts.length === 1) {
|
||||
return {
|
||||
evaluation: `Your net worth is managed by a single account`,
|
||||
evaluation: this.i18nService.getTranslation({
|
||||
id: 'rule.accountClusterRiskSingleAccount.false',
|
||||
languageCode: this.getLanguageCode()
|
||||
}),
|
||||
value: false
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
evaluation: `Your net worth is managed by ${accounts.length} accounts`,
|
||||
evaluation: this.i18nService.getTranslation({
|
||||
id: 'rule.accountClusterRiskSingleAccount.true',
|
||||
languageCode: this.getLanguageCode(),
|
||||
placeholders: {
|
||||
accountsLength: accounts.length
|
||||
}
|
||||
}),
|
||||
value: true
|
||||
};
|
||||
}
|
||||
@ -38,6 +50,14 @@ export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public getName() {
|
||||
return this.i18nService.getTranslation({
|
||||
id: 'rule.accountClusterRiskSingleAccount',
|
||||
languageCode: this.getLanguageCode()
|
||||
});
|
||||
return 'Single Account';
|
||||
}
|
||||
|
||||
public getSettings({ xRayRules }: UserSettings): RuleSettings {
|
||||
return {
|
||||
isActive: xRayRules?.[this.getKey()].isActive ?? true
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { Rule } from '@ghostfolio/api/models/rule';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
|
||||
import { PortfolioPosition, UserSettings } from '@ghostfolio/common/interfaces';
|
||||
|
||||
export class AssetClassClusterRiskEquity extends Rule<Settings> {
|
||||
@ -8,11 +9,13 @@ export class AssetClassClusterRiskEquity extends Rule<Settings> {
|
||||
|
||||
public constructor(
|
||||
protected exchangeRateDataService: ExchangeRateDataService,
|
||||
private i18nService: I18nService,
|
||||
languageCode: string,
|
||||
holdings: PortfolioPosition[]
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
key: AssetClassClusterRiskEquity.name,
|
||||
name: 'Equity'
|
||||
languageCode,
|
||||
key: AssetClassClusterRiskEquity.name
|
||||
});
|
||||
|
||||
this.holdings = holdings;
|
||||
@ -41,26 +44,39 @@ export class AssetClassClusterRiskEquity extends Rule<Settings> {
|
||||
|
||||
if (equityValueRatio > ruleSettings.thresholdMax) {
|
||||
return {
|
||||
evaluation: `The equity contribution of your current investment (${(equityValueRatio * 100).toPrecision(3)}%) exceeds ${(
|
||||
ruleSettings.thresholdMax * 100
|
||||
).toPrecision(3)}%`,
|
||||
evaluation: this.i18nService.getTranslation({
|
||||
id: 'rule.assetClassClusterRiskEquity.false.max',
|
||||
languageCode: this.getLanguageCode(),
|
||||
placeholders: {
|
||||
equityValueRatio: (equityValueRatio * 100).toPrecision(3),
|
||||
thresholdMax: (ruleSettings.thresholdMax * 100).toPrecision(3)
|
||||
}
|
||||
}),
|
||||
value: false
|
||||
};
|
||||
} else if (equityValueRatio < ruleSettings.thresholdMin) {
|
||||
return {
|
||||
evaluation: `The equity contribution of your current investment (${(equityValueRatio * 100).toPrecision(3)}%) is below ${(
|
||||
ruleSettings.thresholdMin * 100
|
||||
).toPrecision(3)}%`,
|
||||
evaluation: this.i18nService.getTranslation({
|
||||
id: 'rule.assetClassClusterRiskEquity.false.min',
|
||||
languageCode: this.getLanguageCode(),
|
||||
placeholders: {
|
||||
equityValueRatio: (equityValueRatio * 100).toPrecision(3),
|
||||
thresholdMin: (ruleSettings.thresholdMin * 100).toPrecision(3)
|
||||
}
|
||||
}),
|
||||
value: false
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
evaluation: `The equity contribution of your current investment (${(equityValueRatio * 100).toPrecision(3)}%) is within the range of ${(
|
||||
ruleSettings.thresholdMin * 100
|
||||
).toPrecision(
|
||||
3
|
||||
)}% and ${(ruleSettings.thresholdMax * 100).toPrecision(3)}%`,
|
||||
evaluation: this.i18nService.getTranslation({
|
||||
id: 'rule.assetClassClusterRiskEquity.true',
|
||||
languageCode: this.getLanguageCode(),
|
||||
placeholders: {
|
||||
equityValueRatio: (equityValueRatio * 100).toPrecision(3),
|
||||
thresholdMax: (ruleSettings.thresholdMax * 100).toPrecision(3),
|
||||
thresholdMin: (ruleSettings.thresholdMin * 100).toPrecision(3)
|
||||
}
|
||||
}),
|
||||
value: true
|
||||
};
|
||||
}
|
||||
@ -78,6 +94,13 @@ export class AssetClassClusterRiskEquity extends Rule<Settings> {
|
||||
};
|
||||
}
|
||||
|
||||
public getName() {
|
||||
return this.i18nService.getTranslation({
|
||||
id: 'rule.assetClassClusterRiskEquity',
|
||||
languageCode: this.getLanguageCode()
|
||||
});
|
||||
}
|
||||
|
||||
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
|
||||
return {
|
||||
baseCurrency,
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { Rule } from '@ghostfolio/api/models/rule';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
|
||||
import { PortfolioPosition, UserSettings } from '@ghostfolio/common/interfaces';
|
||||
|
||||
export class AssetClassClusterRiskFixedIncome extends Rule<Settings> {
|
||||
@ -8,11 +9,13 @@ export class AssetClassClusterRiskFixedIncome extends Rule<Settings> {
|
||||
|
||||
public constructor(
|
||||
protected exchangeRateDataService: ExchangeRateDataService,
|
||||
private i18nService: I18nService,
|
||||
languageCode: string,
|
||||
holdings: PortfolioPosition[]
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
key: AssetClassClusterRiskFixedIncome.name,
|
||||
name: 'Fixed Income'
|
||||
languageCode,
|
||||
key: AssetClassClusterRiskFixedIncome.name
|
||||
});
|
||||
|
||||
this.holdings = holdings;
|
||||
@ -41,26 +44,39 @@ export class AssetClassClusterRiskFixedIncome extends Rule<Settings> {
|
||||
|
||||
if (fixedIncomeValueRatio > ruleSettings.thresholdMax) {
|
||||
return {
|
||||
evaluation: `The fixed income contribution of your current investment (${(fixedIncomeValueRatio * 100).toPrecision(3)}%) exceeds ${(
|
||||
ruleSettings.thresholdMax * 100
|
||||
).toPrecision(3)}%`,
|
||||
evaluation: this.i18nService.getTranslation({
|
||||
id: 'rule.assetClassClusterRiskFixedIncome.false.max',
|
||||
languageCode: this.getLanguageCode(),
|
||||
placeholders: {
|
||||
fixedIncomeValueRatio: (fixedIncomeValueRatio * 100).toPrecision(3),
|
||||
thresholdMax: (ruleSettings.thresholdMax * 100).toPrecision(3)
|
||||
}
|
||||
}),
|
||||
value: false
|
||||
};
|
||||
} else if (fixedIncomeValueRatio < ruleSettings.thresholdMin) {
|
||||
return {
|
||||
evaluation: `The fixed income contribution of your current investment (${(fixedIncomeValueRatio * 100).toPrecision(3)}%) is below ${(
|
||||
ruleSettings.thresholdMin * 100
|
||||
).toPrecision(3)}%`,
|
||||
evaluation: this.i18nService.getTranslation({
|
||||
id: 'rule.assetClassClusterRiskFixedIncome.false.min',
|
||||
languageCode: this.getLanguageCode(),
|
||||
placeholders: {
|
||||
fixedIncomeValueRatio: (fixedIncomeValueRatio * 100).toPrecision(3),
|
||||
thresholdMin: (ruleSettings.thresholdMin * 100).toPrecision(3)
|
||||
}
|
||||
}),
|
||||
value: false
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
evaluation: `The fixed income contribution of your current investment (${(fixedIncomeValueRatio * 100).toPrecision(3)}%) is within the range of ${(
|
||||
ruleSettings.thresholdMin * 100
|
||||
).toPrecision(
|
||||
3
|
||||
)}% and ${(ruleSettings.thresholdMax * 100).toPrecision(3)}%`,
|
||||
evaluation: this.i18nService.getTranslation({
|
||||
id: 'rule.assetClassClusterRiskFixedIncome.true',
|
||||
languageCode: this.getLanguageCode(),
|
||||
placeholders: {
|
||||
fixedIncomeValueRatio: (fixedIncomeValueRatio * 100).toPrecision(3),
|
||||
thresholdMax: (ruleSettings.thresholdMax * 100).toPrecision(3),
|
||||
thresholdMin: (ruleSettings.thresholdMin * 100).toPrecision(3)
|
||||
}
|
||||
}),
|
||||
value: true
|
||||
};
|
||||
}
|
||||
@ -78,6 +94,13 @@ export class AssetClassClusterRiskFixedIncome extends Rule<Settings> {
|
||||
};
|
||||
}
|
||||
|
||||
public getName() {
|
||||
return this.i18nService.getTranslation({
|
||||
id: 'rule.assetClassClusterRiskFixedIncome',
|
||||
languageCode: this.getLanguageCode()
|
||||
});
|
||||
}
|
||||
|
||||
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
|
||||
return {
|
||||
baseCurrency,
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { Rule } from '@ghostfolio/api/models/rule';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
|
||||
import { PortfolioPosition, UserSettings } from '@ghostfolio/common/interfaces';
|
||||
|
||||
export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Settings> {
|
||||
@ -8,11 +9,13 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
|
||||
|
||||
public constructor(
|
||||
protected exchangeRateDataService: ExchangeRateDataService,
|
||||
holdings: PortfolioPosition[]
|
||||
private i18nService: I18nService,
|
||||
holdings: PortfolioPosition[],
|
||||
languageCode: string
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
key: CurrencyClusterRiskBaseCurrencyCurrentInvestment.name,
|
||||
name: 'Investment: Base Currency'
|
||||
languageCode
|
||||
});
|
||||
|
||||
this.holdings = holdings;
|
||||
@ -49,17 +52,29 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
|
||||
|
||||
if (maxItem?.groupKey !== ruleSettings.baseCurrency) {
|
||||
return {
|
||||
evaluation: `The major part of your current investment is not in your base currency (${(
|
||||
baseCurrencyValueRatio * 100
|
||||
).toPrecision(3)}% in ${ruleSettings.baseCurrency})`,
|
||||
evaluation: this.i18nService.getTranslation({
|
||||
id: 'rule.currencyClusterRiskBaseCurrencyCurrentInvestment.false',
|
||||
languageCode: this.getLanguageCode(),
|
||||
placeholders: {
|
||||
baseCurrency: ruleSettings.baseCurrency,
|
||||
baseCurrencyValueRatio: (baseCurrencyValueRatio * 100).toPrecision(
|
||||
3
|
||||
)
|
||||
}
|
||||
}),
|
||||
value: false
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
evaluation: `The major part of your current investment is in your base currency (${(
|
||||
baseCurrencyValueRatio * 100
|
||||
).toPrecision(3)}% in ${ruleSettings.baseCurrency})`,
|
||||
evaluation: this.i18nService.getTranslation({
|
||||
id: 'rule.currencyClusterRiskBaseCurrencyCurrentInvestment.true',
|
||||
languageCode: this.getLanguageCode(),
|
||||
placeholders: {
|
||||
baseCurrency: ruleSettings.baseCurrency,
|
||||
baseCurrencyValueRatio: (baseCurrencyValueRatio * 100).toPrecision(3)
|
||||
}
|
||||
}),
|
||||
value: true
|
||||
};
|
||||
}
|
||||
@ -68,6 +83,13 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public getName() {
|
||||
return this.i18nService.getTranslation({
|
||||
id: 'rule.currencyClusterRiskBaseCurrencyCurrentInvestment',
|
||||
languageCode: this.getLanguageCode()
|
||||
});
|
||||
}
|
||||
|
||||
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
|
||||
return {
|
||||
baseCurrency,
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { Rule } from '@ghostfolio/api/models/rule';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
|
||||
import { PortfolioPosition, UserSettings } from '@ghostfolio/common/interfaces';
|
||||
|
||||
export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
@ -8,11 +9,13 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
|
||||
public constructor(
|
||||
protected exchangeRateDataService: ExchangeRateDataService,
|
||||
holdings: PortfolioPosition[]
|
||||
private i18nService: I18nService,
|
||||
holdings: PortfolioPosition[],
|
||||
languageCode: string
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
key: CurrencyClusterRiskCurrentInvestment.name,
|
||||
name: 'Investment'
|
||||
languageCode
|
||||
});
|
||||
|
||||
this.holdings = holdings;
|
||||
@ -42,21 +45,29 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
|
||||
if (maxValueRatio > ruleSettings.thresholdMax) {
|
||||
return {
|
||||
evaluation: `Over ${
|
||||
ruleSettings.thresholdMax * 100
|
||||
}% of your current investment is in ${maxItem.groupKey} (${(
|
||||
maxValueRatio * 100
|
||||
).toPrecision(3)}%)`,
|
||||
evaluation: this.i18nService.getTranslation({
|
||||
id: 'rule.currencyClusterRiskCurrentInvestment.false',
|
||||
languageCode: this.getLanguageCode(),
|
||||
placeholders: {
|
||||
currency: maxItem.groupKey as string,
|
||||
maxValueRatio: (maxValueRatio * 100).toPrecision(3),
|
||||
thresholdMax: ruleSettings.thresholdMax * 100
|
||||
}
|
||||
}),
|
||||
value: false
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
evaluation: `The major part of your current investment is in ${
|
||||
maxItem?.groupKey ?? ruleSettings.baseCurrency
|
||||
} (${(maxValueRatio * 100).toPrecision(3)}%) and does not exceed ${
|
||||
ruleSettings.thresholdMax * 100
|
||||
}%`,
|
||||
evaluation: this.i18nService.getTranslation({
|
||||
id: 'rule.currencyClusterRiskCurrentInvestment.true',
|
||||
languageCode: this.getLanguageCode(),
|
||||
placeholders: {
|
||||
currency: maxItem.groupKey as string,
|
||||
maxValueRatio: (maxValueRatio * 100).toPrecision(3),
|
||||
thresholdMax: ruleSettings.thresholdMax * 100
|
||||
}
|
||||
}),
|
||||
value: true
|
||||
};
|
||||
}
|
||||
@ -73,6 +84,13 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
};
|
||||
}
|
||||
|
||||
public getName() {
|
||||
return this.i18nService.getTranslation({
|
||||
id: 'rule.currencyClusterRiskCurrentInvestment',
|
||||
languageCode: this.getLanguageCode()
|
||||
});
|
||||
}
|
||||
|
||||
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
|
||||
return {
|
||||
baseCurrency,
|
||||
|
@ -13,8 +13,7 @@ export class EconomicMarketClusterRiskDevelopedMarkets extends Rule<Settings> {
|
||||
developedMarketsValueInBaseCurrency: number
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
key: EconomicMarketClusterRiskDevelopedMarkets.name,
|
||||
name: 'Developed Markets'
|
||||
key: EconomicMarketClusterRiskDevelopedMarkets.name
|
||||
});
|
||||
|
||||
this.currentValueInBaseCurrency = currentValueInBaseCurrency;
|
||||
@ -67,6 +66,10 @@ export class EconomicMarketClusterRiskDevelopedMarkets extends Rule<Settings> {
|
||||
};
|
||||
}
|
||||
|
||||
public getName() {
|
||||
return 'Developed Markets';
|
||||
}
|
||||
|
||||
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
|
||||
return {
|
||||
baseCurrency,
|
||||
|
@ -13,8 +13,7 @@ export class EconomicMarketClusterRiskEmergingMarkets extends Rule<Settings> {
|
||||
emergingMarketsValueInBaseCurrency: number
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
key: EconomicMarketClusterRiskEmergingMarkets.name,
|
||||
name: 'Emerging Markets'
|
||||
key: EconomicMarketClusterRiskEmergingMarkets.name
|
||||
});
|
||||
|
||||
this.currentValueInBaseCurrency = currentValueInBaseCurrency;
|
||||
@ -67,6 +66,10 @@ export class EconomicMarketClusterRiskEmergingMarkets extends Rule<Settings> {
|
||||
};
|
||||
}
|
||||
|
||||
public getName() {
|
||||
return 'Emerging Markets';
|
||||
}
|
||||
|
||||
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
|
||||
return {
|
||||
baseCurrency,
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { Rule } from '@ghostfolio/api/models/rule';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
|
||||
import { UserSettings } from '@ghostfolio/common/interfaces';
|
||||
|
||||
export class EmergencyFundSetup extends Rule<Settings> {
|
||||
@ -8,11 +9,13 @@ export class EmergencyFundSetup extends Rule<Settings> {
|
||||
|
||||
public constructor(
|
||||
protected exchangeRateDataService: ExchangeRateDataService,
|
||||
private i18nService: I18nService,
|
||||
languageCode: string,
|
||||
emergencyFund: number
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
key: EmergencyFundSetup.name,
|
||||
name: 'Emergency Fund: Set up'
|
||||
languageCode,
|
||||
key: EmergencyFundSetup.name
|
||||
});
|
||||
|
||||
this.emergencyFund = emergencyFund;
|
||||
@ -21,13 +24,19 @@ export class EmergencyFundSetup extends Rule<Settings> {
|
||||
public evaluate() {
|
||||
if (!this.emergencyFund) {
|
||||
return {
|
||||
evaluation: 'No emergency fund has been set up',
|
||||
evaluation: this.i18nService.getTranslation({
|
||||
id: 'rule.emergencyFundSetup.false',
|
||||
languageCode: this.getLanguageCode()
|
||||
}),
|
||||
value: false
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
evaluation: 'An emergency fund has been set up',
|
||||
evaluation: this.i18nService.getTranslation({
|
||||
id: 'rule.emergencyFundSetup.true',
|
||||
languageCode: this.getLanguageCode()
|
||||
}),
|
||||
value: true
|
||||
};
|
||||
}
|
||||
@ -36,6 +45,13 @@ export class EmergencyFundSetup extends Rule<Settings> {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public getName() {
|
||||
return this.i18nService.getTranslation({
|
||||
id: 'rule.emergencyFundSetup',
|
||||
languageCode: this.getLanguageCode()
|
||||
});
|
||||
}
|
||||
|
||||
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
|
||||
return {
|
||||
baseCurrency,
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { Rule } from '@ghostfolio/api/models/rule';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
|
||||
import { UserSettings } from '@ghostfolio/common/interfaces';
|
||||
|
||||
export class FeeRatioInitialInvestment extends Rule<Settings> {
|
||||
@ -9,12 +10,14 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
|
||||
|
||||
public constructor(
|
||||
protected exchangeRateDataService: ExchangeRateDataService,
|
||||
private i18nService: I18nService,
|
||||
languageCode: string,
|
||||
totalInvestment: number,
|
||||
fees: number
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
key: FeeRatioInitialInvestment.name,
|
||||
name: 'Fee Ratio'
|
||||
languageCode,
|
||||
key: FeeRatioInitialInvestment.name
|
||||
});
|
||||
|
||||
this.fees = fees;
|
||||
@ -28,17 +31,27 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
|
||||
|
||||
if (feeRatio > ruleSettings.thresholdMax) {
|
||||
return {
|
||||
evaluation: `The fees do exceed ${
|
||||
ruleSettings.thresholdMax * 100
|
||||
}% of your initial investment (${(feeRatio * 100).toPrecision(3)}%)`,
|
||||
evaluation: this.i18nService.getTranslation({
|
||||
id: 'rule.feeRatioInitialInvestment.false',
|
||||
languageCode: this.getLanguageCode(),
|
||||
placeholders: {
|
||||
feeRatio: (ruleSettings.thresholdMax * 100).toFixed(2),
|
||||
thresholdMax: (feeRatio * 100).toPrecision(3)
|
||||
}
|
||||
}),
|
||||
value: false
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
evaluation: `The fees do not exceed ${
|
||||
ruleSettings.thresholdMax * 100
|
||||
}% of your initial investment (${(feeRatio * 100).toPrecision(3)}%)`,
|
||||
evaluation: this.i18nService.getTranslation({
|
||||
id: 'rule.feeRatioInitialInvestment.true',
|
||||
languageCode: this.getLanguageCode(),
|
||||
placeholders: {
|
||||
feeRatio: (feeRatio * 100).toPrecision(3),
|
||||
thresholdMax: (ruleSettings.thresholdMax * 100).toFixed(2)
|
||||
}
|
||||
}),
|
||||
value: true
|
||||
};
|
||||
}
|
||||
@ -55,6 +68,13 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
|
||||
};
|
||||
}
|
||||
|
||||
public getName() {
|
||||
return this.i18nService.getTranslation({
|
||||
id: 'rule.feeRatioInitialInvestment',
|
||||
languageCode: this.getLanguageCode()
|
||||
});
|
||||
}
|
||||
|
||||
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
|
||||
return {
|
||||
baseCurrency,
|
||||
|
@ -14,8 +14,7 @@ export class RegionalMarketClusterRiskAsiaPacific extends Rule<Settings> {
|
||||
asiaPacificValueInBaseCurrency: number
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
key: RegionalMarketClusterRiskAsiaPacific.name,
|
||||
name: 'Asia-Pacific'
|
||||
key: RegionalMarketClusterRiskAsiaPacific.name
|
||||
});
|
||||
|
||||
this.asiaPacificValueInBaseCurrency = asiaPacificValueInBaseCurrency;
|
||||
@ -66,6 +65,10 @@ export class RegionalMarketClusterRiskAsiaPacific extends Rule<Settings> {
|
||||
};
|
||||
}
|
||||
|
||||
public getName() {
|
||||
return 'Asia-Pacific';
|
||||
}
|
||||
|
||||
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
|
||||
return {
|
||||
baseCurrency,
|
||||
|
@ -14,8 +14,7 @@ export class RegionalMarketClusterRiskEmergingMarkets extends Rule<Settings> {
|
||||
emergingMarketsValueInBaseCurrency: number
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
key: RegionalMarketClusterRiskEmergingMarkets.name,
|
||||
name: 'Emerging Markets'
|
||||
key: RegionalMarketClusterRiskEmergingMarkets.name
|
||||
});
|
||||
|
||||
this.currentValueInBaseCurrency = currentValueInBaseCurrency;
|
||||
@ -68,6 +67,10 @@ export class RegionalMarketClusterRiskEmergingMarkets extends Rule<Settings> {
|
||||
};
|
||||
}
|
||||
|
||||
public getName() {
|
||||
return 'Emerging Markets';
|
||||
}
|
||||
|
||||
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
|
||||
return {
|
||||
baseCurrency,
|
||||
|
@ -14,8 +14,7 @@ export class RegionalMarketClusterRiskEurope extends Rule<Settings> {
|
||||
europeValueInBaseCurrency: number
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
key: RegionalMarketClusterRiskEurope.name,
|
||||
name: 'Europe'
|
||||
key: RegionalMarketClusterRiskEurope.name
|
||||
});
|
||||
|
||||
this.currentValueInBaseCurrency = currentValueInBaseCurrency;
|
||||
@ -66,6 +65,10 @@ export class RegionalMarketClusterRiskEurope extends Rule<Settings> {
|
||||
};
|
||||
}
|
||||
|
||||
public getName() {
|
||||
return 'Europe';
|
||||
}
|
||||
|
||||
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
|
||||
return {
|
||||
baseCurrency,
|
||||
|
@ -14,8 +14,7 @@ export class RegionalMarketClusterRiskJapan extends Rule<Settings> {
|
||||
japanValueInBaseCurrency: number
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
key: RegionalMarketClusterRiskJapan.name,
|
||||
name: 'Japan'
|
||||
key: RegionalMarketClusterRiskJapan.name
|
||||
});
|
||||
|
||||
this.currentValueInBaseCurrency = currentValueInBaseCurrency;
|
||||
@ -66,6 +65,10 @@ export class RegionalMarketClusterRiskJapan extends Rule<Settings> {
|
||||
};
|
||||
}
|
||||
|
||||
public getName() {
|
||||
return 'Japan';
|
||||
}
|
||||
|
||||
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
|
||||
return {
|
||||
baseCurrency,
|
||||
|
@ -14,8 +14,7 @@ export class RegionalMarketClusterRiskNorthAmerica extends Rule<Settings> {
|
||||
northAmericaValueInBaseCurrency: number
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
key: RegionalMarketClusterRiskNorthAmerica.name,
|
||||
name: 'North America'
|
||||
key: RegionalMarketClusterRiskNorthAmerica.name
|
||||
});
|
||||
|
||||
this.currentValueInBaseCurrency = currentValueInBaseCurrency;
|
||||
@ -66,6 +65,10 @@ export class RegionalMarketClusterRiskNorthAmerica extends Rule<Settings> {
|
||||
};
|
||||
}
|
||||
|
||||
public getName() {
|
||||
return 'North America';
|
||||
}
|
||||
|
||||
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
|
||||
return {
|
||||
baseCurrency,
|
||||
|
23
apps/api/src/services/cron/cron.module.ts
Normal file
23
apps/api/src/services/cron/cron.module.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
|
||||
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CronService } from './cron.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
ExchangeRateDataModule,
|
||||
PropertyModule,
|
||||
TwitterBotModule,
|
||||
UserModule
|
||||
],
|
||||
providers: [CronService]
|
||||
})
|
||||
export class CronModule {}
|
@ -1,4 +1,9 @@
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
|
||||
import { TwitterBotService } from '@ghostfolio/api/services/twitter-bot/twitter-bot.service';
|
||||
import {
|
||||
DATA_GATHERING_QUEUE_PRIORITY_LOW,
|
||||
GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
|
||||
@ -10,12 +15,6 @@ import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
|
||||
import { ConfigurationService } from './configuration/configuration.service';
|
||||
import { ExchangeRateDataService } from './exchange-rate-data/exchange-rate-data.service';
|
||||
import { PropertyService } from './property/property.service';
|
||||
import { DataGatheringService } from './queues/data-gathering/data-gathering.service';
|
||||
import { TwitterBotService } from './twitter-bot/twitter-bot.service';
|
||||
|
||||
@Injectable()
|
||||
export class CronService {
|
||||
private static readonly EVERY_SUNDAY_AT_LUNCH_TIME = '0 12 * * 0';
|
||||
@ -43,7 +42,9 @@ export class CronService {
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_5PM)
|
||||
public async runEveryDayAtFivePm() {
|
||||
this.twitterBotService.tweetFearAndGreedIndex();
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
this.twitterBotService.tweetFearAndGreedIndex();
|
||||
}
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
|
@ -29,7 +29,7 @@ import {
|
||||
import { hasRole } from '@ghostfolio/common/permissions';
|
||||
import type { Granularity, UserWithSettings } from '@ghostfolio/common/types';
|
||||
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { DataSource, MarketData, SymbolProfile } from '@prisma/client';
|
||||
import { Big } from 'big.js';
|
||||
import { eachDayOfInterval, format, isBefore, isValid } from 'date-fns';
|
||||
@ -37,7 +37,7 @@ import { groupBy, isEmpty, isNumber, uniqWith } from 'lodash';
|
||||
import ms from 'ms';
|
||||
|
||||
@Injectable()
|
||||
export class DataProviderService {
|
||||
export class DataProviderService implements OnModuleInit {
|
||||
private dataProviderMapping: { [dataProviderName: string]: string };
|
||||
|
||||
public constructor(
|
||||
@ -48,11 +48,9 @@ export class DataProviderService {
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly propertyService: PropertyService,
|
||||
private readonly redisCacheService: RedisCacheService
|
||||
) {
|
||||
this.initialize();
|
||||
}
|
||||
) {}
|
||||
|
||||
public async initialize() {
|
||||
public async onModuleInit() {
|
||||
this.dataProviderMapping =
|
||||
((await this.propertyService.getByKey(PROPERTY_DATA_SOURCE_MAPPING)) as {
|
||||
[dataProviderName: string]: string;
|
||||
|
@ -22,6 +22,7 @@ import {
|
||||
LookupItem,
|
||||
LookupResponse
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { MarketState } from '@ghostfolio/common/types';
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
@ -378,12 +379,22 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
||||
);
|
||||
|
||||
for (const { price, symbol } of quotes) {
|
||||
let marketState: MarketState = 'delayed';
|
||||
|
||||
if (
|
||||
isCurrency(
|
||||
symbol.substring(0, symbol.length - DEFAULT_CURRENCY.length)
|
||||
)
|
||||
) {
|
||||
marketState = 'open';
|
||||
}
|
||||
|
||||
response[symbol] = {
|
||||
marketState,
|
||||
currency: currencyBySymbolMap[symbol]?.currency,
|
||||
dataProviderInfo: this.getDataProviderInfo(),
|
||||
dataSource: DataSource.FINANCIAL_MODELING_PREP,
|
||||
marketPrice: price,
|
||||
marketState: 'delayed'
|
||||
marketPrice: price
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
|
@ -104,7 +104,7 @@ export class ManualService implements DataProviderInterface {
|
||||
}
|
||||
|
||||
return historical;
|
||||
} else if (selector === undefined || url === undefined) {
|
||||
} else if (!selector || !url) {
|
||||
return {};
|
||||
}
|
||||
|
||||
@ -162,7 +162,11 @@ export class ManualService implements DataProviderInterface {
|
||||
|
||||
const symbolProfilesWithScraperConfigurationAndInstantMode =
|
||||
symbolProfiles.filter(({ scraperConfiguration }) => {
|
||||
return scraperConfiguration?.mode === 'instant';
|
||||
return (
|
||||
scraperConfiguration?.mode === 'instant' &&
|
||||
scraperConfiguration?.selector &&
|
||||
scraperConfiguration?.url
|
||||
);
|
||||
});
|
||||
|
||||
const scraperResultPromises =
|
||||
@ -282,14 +286,12 @@ export class ManualService implements DataProviderInterface {
|
||||
)
|
||||
});
|
||||
|
||||
let value: string;
|
||||
|
||||
if (response.headers.get('content-type')?.includes('application/json')) {
|
||||
const data = await response.json();
|
||||
|
||||
const value = String(
|
||||
jsonpath.query(data, scraperConfiguration.selector)[0]
|
||||
);
|
||||
|
||||
return extractNumberFromString({ locale, value });
|
||||
value = String(jsonpath.query(data, scraperConfiguration.selector)[0]);
|
||||
} else {
|
||||
const $ = cheerio.load(await response.text());
|
||||
|
||||
@ -299,10 +301,24 @@ export class ManualService implements DataProviderInterface {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
value = $(scraperConfiguration.selector).first().text();
|
||||
|
||||
const lines = value?.split('\n') ?? [];
|
||||
|
||||
const lineWithDigits = lines.find((line) => {
|
||||
return /\d/.test(line);
|
||||
});
|
||||
|
||||
if (lineWithDigits) {
|
||||
value = lineWithDigits;
|
||||
}
|
||||
|
||||
return extractNumberFromString({
|
||||
locale,
|
||||
value: $(scraperConfiguration.selector).first().text()
|
||||
value
|
||||
});
|
||||
}
|
||||
|
||||
return extractNumberFromString({ locale, value });
|
||||
}
|
||||
}
|
||||
|
13
apps/api/src/services/demo/demo.module.ts
Normal file
13
apps/api/src/services/demo/demo.module.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { DemoService } from './demo.service';
|
||||
|
||||
@Module({
|
||||
exports: [DemoService],
|
||||
imports: [PrismaModule, PropertyModule],
|
||||
providers: [DemoService]
|
||||
})
|
||||
export class DemoModule {}
|
59
apps/api/src/services/demo/demo.service.ts
Normal file
59
apps/api/src/services/demo/demo.service.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import {
|
||||
PROPERTY_DEMO_ACCOUNT_ID,
|
||||
PROPERTY_DEMO_USER_ID,
|
||||
TAG_ID_DEMO
|
||||
} from '@ghostfolio/common/config';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
@Injectable()
|
||||
export class DemoService {
|
||||
public constructor(
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly propertyService: PropertyService
|
||||
) {}
|
||||
|
||||
public async syncDemoUserAccount() {
|
||||
const [demoAccountId, demoUserId] = (await Promise.all([
|
||||
this.propertyService.getByKey(PROPERTY_DEMO_ACCOUNT_ID),
|
||||
this.propertyService.getByKey(PROPERTY_DEMO_USER_ID)
|
||||
])) as [string, string];
|
||||
|
||||
let activities = await this.prismaService.order.findMany({
|
||||
orderBy: {
|
||||
date: 'asc'
|
||||
},
|
||||
where: {
|
||||
tags: {
|
||||
some: {
|
||||
id: TAG_ID_DEMO
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
activities = activities.map((activity) => {
|
||||
return {
|
||||
...activity,
|
||||
accountId: demoAccountId,
|
||||
accountUserId: demoUserId,
|
||||
comment: null,
|
||||
id: uuidv4(),
|
||||
userId: demoUserId
|
||||
};
|
||||
});
|
||||
|
||||
await this.prismaService.order.deleteMany({
|
||||
where: {
|
||||
userId: demoUserId
|
||||
}
|
||||
});
|
||||
|
||||
return this.prismaService.order.createMany({
|
||||
data: activities
|
||||
});
|
||||
}
|
||||
}
|
9
apps/api/src/services/i18n/i18n.module.ts
Normal file
9
apps/api/src/services/i18n/i18n.module.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { I18nService } from './i18n.service';
|
||||
|
||||
@Module({
|
||||
exports: [I18nService],
|
||||
providers: [I18nService]
|
||||
})
|
||||
export class I18nModule {}
|
@ -1,10 +1,11 @@
|
||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import * as cheerio from 'cheerio';
|
||||
import { readFileSync, readdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
@Injectable()
|
||||
export class I18nService {
|
||||
private localesPath = join(__dirname, 'assets', 'locales');
|
||||
private translations: { [locale: string]: cheerio.CheerioAPI } = {};
|
||||
@ -15,10 +16,12 @@ export class I18nService {
|
||||
|
||||
public getTranslation({
|
||||
id,
|
||||
languageCode
|
||||
languageCode,
|
||||
placeholders
|
||||
}: {
|
||||
id: string;
|
||||
languageCode: string;
|
||||
placeholders?: Record<string, string | number>;
|
||||
}): string {
|
||||
const $ = this.translations[languageCode];
|
||||
|
||||
@ -26,7 +29,7 @@ export class I18nService {
|
||||
Logger.warn(`Translation not found for locale '${languageCode}'`);
|
||||
}
|
||||
|
||||
const translatedText = $(
|
||||
let translatedText = $(
|
||||
`trans-unit[id="${id}"] > ${
|
||||
languageCode === DEFAULT_LANGUAGE_CODE ? 'source' : 'target'
|
||||
}`
|
||||
@ -38,6 +41,12 @@ export class I18nService {
|
||||
);
|
||||
}
|
||||
|
||||
if (placeholders) {
|
||||
for (const [key, value] of Object.entries(placeholders)) {
|
||||
translatedText = translatedText.replace(`\${${key}}`, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
return translatedText.trim();
|
||||
}
|
||||
|
||||
|
@ -16,7 +16,7 @@ export class ImpersonationService {
|
||||
if (this.request.user) {
|
||||
const accessObject = await this.prismaService.access.findFirst({
|
||||
where: {
|
||||
GranteeUser: { id: this.request.user.id },
|
||||
granteeUserId: this.request.user.id,
|
||||
id: aId
|
||||
}
|
||||
});
|
||||
@ -35,8 +35,8 @@ export class ImpersonationService {
|
||||
// Public access
|
||||
const accessObject = await this.prismaService.access.findFirst({
|
||||
where: {
|
||||
GranteeUser: null,
|
||||
User: { id: aId }
|
||||
granteeUserId: null,
|
||||
user: { id: aId }
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -44,7 +44,7 @@ export class SymbolProfileService {
|
||||
include: {
|
||||
activities: {
|
||||
include: {
|
||||
User: true
|
||||
user: true
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -53,14 +53,14 @@ export class SymbolProfileService {
|
||||
activities: withUserSubscription
|
||||
? {
|
||||
some: {
|
||||
User: {
|
||||
user: {
|
||||
subscriptions: { some: { expiresAt: { gt: new Date() } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
: {
|
||||
every: {
|
||||
User: {
|
||||
user: {
|
||||
subscriptions: { none: { expiresAt: { gt: new Date() } } }
|
||||
}
|
||||
}
|
||||
|
@ -52,7 +52,7 @@ export class TagService {
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
orders: {
|
||||
activities: {
|
||||
where: {
|
||||
userId
|
||||
}
|
||||
@ -79,7 +79,7 @@ export class TagService {
|
||||
id,
|
||||
name,
|
||||
userId,
|
||||
isUsed: _count.orders > 0
|
||||
isUsed: _count.activities > 0
|
||||
}));
|
||||
}
|
||||
|
||||
@ -87,7 +87,7 @@ export class TagService {
|
||||
const tagsWithOrderCount = await this.prismaService.tag.findMany({
|
||||
include: {
|
||||
_count: {
|
||||
select: { orders: true }
|
||||
select: { activities: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -97,7 +97,7 @@ export class TagService {
|
||||
id,
|
||||
name,
|
||||
userId,
|
||||
activityCount: _count.orders
|
||||
activityCount: _count.activities
|
||||
};
|
||||
});
|
||||
}
|
||||
|
@ -3,12 +3,13 @@
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "apps/client-e2e/src",
|
||||
"projectType": "application",
|
||||
"tags": [],
|
||||
"implicitDependencies": ["client"],
|
||||
"targets": {
|
||||
"e2e": {
|
||||
"executor": "@nx/cypress:cypress",
|
||||
"options": {
|
||||
"cypressConfig": "apps/client-e2e/cypress.json",
|
||||
"tsConfig": "apps/client-e2e/tsconfig.e2e.json",
|
||||
"devServerTarget": "client:serve"
|
||||
},
|
||||
"configurations": {
|
||||
@ -17,7 +18,5 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [],
|
||||
"implicitDependencies": ["client"]
|
||||
}
|
||||
}
|
||||
|
@ -2,13 +2,63 @@
|
||||
"name": "client",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"projectType": "application",
|
||||
"sourceRoot": "apps/client/src",
|
||||
"prefix": "gf",
|
||||
"i18n": {
|
||||
"locales": {
|
||||
"ca": {
|
||||
"baseHref": "/ca/",
|
||||
"translation": "apps/client/src/locales/messages.ca.xlf"
|
||||
},
|
||||
"de": {
|
||||
"baseHref": "/de/",
|
||||
"translation": "apps/client/src/locales/messages.de.xlf"
|
||||
},
|
||||
"es": {
|
||||
"baseHref": "/es/",
|
||||
"translation": "apps/client/src/locales/messages.es.xlf"
|
||||
},
|
||||
"fr": {
|
||||
"baseHref": "/fr/",
|
||||
"translation": "apps/client/src/locales/messages.fr.xlf"
|
||||
},
|
||||
"it": {
|
||||
"baseHref": "/it/",
|
||||
"translation": "apps/client/src/locales/messages.it.xlf"
|
||||
},
|
||||
"nl": {
|
||||
"baseHref": "/nl/",
|
||||
"translation": "apps/client/src/locales/messages.nl.xlf"
|
||||
},
|
||||
"pl": {
|
||||
"baseHref": "/pl/",
|
||||
"translation": "apps/client/src/locales/messages.pl.xlf"
|
||||
},
|
||||
"pt": {
|
||||
"baseHref": "/pt/",
|
||||
"translation": "apps/client/src/locales/messages.pt.xlf"
|
||||
},
|
||||
"tr": {
|
||||
"baseHref": "/tr/",
|
||||
"translation": "apps/client/src/locales/messages.tr.xlf"
|
||||
},
|
||||
"uk": {
|
||||
"baseHref": "/uk/",
|
||||
"translation": "apps/client/src/locales/messages.uk.xlf"
|
||||
},
|
||||
"zh": {
|
||||
"baseHref": "/zh/",
|
||||
"translation": "apps/client/src/locales/messages.zh.xlf"
|
||||
}
|
||||
},
|
||||
"sourceLocale": "en"
|
||||
},
|
||||
"tags": [],
|
||||
"generators": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss"
|
||||
}
|
||||
},
|
||||
"sourceRoot": "apps/client/src",
|
||||
"prefix": "gf",
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/angular:webpack-browser",
|
||||
@ -23,7 +73,8 @@
|
||||
"styles": [
|
||||
"apps/client/src/assets/fonts/inter.css",
|
||||
"apps/client/src/styles/theme.scss",
|
||||
"apps/client/src/styles.scss"
|
||||
"apps/client/src/styles.scss",
|
||||
"node_modules/open-color/open-color.css"
|
||||
],
|
||||
"scripts": ["node_modules/marked/marked.min.js"],
|
||||
"vendorChunk": true,
|
||||
@ -135,7 +186,7 @@
|
||||
"command": "shx cp -r apps/client/src/assets/* dist/apps/client/assets"
|
||||
},
|
||||
{
|
||||
"command": "shx cp -r apps/client/src/assets/.well-known/* dist/apps/client/.well-known"
|
||||
"command": "shx cp -r apps/client/src/assets/.well-known/assetlinks.json dist/apps/client/.well-known"
|
||||
},
|
||||
{
|
||||
"command": "shx cp apps/client/src/assets/favicon.ico dist/apps/client"
|
||||
@ -211,7 +262,8 @@
|
||||
"production": {
|
||||
"buildTarget": "client:build:production"
|
||||
}
|
||||
}
|
||||
},
|
||||
"continuous": true
|
||||
},
|
||||
"extract-i18n": {
|
||||
"executor": "ng-extract-i18n-merge:ng-extract-i18n-merge",
|
||||
@ -247,55 +299,5 @@
|
||||
},
|
||||
"outputs": ["{workspaceRoot}/coverage/apps/client"]
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
"locales": {
|
||||
"ca": {
|
||||
"baseHref": "/ca/",
|
||||
"translation": "apps/client/src/locales/messages.ca.xlf"
|
||||
},
|
||||
"de": {
|
||||
"baseHref": "/de/",
|
||||
"translation": "apps/client/src/locales/messages.de.xlf"
|
||||
},
|
||||
"es": {
|
||||
"baseHref": "/es/",
|
||||
"translation": "apps/client/src/locales/messages.es.xlf"
|
||||
},
|
||||
"fr": {
|
||||
"baseHref": "/fr/",
|
||||
"translation": "apps/client/src/locales/messages.fr.xlf"
|
||||
},
|
||||
"it": {
|
||||
"baseHref": "/it/",
|
||||
"translation": "apps/client/src/locales/messages.it.xlf"
|
||||
},
|
||||
"nl": {
|
||||
"baseHref": "/nl/",
|
||||
"translation": "apps/client/src/locales/messages.nl.xlf"
|
||||
},
|
||||
"pl": {
|
||||
"baseHref": "/pl/",
|
||||
"translation": "apps/client/src/locales/messages.pl.xlf"
|
||||
},
|
||||
"pt": {
|
||||
"baseHref": "/pt/",
|
||||
"translation": "apps/client/src/locales/messages.pt.xlf"
|
||||
},
|
||||
"tr": {
|
||||
"baseHref": "/tr/",
|
||||
"translation": "apps/client/src/locales/messages.tr.xlf"
|
||||
},
|
||||
"uk": {
|
||||
"baseHref": "/uk/",
|
||||
"translation": "apps/client/src/locales/messages.uk.xlf"
|
||||
},
|
||||
"zh": {
|
||||
"baseHref": "/zh/",
|
||||
"translation": "apps/client/src/locales/messages.zh.xlf"
|
||||
}
|
||||
},
|
||||
"sourceLocale": "en"
|
||||
},
|
||||
"tags": []
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
||||
import { PageTitleStrategy } from '@ghostfolio/client/services/page-title.strategy';
|
||||
import { paths } from '@ghostfolio/common/paths';
|
||||
import { publicRoutes, internalRoutes } from '@ghostfolio/common/routes/routes';
|
||||
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes, TitleStrategy } from '@angular/router';
|
||||
@ -9,26 +9,26 @@ import { ModulePreloadService } from './core/module-preload.service';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: paths.about,
|
||||
path: publicRoutes.about.path,
|
||||
loadChildren: () =>
|
||||
import('./pages/about/about-page.module').then((m) => m.AboutPageModule)
|
||||
},
|
||||
{
|
||||
path: paths.account,
|
||||
path: internalRoutes.account.path,
|
||||
loadChildren: () =>
|
||||
import('./pages/user-account/user-account-page.module').then(
|
||||
(m) => m.UserAccountPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: paths.accounts,
|
||||
path: internalRoutes.accounts.path,
|
||||
loadChildren: () =>
|
||||
import('./pages/accounts/accounts-page.module').then(
|
||||
(m) => m.AccountsPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: paths.adminControl,
|
||||
path: internalRoutes.adminControl.path,
|
||||
loadChildren: () =>
|
||||
import('./pages/admin/admin-page.module').then((m) => m.AdminPageModule)
|
||||
},
|
||||
@ -38,16 +38,17 @@ const routes: Routes = [
|
||||
import('./pages/api/api-page.component').then(
|
||||
(c) => c.GfApiPageComponent
|
||||
),
|
||||
path: paths.api,
|
||||
title: 'Ghostfolio API'
|
||||
path: internalRoutes.api.path,
|
||||
title: internalRoutes.api.title
|
||||
},
|
||||
{
|
||||
path: paths.auth,
|
||||
path: internalRoutes.auth.path,
|
||||
loadChildren: () =>
|
||||
import('./pages/auth/auth-page.module').then((m) => m.AuthPageModule)
|
||||
import('./pages/auth/auth-page.module').then((m) => m.AuthPageModule),
|
||||
title: internalRoutes.auth.title
|
||||
},
|
||||
{
|
||||
path: paths.blog,
|
||||
path: publicRoutes.blog.path,
|
||||
loadChildren: () =>
|
||||
import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule)
|
||||
},
|
||||
@ -57,10 +58,10 @@ const routes: Routes = [
|
||||
import('./pages/demo/demo-page.component').then(
|
||||
(c) => c.GfDemoPageComponent
|
||||
),
|
||||
path: paths.demo
|
||||
path: publicRoutes.demo.path
|
||||
},
|
||||
{
|
||||
path: paths.faq,
|
||||
path: publicRoutes.faq.path,
|
||||
loadChildren: () =>
|
||||
import('./pages/faq/faq-page.module').then((m) => m.FaqPageModule)
|
||||
},
|
||||
@ -70,11 +71,11 @@ const routes: Routes = [
|
||||
import('./pages/features/features-page.component').then(
|
||||
(c) => c.GfFeaturesPageComponent
|
||||
),
|
||||
path: paths.features,
|
||||
title: $localize`Features`
|
||||
path: publicRoutes.features.path,
|
||||
title: publicRoutes.features.title
|
||||
},
|
||||
{
|
||||
path: paths.home,
|
||||
path: internalRoutes.home.path,
|
||||
loadChildren: () =>
|
||||
import('./pages/home/home-page.module').then((m) => m.HomePageModule)
|
||||
},
|
||||
@ -84,58 +85,58 @@ const routes: Routes = [
|
||||
import('./pages/i18n/i18n-page.component').then(
|
||||
(c) => c.GfI18nPageComponent
|
||||
),
|
||||
path: paths.i18n,
|
||||
title: $localize`Internationalization`
|
||||
path: internalRoutes.i18n.path,
|
||||
title: internalRoutes.i18n.title
|
||||
},
|
||||
{
|
||||
path: paths.markets,
|
||||
path: publicRoutes.markets.path,
|
||||
loadChildren: () =>
|
||||
import('./pages/markets/markets-page.module').then(
|
||||
(m) => m.MarketsPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: paths.openStartup,
|
||||
path: publicRoutes.openStartup.path,
|
||||
loadChildren: () =>
|
||||
import('./pages/open/open-page.module').then((m) => m.OpenPageModule)
|
||||
},
|
||||
{
|
||||
path: paths.portfolio,
|
||||
path: internalRoutes.portfolio.path,
|
||||
loadChildren: () =>
|
||||
import('./pages/portfolio/portfolio-page.module').then(
|
||||
(m) => m.PortfolioPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: paths.pricing,
|
||||
path: publicRoutes.pricing.path,
|
||||
loadChildren: () =>
|
||||
import('./pages/pricing/pricing-page.module').then(
|
||||
(m) => m.PricingPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: paths.public,
|
||||
path: publicRoutes.public.path,
|
||||
loadChildren: () =>
|
||||
import('./pages/public/public-page.module').then(
|
||||
(m) => m.PublicPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: paths.register,
|
||||
path: publicRoutes.register.path,
|
||||
loadChildren: () =>
|
||||
import('./pages/register/register-page.module').then(
|
||||
(m) => m.RegisterPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: paths.resources,
|
||||
path: publicRoutes.resources.path,
|
||||
loadChildren: () =>
|
||||
import('./pages/resources/resources-page.module').then(
|
||||
(m) => m.ResourcesPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: paths.start,
|
||||
path: publicRoutes.start.path,
|
||||
loadChildren: () =>
|
||||
import('./pages/landing/landing-page.module').then(
|
||||
(m) => m.LandingPageModule
|
||||
@ -146,11 +147,11 @@ const routes: Routes = [
|
||||
import('./pages/webauthn/webauthn-page.component').then(
|
||||
(c) => c.GfWebauthnPageComponent
|
||||
),
|
||||
path: paths.webauthn,
|
||||
title: $localize`Sign in`
|
||||
path: internalRoutes.webauthn.path,
|
||||
title: internalRoutes.webauthn.title
|
||||
},
|
||||
{
|
||||
path: paths.zen,
|
||||
path: internalRoutes.zen.path,
|
||||
loadChildren: () =>
|
||||
import('./pages/zen/zen-page.module').then((m) => m.ZenPageModule)
|
||||
},
|
||||
|
@ -10,7 +10,7 @@
|
||||
(click)="onCreateAccount()"
|
||||
>
|
||||
<span i18n>You are using the Live Demo.</span>
|
||||
<span class="a ml-2" i18n>Create Account</span>
|
||||
<span class="a ml-2 p-1" i18n>Create Account</span>
|
||||
</div></a
|
||||
>
|
||||
}
|
||||
|
@ -15,12 +15,14 @@
|
||||
z-index: 999;
|
||||
|
||||
.info-message {
|
||||
color: rgba(var(--palette-foreground-text), 1);
|
||||
color: rgba(var(--light-primary-text));
|
||||
font-size: 80%;
|
||||
font-weight: 500;
|
||||
max-width: 100%;
|
||||
|
||||
.a {
|
||||
font-weight: 500;
|
||||
border: 1px solid rgba(var(--light-primary-text));
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -2,8 +2,8 @@ import { GfHoldingDetailDialogComponent } from '@ghostfolio/client/components/ho
|
||||
import { HoldingDetailDialogParams } from '@ghostfolio/client/components/holding-detail-dialog/interfaces/interfaces';
|
||||
import { getCssVariable } from '@ghostfolio/common/helper';
|
||||
import { InfoItem, User } from '@ghostfolio/common/interfaces';
|
||||
import { paths } from '@ghostfolio/common/paths';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { internalRoutes, publicRoutes } from '@ghostfolio/common/routes/routes';
|
||||
import { ColorScheme } from '@ghostfolio/common/types';
|
||||
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
@ -63,25 +63,23 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
public hasTabs = false;
|
||||
public info: InfoItem;
|
||||
public pageTitle: string;
|
||||
public routerLinkAbout = ['/' + paths.about];
|
||||
public routerLinkAboutChangelog = ['/' + paths.about, paths.changelog];
|
||||
public routerLinkAboutLicense = ['/' + paths.about, paths.license];
|
||||
public routerLinkAboutPrivacyPolicy = [
|
||||
'/' + paths.about,
|
||||
paths.privacyPolicy
|
||||
];
|
||||
public routerLinkAboutTermsOfService = [
|
||||
'/' + paths.about,
|
||||
paths.termsOfService
|
||||
];
|
||||
public routerLinkBlog = ['/' + paths.blog];
|
||||
public routerLinkFaq = ['/' + paths.faq];
|
||||
public routerLinkFeatures = ['/' + paths.features];
|
||||
public routerLinkMarkets = ['/' + paths.markets];
|
||||
public routerLinkOpenStartup = ['/' + paths.openStartup];
|
||||
public routerLinkPricing = ['/' + paths.pricing];
|
||||
public routerLinkRegister = ['/' + paths.register];
|
||||
public routerLinkResources = ['/' + paths.resources];
|
||||
public routerLinkAbout = publicRoutes.about.routerLink;
|
||||
public routerLinkAboutChangelog =
|
||||
publicRoutes.about.subRoutes.changelog.routerLink;
|
||||
public routerLinkAboutLicense =
|
||||
publicRoutes.about.subRoutes.license.routerLink;
|
||||
public routerLinkAboutPrivacyPolicy =
|
||||
publicRoutes.about.subRoutes.privacyPolicy.routerLink;
|
||||
public routerLinkAboutTermsOfService =
|
||||
publicRoutes.about.subRoutes.termsOfService.routerLink;
|
||||
public routerLinkBlog = publicRoutes.blog.routerLink;
|
||||
public routerLinkFaq = publicRoutes.faq.routerLink;
|
||||
public routerLinkFeatures = publicRoutes.features.routerLink;
|
||||
public routerLinkMarkets = publicRoutes.markets.routerLink;
|
||||
public routerLinkOpenStartup = publicRoutes.openStartup.routerLink;
|
||||
public routerLinkPricing = publicRoutes.pricing.routerLink;
|
||||
public routerLinkRegister = publicRoutes.register.routerLink;
|
||||
public routerLinkResources = publicRoutes.resources.routerLink;
|
||||
public showFooter = false;
|
||||
public user: User;
|
||||
|
||||
@ -160,12 +158,14 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
this.currentSubRoute = urlSegments[1]?.path;
|
||||
|
||||
if (
|
||||
(this.currentRoute === 'home' && !this.currentSubRoute) ||
|
||||
(this.currentRoute === 'home' &&
|
||||
this.currentSubRoute === 'holdings') ||
|
||||
(this.currentRoute === 'portfolio' && !this.currentSubRoute) ||
|
||||
(this.currentRoute === 'zen' && !this.currentSubRoute) ||
|
||||
(this.currentRoute === 'zen' && this.currentSubRoute === 'holdings')
|
||||
((this.currentRoute === internalRoutes.home.path &&
|
||||
!this.currentSubRoute) ||
|
||||
(this.currentRoute === internalRoutes.home.path &&
|
||||
this.currentSubRoute ===
|
||||
internalRoutes.home.subRoutes.holdings.path) ||
|
||||
(this.currentRoute === internalRoutes.portfolio.path &&
|
||||
!this.currentSubRoute)) &&
|
||||
this.user?.settings?.viewMode !== 'ZEN'
|
||||
) {
|
||||
this.hasPermissionToChangeDateRange = true;
|
||||
} else {
|
||||
@ -173,14 +173,20 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
if (
|
||||
(this.currentRoute === 'home' &&
|
||||
this.currentSubRoute === 'holdings') ||
|
||||
(this.currentRoute === 'portfolio' && !this.currentSubRoute) ||
|
||||
(this.currentRoute === 'portfolio' &&
|
||||
this.currentSubRoute === 'activities') ||
|
||||
(this.currentRoute === 'portfolio' &&
|
||||
this.currentSubRoute === 'allocations') ||
|
||||
(this.currentRoute === 'zen' && this.currentSubRoute === 'holdings')
|
||||
(this.currentRoute === internalRoutes.home.path &&
|
||||
this.currentSubRoute ===
|
||||
internalRoutes.home.subRoutes.holdings.path) ||
|
||||
(this.currentRoute === internalRoutes.portfolio.path &&
|
||||
!this.currentSubRoute) ||
|
||||
(this.currentRoute === internalRoutes.portfolio.path &&
|
||||
this.currentSubRoute ===
|
||||
internalRoutes.portfolio.subRoutes.activities.path) ||
|
||||
(this.currentRoute === internalRoutes.portfolio.path &&
|
||||
this.currentSubRoute ===
|
||||
internalRoutes.portfolio.subRoutes.allocations.path) ||
|
||||
(this.currentRoute === internalRoutes.zen.path &&
|
||||
this.currentSubRoute ===
|
||||
internalRoutes.home.subRoutes.holdings.path)
|
||||
) {
|
||||
this.hasPermissionToChangeFilters = true;
|
||||
} else {
|
||||
@ -188,25 +194,25 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
this.hasTabs =
|
||||
(this.currentRoute === this.routerLinkAbout[0].slice(1) ||
|
||||
this.currentRoute === this.routerLinkFaq[0].slice(1) ||
|
||||
this.currentRoute === this.routerLinkResources[0].slice(1) ||
|
||||
this.currentRoute === 'account' ||
|
||||
this.currentRoute === 'admin' ||
|
||||
this.currentRoute === 'home' ||
|
||||
this.currentRoute === 'portfolio' ||
|
||||
this.currentRoute === 'zen') &&
|
||||
(this.currentRoute === publicRoutes.about.path ||
|
||||
this.currentRoute === publicRoutes.faq.path ||
|
||||
this.currentRoute === publicRoutes.resources.path ||
|
||||
this.currentRoute === internalRoutes.account.path ||
|
||||
this.currentRoute === internalRoutes.adminControl.path ||
|
||||
this.currentRoute === internalRoutes.home.path ||
|
||||
this.currentRoute === internalRoutes.portfolio.path ||
|
||||
this.currentRoute === internalRoutes.zen.path) &&
|
||||
this.deviceType !== 'mobile';
|
||||
|
||||
this.showFooter =
|
||||
(this.currentRoute === 'blog' ||
|
||||
this.currentRoute === this.routerLinkFeatures[0].slice(1) ||
|
||||
this.currentRoute === this.routerLinkMarkets[0].slice(1) ||
|
||||
this.currentRoute === 'open' ||
|
||||
this.currentRoute === 'p' ||
|
||||
this.currentRoute === this.routerLinkPricing[0].slice(1) ||
|
||||
this.currentRoute === this.routerLinkRegister[0].slice(1) ||
|
||||
this.currentRoute === 'start') &&
|
||||
(this.currentRoute === publicRoutes.blog.path ||
|
||||
this.currentRoute === publicRoutes.features.path ||
|
||||
this.currentRoute === publicRoutes.markets.path ||
|
||||
this.currentRoute === publicRoutes.openStartup.path ||
|
||||
this.currentRoute === publicRoutes.public.path ||
|
||||
this.currentRoute === publicRoutes.pricing.path ||
|
||||
this.currentRoute === publicRoutes.register.path ||
|
||||
this.currentRoute === publicRoutes.start.path) &&
|
||||
this.deviceType !== 'mobile';
|
||||
|
||||
if (this.deviceType === 'mobile') {
|
||||
|
@ -1,8 +1,7 @@
|
||||
import { ConfirmationDialogType } from '@ghostfolio/client/core/notification/confirmation-dialog/confirmation-dialog.type';
|
||||
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
|
||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||
import { Access, User } from '@ghostfolio/common/interfaces';
|
||||
import { paths } from '@ghostfolio/common/paths';
|
||||
import { publicRoutes } from '@ghostfolio/common/routes/routes';
|
||||
|
||||
import { Clipboard } from '@angular/cdk/clipboard';
|
||||
import {
|
||||
@ -54,9 +53,9 @@ export class AccessTableComponent implements OnChanges {
|
||||
}
|
||||
|
||||
public getPublicUrl(aId: string): string {
|
||||
const languageCode = this.user?.settings?.language ?? DEFAULT_LANGUAGE_CODE;
|
||||
const languageCode = this.user.settings.language;
|
||||
|
||||
return `${this.baseUrl}/${languageCode}/${paths.public}/${aId}`;
|
||||
return `${this.baseUrl}/${languageCode}/${publicRoutes.public.path}/${aId}`;
|
||||
}
|
||||
|
||||
public onCopyUrlToClipboard(aId: string): void {
|
||||
|
@ -9,8 +9,8 @@ import {
|
||||
PortfolioPosition,
|
||||
User
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { paths } from '@ghostfolio/common/paths';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { internalRoutes } from '@ghostfolio/common/routes/routes';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
@ -93,9 +93,12 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
public onCloneActivity(aActivity: Activity) {
|
||||
this.router.navigate(['/' + paths.portfolio, paths.activities], {
|
||||
queryParams: { activityId: aActivity.id, createDialog: true }
|
||||
});
|
||||
this.router.navigate(
|
||||
internalRoutes.portfolio.subRoutes.activities.routerLink,
|
||||
{
|
||||
queryParams: { activityId: aActivity.id, createDialog: true }
|
||||
}
|
||||
);
|
||||
|
||||
this.dialogRef.close();
|
||||
}
|
||||
@ -152,9 +155,12 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
public onUpdateActivity(aActivity: Activity) {
|
||||
this.router.navigate(['/' + paths.portfolio, paths.activities], {
|
||||
queryParams: { activityId: aActivity.id, editDialog: true }
|
||||
});
|
||||
this.router.navigate(
|
||||
internalRoutes.portfolio.subRoutes.activities.routerLink,
|
||||
{
|
||||
queryParams: { activityId: aActivity.id, editDialog: true }
|
||||
}
|
||||
);
|
||||
|
||||
this.dialogRef.close();
|
||||
}
|
||||
@ -168,7 +174,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||
balance,
|
||||
currency,
|
||||
name,
|
||||
Platform,
|
||||
platform,
|
||||
transactionCount,
|
||||
value,
|
||||
valueInBaseCurrency
|
||||
@ -183,7 +189,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
this.name = name;
|
||||
this.platformName = Platform?.name ?? '-';
|
||||
this.platformName = platform?.name ?? '-';
|
||||
this.transactionCount = transactionCount;
|
||||
this.valueInBaseCurrency = valueInBaseCurrency;
|
||||
|
||||
|
@ -43,11 +43,11 @@
|
||||
<ng-container i18n>Name</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
@if (element.Platform?.url) {
|
||||
<gf-asset-profile-icon
|
||||
@if (element.platform?.url) {
|
||||
<gf-entity-logo
|
||||
class="d-inline d-sm-none mr-1"
|
||||
[tooltip]="element.Platform?.name"
|
||||
[url]="element.Platform?.url"
|
||||
[tooltip]="element.platform?.name"
|
||||
[url]="element.platform.url"
|
||||
/>
|
||||
}
|
||||
<span>{{ element.name }}</span>
|
||||
@ -81,7 +81,7 @@
|
||||
*matHeaderCellDef
|
||||
class="d-none d-lg-table-cell px-1"
|
||||
mat-header-cell
|
||||
mat-sort-header="Platform.name"
|
||||
mat-sort-header="platform.name"
|
||||
>
|
||||
<ng-container i18n>Platform</ng-container>
|
||||
</th>
|
||||
@ -91,14 +91,14 @@
|
||||
mat-cell
|
||||
>
|
||||
<div class="d-flex">
|
||||
@if (element.Platform?.url) {
|
||||
<gf-asset-profile-icon
|
||||
@if (element.platform?.url) {
|
||||
<gf-entity-logo
|
||||
class="mr-1"
|
||||
[tooltip]="element.Platform?.name"
|
||||
[url]="element.Platform?.url"
|
||||
[tooltip]="element.platform?.name"
|
||||
[url]="element.platform.url"
|
||||
/>
|
||||
}
|
||||
<span>{{ element.Platform?.name }}</span>
|
||||
<span>{{ element.platform?.name }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { GfAssetProfileIconComponent } from '@ghostfolio/client/components/asset-profile-icon/asset-profile-icon.component';
|
||||
import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
|
||||
import { GfValueComponent } from '@ghostfolio/ui/value';
|
||||
|
||||
import { CommonModule } from '@angular/common';
|
||||
@ -17,7 +17,7 @@ import { AccountsTableComponent } from './accounts-table.component';
|
||||
exports: [AccountsTableComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
GfAssetProfileIconComponent,
|
||||
GfEntityLogoComponent,
|
||||
GfValueComponent,
|
||||
MatButtonModule,
|
||||
MatMenuModule,
|
||||
|
@ -11,6 +11,7 @@ import {
|
||||
AdminMarketDataDetails,
|
||||
AssetProfileIdentifier,
|
||||
LineChartItem,
|
||||
ScraperConfiguration,
|
||||
User
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
@ -41,6 +42,7 @@ import {
|
||||
AssetClass,
|
||||
AssetSubClass,
|
||||
MarketData,
|
||||
Prisma,
|
||||
SymbolProfile
|
||||
} from '@prisma/client';
|
||||
import { format } from 'date-fns';
|
||||
@ -343,7 +345,10 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
|
||||
public async onSubmitAssetProfileForm() {
|
||||
let countries = [];
|
||||
let scraperConfiguration = {};
|
||||
let scraperConfiguration: ScraperConfiguration = {
|
||||
selector: '',
|
||||
url: ''
|
||||
};
|
||||
let sectors = [];
|
||||
let symbolMapping = {};
|
||||
|
||||
@ -354,9 +359,9 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
try {
|
||||
scraperConfiguration = {
|
||||
defaultMarketPrice:
|
||||
this.assetProfileForm.controls['scraperConfiguration'].controls[
|
||||
(this.assetProfileForm.controls['scraperConfiguration'].controls[
|
||||
'defaultMarketPrice'
|
||||
].value,
|
||||
].value as number) || undefined,
|
||||
headers: JSON.parse(
|
||||
this.assetProfileForm.controls['scraperConfiguration'].controls[
|
||||
'headers'
|
||||
@ -365,10 +370,10 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
locale:
|
||||
this.assetProfileForm.controls['scraperConfiguration'].controls[
|
||||
'locale'
|
||||
].value,
|
||||
].value || undefined,
|
||||
mode: this.assetProfileForm.controls['scraperConfiguration'].controls[
|
||||
'mode'
|
||||
].value,
|
||||
].value as ScraperConfiguration['mode'],
|
||||
selector:
|
||||
this.assetProfileForm.controls['scraperConfiguration'].controls[
|
||||
'selector'
|
||||
@ -377,6 +382,10 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
'url'
|
||||
].value
|
||||
};
|
||||
|
||||
if (!scraperConfiguration.selector || !scraperConfiguration.url) {
|
||||
scraperConfiguration = undefined;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
@ -391,7 +400,6 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
|
||||
const assetProfile: UpdateAssetProfileDto = {
|
||||
countries,
|
||||
scraperConfiguration,
|
||||
sectors,
|
||||
symbolMapping,
|
||||
assetClass: this.assetProfileForm.get('assetClass').value,
|
||||
@ -400,6 +408,8 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
currency: this.assetProfileForm.get('currency').value,
|
||||
isActive: this.assetProfileForm.get('isActive').value,
|
||||
name: this.assetProfileForm.get('name').value,
|
||||
scraperConfiguration:
|
||||
scraperConfiguration as unknown as Prisma.InputJsonObject,
|
||||
url: this.assetProfileForm.get('url').value || null
|
||||
};
|
||||
|
||||
@ -493,11 +503,10 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
this.adminService
|
||||
.testMarketData({
|
||||
dataSource: this.data.dataSource,
|
||||
scraperConfiguration: JSON.stringify({
|
||||
defaultMarketPrice:
|
||||
this.assetProfileForm.controls['scraperConfiguration'].controls[
|
||||
'defaultMarketPrice'
|
||||
].value,
|
||||
scraperConfiguration: {
|
||||
defaultMarketPrice: this.assetProfileForm.controls[
|
||||
'scraperConfiguration'
|
||||
].controls['defaultMarketPrice'].value as number,
|
||||
headers: JSON.parse(
|
||||
this.assetProfileForm.controls['scraperConfiguration'].controls[
|
||||
'headers'
|
||||
@ -506,7 +515,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
locale:
|
||||
this.assetProfileForm.controls['scraperConfiguration'].controls[
|
||||
'locale'
|
||||
].value,
|
||||
].value || undefined,
|
||||
mode: this.assetProfileForm.controls['scraperConfiguration'].controls[
|
||||
'mode'
|
||||
].value,
|
||||
@ -517,7 +526,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
url: this.assetProfileForm.controls['scraperConfiguration'].controls[
|
||||
'url'
|
||||
].value
|
||||
}),
|
||||
},
|
||||
symbol: this.data.symbol
|
||||
})
|
||||
.pipe(
|
||||
|
@ -507,7 +507,7 @@
|
||||
<mat-label i18n>Url</mat-label>
|
||||
<input formControlName="url" matInput type="text" />
|
||||
@if (assetProfileForm.get('url').value) {
|
||||
<gf-asset-profile-icon
|
||||
<gf-entity-logo
|
||||
class="mr-3"
|
||||
matSuffix
|
||||
[url]="assetProfileForm.get('url').value"
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { AdminMarketDataService } from '@ghostfolio/client/components/admin-market-data/admin-market-data.service';
|
||||
import { GfAssetProfileIconComponent } from '@ghostfolio/client/components/asset-profile-icon/asset-profile-icon.component';
|
||||
import { GfCurrencySelectorComponent } from '@ghostfolio/ui/currency-selector';
|
||||
import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
|
||||
import { GfHistoricalMarketDataEditorComponent } from '@ghostfolio/ui/historical-market-data-editor';
|
||||
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
|
||||
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart';
|
||||
@ -27,8 +27,8 @@ import { AssetProfileDialog } from './asset-profile-dialog.component';
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
GfAssetProfileIconComponent,
|
||||
GfCurrencySelectorComponent,
|
||||
GfEntityLogoComponent,
|
||||
GfHistoricalMarketDataEditorComponent,
|
||||
GfLineChartComponent,
|
||||
GfPortfolioProportionChartComponent,
|
||||
|
@ -22,12 +22,13 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { MatSlideToggleChange } from '@angular/material/slide-toggle';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import {
|
||||
differenceInSeconds,
|
||||
formatDistanceToNowStrict,
|
||||
parseISO
|
||||
} from 'date-fns';
|
||||
import { StringValue } from 'ms';
|
||||
import ms, { StringValue } from 'ms';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
@ -42,6 +43,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||
public coupons: Coupon[];
|
||||
public hasPermissionForSubscription: boolean;
|
||||
public hasPermissionForSystemMessage: boolean;
|
||||
public hasPermissionToSyncDemoUserAccount: boolean;
|
||||
public hasPermissionToToggleReadOnlyMode: boolean;
|
||||
public info: InfoItem;
|
||||
public isDataGatheringEnabled: boolean;
|
||||
@ -60,6 +62,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
private notificationService: NotificationService,
|
||||
private snackBar: MatSnackBar,
|
||||
private userService: UserService
|
||||
) {
|
||||
this.info = this.dataService.fetchInfo();
|
||||
@ -80,6 +83,11 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||
permissions.enableSystemMessage
|
||||
);
|
||||
|
||||
this.hasPermissionToSyncDemoUserAccount = hasPermission(
|
||||
this.user.permissions,
|
||||
permissions.syncDemoUserAccount
|
||||
);
|
||||
|
||||
this.hasPermissionToToggleReadOnlyMode = hasPermission(
|
||||
this.user.permissions,
|
||||
permissions.toggleReadOnlyMode
|
||||
@ -206,6 +214,21 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
public onSyncDemoUserAccount() {
|
||||
this.adminService
|
||||
.syncDemoUserAccount()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
this.snackBar.open(
|
||||
'✅ ' + $localize`Demo user account has been synced.`,
|
||||
undefined,
|
||||
{
|
||||
duration: ms('3 seconds')
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
|
@ -169,10 +169,23 @@
|
||||
<div class="d-flex my-3">
|
||||
<div class="w-50" i18n>Housekeeping</div>
|
||||
<div class="w-50">
|
||||
<button color="warn" mat-flat-button (click)="onFlushCache()">
|
||||
<ion-icon class="mr-1" name="close-circle-outline" />
|
||||
<span i18n>Flush Cache</span>
|
||||
</button>
|
||||
<div class="align-items-start d-flex flex-column">
|
||||
@if (hasPermissionToSyncDemoUserAccount) {
|
||||
<button
|
||||
class="mb-2"
|
||||
color="accent"
|
||||
mat-flat-button
|
||||
(click)="onSyncDemoUserAccount()"
|
||||
>
|
||||
<ion-icon class="mr-1" name="sync-outline" />
|
||||
<span i18n>Sync Demo User Account</span>
|
||||
</button>
|
||||
}
|
||||
<button color="warn" mat-flat-button (click)="onFlushCache()">
|
||||
<ion-icon class="mr-1" name="close-circle-outline" />
|
||||
<span i18n>Flush Cache</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
|
@ -9,6 +9,7 @@ import { MatCardModule } from '@angular/material/card';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
import { AdminOverviewComponent } from './admin-overview.component';
|
||||
@ -24,6 +25,7 @@ import { AdminOverviewComponent } from './admin-overview.component';
|
||||
MatCardModule,
|
||||
MatMenuModule,
|
||||
MatSelectModule,
|
||||
MatSnackBarModule,
|
||||
MatSlideToggleModule,
|
||||
ReactiveFormsModule,
|
||||
RouterModule
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user