update
Some checks failed
Docker image CD / build_and_push (push) Failing after 0s

This commit is contained in:
2025-06-29 00:12:04 -07:00
316 changed files with 16565 additions and 9544 deletions

View File

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

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

2
.nvmrc
View File

@ -1 +1 @@
v20
v22

View File

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

View File

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

View File

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

View File

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

View File

@ -13,7 +13,7 @@ export class AccessService {
): Promise<AccessWithGranteeUser | null> {
return this.prismaService.access.findFirst({
include: {
GranteeUser: true
granteeUser: true
},
where: accessWhereInput
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,6 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { 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
});

View File

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

View File

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

View File

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

View File

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

View File

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

View 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 })
: ''
})
);
}
}

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,5 +6,5 @@ import { IsArray, IsOptional } from 'class-validator';
export class CreateAccountWithBalancesDto extends CreateAccountDto {
@IsArray()
@IsOptional()
balances?: AccountBalance;
balances?: AccountBalance[];
}

View File

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

View File

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

View File

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

View File

@ -9,7 +9,7 @@ export interface Activities {
}
export interface Activity extends Order {
Account?: AccountWithPlatform;
account?: AccountWithPlatform;
error?: ActivityError;
feeInAssetProfileCurrency: number;
feeInBaseCurrency: number;

View File

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

View File

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

View File

@ -63,7 +63,7 @@ describe('PortfolioCalculator', () => {
beforeAll(() => {
activityDtos = loadActivityExportFile(
join(__dirname, '../../../../../../../test/import/ok-btceur.json')
join(__dirname, '../../../../../../../test/import/ok/btceur.json')
);
});

View File

@ -63,7 +63,7 @@ describe('PortfolioCalculator', () => {
beforeAll(() => {
activityDtos = loadActivityExportFile(
join(__dirname, '../../../../../../../test/import/ok-btcusd.json')
join(__dirname, '../../../../../../../test/import/ok/btcusd.json')
);
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class UpdateOwnAccessTokenDto {
@IsString()
accessToken: string;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { I18nService } from './i18n.service';
@Module({
exports: [I18nService],
providers: [I18nService]
})
export class I18nModule {}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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