Browse Source

Merge remote-tracking branch 'origin/main' into mr/upstream-2025-06-25-+fixes

pull/5027/head
Dan 7 days ago
parent
commit
4382d17112
  1. 52
      CHANGELOG.md
  2. 8
      DEVELOPMENT.md
  3. 10
      apps/api/src/app/access/access.controller.ts
  4. 2
      apps/api/src/app/access/access.service.ts
  5. 6
      apps/api/src/app/account/account.controller.ts
  6. 7
      apps/api/src/app/account/account.service.ts
  7. 4
      apps/api/src/app/app.module.ts
  8. 2
      apps/api/src/app/endpoints/ai/ai.service.ts
  9. 49
      apps/api/src/app/endpoints/sitemap/sitemap.controller.ts
  10. 5
      apps/api/src/app/endpoints/sitemap/sitemap.module.ts
  11. 47
      apps/api/src/app/endpoints/sitemap/sitemap.service.ts
  12. 4
      apps/api/src/app/export/export.service.ts
  13. 40
      apps/api/src/app/health/health.controller.ts
  14. 2
      apps/api/src/app/import/import.service.ts
  15. 2
      apps/api/src/app/order/order.service.ts
  16. 31
      apps/api/src/app/portfolio/portfolio.service.ts
  17. 80
      apps/api/src/app/sitemap/sitemap.controller.ts
  18. 15
      apps/api/src/app/user/user.service.ts
  19. 371
      apps/api/src/assets/cryptocurrencies/cryptocurrencies.json
  20. 3
      apps/api/src/main.ts
  21. 63
      apps/api/src/models/rules/account-cluster-risk/current-investment.ts
  22. 21
      apps/api/src/models/rules/account-cluster-risk/single-account.ts
  23. 10
      apps/api/src/services/data-provider/data-provider.service.ts
  24. 4
      apps/api/src/services/impersonation/impersonation.service.ts
  25. 3
      apps/client/project.json
  26. 16
      apps/client/src/app/app-routing.module.ts
  27. 8
      apps/client/src/app/app.component.ts
  28. 4
      apps/client/src/app/components/access-table/access-table.component.ts
  29. 4
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  30. 16
      apps/client/src/app/components/accounts-table/accounts-table.component.html
  31. 83
      apps/client/src/app/components/admin-settings/admin-settings.component.html
  32. 20
      apps/client/src/app/components/admin-settings/admin-settings.component.scss
  33. 45
      apps/client/src/app/components/admin-settings/admin-settings.component.ts
  34. 4
      apps/client/src/app/components/admin-settings/admin-settings.module.ts
  35. 60
      apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.component.ts
  36. 49
      apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.html
  37. 2
      apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.scss
  38. 4
      apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/interfaces/interfaces.ts
  39. 15
      apps/client/src/app/components/data-provider-status/data-provider-status.component.html
  40. 51
      apps/client/src/app/components/data-provider-status/data-provider-status.component.ts
  41. 3
      apps/client/src/app/components/data-provider-status/interfaces/interfaces.ts
  42. 13
      apps/client/src/app/components/header/header.component.html
  43. 7
      apps/client/src/app/components/header/header.component.ts
  44. 8
      apps/client/src/app/core/auth.guard.ts
  45. 12
      apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.html
  46. 6
      apps/client/src/app/pages/admin/admin-page.component.ts
  47. 2
      apps/client/src/app/pages/admin/admin-page.html
  48. 9
      apps/client/src/app/pages/home/home-page-routing.module.ts
  49. 6
      apps/client/src/app/pages/home/home-page.component.ts
  50. 17
      apps/client/src/app/pages/i18n/i18n-page.html
  51. 6
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html
  52. 10
      apps/client/src/app/pages/pricing/pricing-page.html
  53. 8
      apps/client/src/app/pages/resources/glossary/resources-glossary.component.ts
  54. 8
      apps/client/src/app/pages/resources/personal-finance-tools/personal-finance-tools-page-routing.module.ts
  55. 8
      apps/client/src/app/pages/resources/personal-finance-tools/personal-finance-tools-page.component.ts
  56. 4
      apps/client/src/app/pages/resources/personal-finance-tools/personal-finance-tools-page.html
  57. 8
      apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts
  58. 11
      apps/client/src/app/pages/resources/resources-page-routing.module.ts
  59. 10
      apps/client/src/app/pages/user-account/user-account-page-routing.module.ts
  60. 7
      apps/client/src/app/services/data.service.ts
  61. 670
      apps/client/src/locales/messages.ca.xlf
  62. 676
      apps/client/src/locales/messages.de.xlf
  63. 704
      apps/client/src/locales/messages.es.xlf
  64. 674
      apps/client/src/locales/messages.fr.xlf
  65. 670
      apps/client/src/locales/messages.it.xlf
  66. 670
      apps/client/src/locales/messages.nl.xlf
  67. 670
      apps/client/src/locales/messages.pl.xlf
  68. 740
      apps/client/src/locales/messages.pt.xlf
  69. 670
      apps/client/src/locales/messages.tr.xlf
  70. 670
      apps/client/src/locales/messages.uk.xlf
  71. 646
      apps/client/src/locales/messages.xlf
  72. 670
      apps/client/src/locales/messages.zh.xlf
  73. 4
      libs/common/src/lib/interfaces/index.ts
  74. 3
      libs/common/src/lib/interfaces/responses/data-enhancer-health-response.interface.ts
  75. 3
      libs/common/src/lib/interfaces/responses/data-provider-health-response.interface.ts
  76. 4
      libs/common/src/lib/routes/interfaces/interfaces.ts
  77. 139
      libs/common/src/lib/routes/routes.ts
  78. 2
      libs/common/src/lib/types/access-with-grantee-user.type.ts
  79. 2
      libs/common/src/lib/types/account-with-platform.type.ts
  80. 2
      libs/common/src/lib/types/account-with-value.type.ts
  81. 6
      libs/ui/src/lib/activities-table/activities-table.component.html
  82. 40
      libs/ui/src/lib/assistant/assistant.component.ts
  83. 17
      libs/ui/src/lib/assistant/assistant.html
  84. 2
      libs/ui/src/lib/entity-logo/entity-logo.component.ts
  85. 11
      libs/ui/src/lib/value/value.component.html
  86. 86
      package-lock.json
  87. 8
      package.json
  88. 4
      prisma/schema.prisma

52
CHANGELOG.md

@ -5,13 +5,61 @@ 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
## 2.174.0 - 2025-06-24
### Changelog
### Added
- Set up the language localization for the static portfolio analysis rule: _Account Cluster Risks_ (Current Investment)
- Extended the data providers management of the admin control panel by the online status
### Changed
- Migrated the `@ghostfolio/ui/value` component to control flow
- Renamed `Platform` to `platform` in the `Account` database schema
- Refactored the health check endpoint for data enhancers
- Refactored the health check endpoint for data providers
- Improved the language localization for French (`fr`)
- Improved the language localization for German (`de`)
- Refreshed the cryptocurrencies list
## 2.173.0 - 2025-06-21
### Added
- Set up `open-color` for CSS variable usage
### Changed
- Simplified the data providers management of the admin control panel
- Migrated the `@ghostfolio/ui/assistant` component to control flow
- Migrated the `@ghostfolio/ui/value` component to control flow
- Renamed `GranteeUser` to `granteeUser` in the `Access` database schema
- Improved the language localization for French (`fr`)
- Improved the language localization for German (`de`)
- Upgraded `class-validator` from version `0.14.1` to `0.14.2`
- Upgraded `prisma` from version `6.9.0` to `6.10.1`
### Fixed
- Fixed an issue in the `HtmlTemplateMiddleware` related to incorrect variable resolution
- Eliminated the _Unsupported route path_ warning of the `LegacyRouteConverter` on startup
## 2.172.0 - 2025-06-19
### Added
- Set up the language localization for the static portfolio analysis rule: _Account Cluster Risks_ (Single Account)
- Included the admin control panel in the quick links of the assistant
### Changed
- Adapted the options of the date range selector in the assistant dynamically based on the user’s first activity
- Switched the data provider service to `OnModuleInit`, ensuring (currency) quotes are fetched only once
- Migrated the `@ghostfolio/ui/assistant` component to control flow
- Migrated the `@ghostfolio/ui/value` component to control flow
- Improved the language localization for Chinese (`zh`)
- Improved the language localization for Español (`es`)
- Improved the language localization for German (`de`)
- Improved the language localization for Portuguese (`pt`)
## 2.171.0 - 2025-06-15

8
DEVELOPMENT.md

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

10
apps/api/src/app/access/access.controller.ts

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

2
apps/api/src/app/access/access.service.ts

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

6
apps/api/src/app/account/account.controller.ts

@ -152,7 +152,7 @@ export class AccountController {
return this.accountService.createAccount(
{
...data,
Platform: { connect: { id: platformId } },
platform: { connect: { id: platformId } },
user: { connect: { id: this.request.user.id } }
},
this.request.user.id
@ -250,7 +250,7 @@ export class AccountController {
{
data: {
...data,
Platform: { connect: { id: platformId } },
platform: { connect: { id: platformId } },
user: { connect: { id: this.request.user.id } }
},
where: {
@ -270,7 +270,7 @@ export class AccountController {
{
data: {
...data,
Platform: originalAccount.platformId
platform: originalAccount.platformId
? { disconnect: true }
: undefined,
user: { connect: { id: this.request.user.id } }

7
apps/api/src/app/account/account.service.ts

@ -65,7 +65,7 @@ export class AccountService {
(Account & {
activities?: Order[];
balances?: AccountBalance[];
Platform?: Platform;
platform?: Platform;
})[]
> {
const { include = {}, skip, take, cursor, where, orderBy } = params;
@ -141,7 +141,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 }
});

4
apps/api/src/app/app.module.ts

@ -38,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';
@ -50,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';
@ -141,6 +141,6 @@ import { UserModule } from './user/user.module';
})
export class AppModule implements NestModule {
public configure(consumer: MiddlewareConsumer) {
consumer.apply(HtmlTemplateMiddleware).forRoutes('*');
consumer.apply(HtmlTemplateMiddleware).forRoutes('*wildcard');
}
}

2
apps/api/src/app/endpoints/ai/ai.service.ts

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

49
apps/api/src/app/endpoints/sitemap/sitemap.controller.ts

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

5
apps/api/src/app/sitemap/sitemap.module.ts → apps/api/src/app/endpoints/sitemap/sitemap.module.ts

@ -1,11 +1,14 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
import { Module } from '@nestjs/common';
import { SitemapController } from './sitemap.controller';
import { SitemapService } from './sitemap.service';
@Module({
controllers: [SitemapController],
imports: [ConfigurationModule]
imports: [ConfigurationModule, I18nModule],
providers: [SitemapService]
})
export class SitemapModule {}

47
apps/api/src/app/endpoints/sitemap/sitemap.service.ts

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

4
apps/api/src/app/export/export.service.ts

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

40
apps/api/src/app/health/health.controller.ts

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

2
apps/api/src/app/import/import.service.ts

@ -207,7 +207,7 @@ export class ImportService {
) {
accountObject = {
...accountObject,
Platform: { connect: { id: platformId } }
platform: { connect: { id: platformId } }
};
}

2
apps/api/src/app/order/order.service.ts

@ -536,7 +536,7 @@ export class OrderService {
// eslint-disable-next-line @typescript-eslint/naming-convention
Account: {
include: {
Platform: true
platform: true
}
},
// eslint-disable-next-line @typescript-eslint/naming-convention

31
apps/api/src/app/portfolio/portfolio.service.ts

@ -160,7 +160,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({
@ -1272,10 +1275,14 @@ export class PortfolioService {
[
new AccountClusterRiskCurrentInvestment(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
accounts
),
new AccountClusterRiskSingleAccount(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
accounts
)
],
@ -1876,14 +1883,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 {
@ -1900,7 +1907,7 @@ export class PortfolioService {
);
currentAccounts = await this.accountService.accounts({
include: { Platform: true },
include: { platform: true },
where: { id: { in: accountIds } }
});
}
@ -1925,18 +1932,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,
@ -1970,15 +1977,15 @@ export class PortfolioService {
}
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,
name: account.platform?.name,
valueInBaseCurrency: currentValueOfSymbolInBaseCurrency
};
}

80
apps/api/src/app/sitemap/sitemap.controller.ts

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

15
apps/api/src/app/user/user.service.ts

@ -104,7 +104,7 @@ export class UserService {
user: true
},
orderBy: { alias: 'asc' },
where: { GranteeUser: { id } }
where: { granteeUserId: id }
}),
this.prismaService.order.count({
where: { userId: id }
@ -196,7 +196,7 @@ export class UserService {
include: {
Access: true,
accounts: {
include: { Platform: true }
include: { platform: true }
},
Analytics: true,
Settings: true,
@ -259,10 +259,15 @@ 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),

371
apps/api/src/assets/cryptocurrencies/cryptocurrencies.json

File diff suppressed because it is too large

3
apps/api/src/main.ts

@ -48,7 +48,8 @@ async function bootstrap() {
exclude: [
'sitemap.xml',
...SUPPORTED_LANGUAGE_CODES.map((languageCode) => {
return `/${languageCode}/*wildcard`;
// Exclude language-specific routes with an optional wildcard
return `/${languageCode}{/*wildcard}`;
})
]
});

63
apps/api/src/models/rules/account-cluster-risk/current-investment.ts

@ -1,20 +1,22 @@
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, {
languageCode,
key: AccountClusterRiskCurrentInvestment.name
});
@ -23,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,7 +98,10 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
}
public getName() {
return 'Investment';
return this.i18nService.getTranslation({
id: 'rule.accountClusterRiskCurrentInvestment',
languageCode: this.getLanguageCode()
});
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {

21
apps/api/src/models/rules/account-cluster-risk/single-account.ts

@ -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,9 +9,12 @@ export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService,
languageCode: string,
accounts: PortfolioDetails['accounts']
) {
super(exchangeRateDataService, {
languageCode,
key: AccountClusterRiskSingleAccount.name
});
@ -22,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 +51,10 @@ export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
}
public getName() {
return this.i18nService.getTranslation({
id: 'rule.accountClusterRiskSingleAccount',
languageCode: this.getLanguageCode()
});
return 'Single Account';
}

10
apps/api/src/services/data-provider/data-provider.service.ts

@ -30,7 +30,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';
@ -38,7 +38,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(
@ -49,11 +49,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;

4
apps/api/src/services/impersonation/impersonation.service.ts

@ -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,7 +35,7 @@ export class ImpersonationService {
// Public access
const accessObject = await this.prismaService.access.findFirst({
where: {
GranteeUser: null,
granteeUserId: null,
user: { id: aId }
}
});

3
apps/client/project.json

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

16
apps/client/src/app/app-routing.module.ts

@ -1,10 +1,6 @@
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { PageTitleStrategy } from '@ghostfolio/client/services/page-title.strategy';
import {
publicRoutes,
routes as ghostfolioRoutes,
internalRoutes
} from '@ghostfolio/common/routes/routes';
import { publicRoutes, internalRoutes } from '@ghostfolio/common/routes/routes';
import { NgModule } from '@angular/core';
import { RouterModule, Routes, TitleStrategy } from '@angular/router';
@ -42,8 +38,8 @@ const routes: Routes = [
import('./pages/api/api-page.component').then(
(c) => c.GfApiPageComponent
),
path: ghostfolioRoutes.api,
title: 'Ghostfolio API'
path: internalRoutes.api.path,
title: internalRoutes.api.title
},
{
path: internalRoutes.auth.path,
@ -89,8 +85,8 @@ const routes: Routes = [
import('./pages/i18n/i18n-page.component').then(
(c) => c.GfI18nPageComponent
),
path: ghostfolioRoutes.i18n,
title: $localize`Internationalization`
path: internalRoutes.i18n.path,
title: internalRoutes.i18n.title
},
{
path: publicRoutes.markets.path,
@ -119,7 +115,7 @@ const routes: Routes = [
)
},
{
path: ghostfolioRoutes.public,
path: publicRoutes.public.path,
loadChildren: () =>
import('./pages/public/public-page.module').then(
(m) => m.PublicPageModule

8
apps/client/src/app/app.component.ts

@ -3,11 +3,7 @@ import { HoldingDetailDialogParams } from '@ghostfolio/client/components/holding
import { getCssVariable } from '@ghostfolio/common/helper';
import { InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import {
internalRoutes,
publicRoutes,
routes
} from '@ghostfolio/common/routes/routes';
import { internalRoutes, publicRoutes } from '@ghostfolio/common/routes/routes';
import { ColorScheme } from '@ghostfolio/common/types';
import { DOCUMENT } from '@angular/common';
@ -213,7 +209,7 @@ export class AppComponent implements OnDestroy, OnInit {
this.currentRoute === publicRoutes.features.path ||
this.currentRoute === publicRoutes.markets.path ||
this.currentRoute === publicRoutes.openStartup.path ||
this.currentRoute === routes.public ||
this.currentRoute === publicRoutes.public.path ||
this.currentRoute === publicRoutes.pricing.path ||
this.currentRoute === publicRoutes.register.path ||
this.currentRoute === publicRoutes.start.path) &&

4
apps/client/src/app/components/access-table/access-table.component.ts

@ -1,7 +1,7 @@
import { ConfirmationDialogType } from '@ghostfolio/client/core/notification/confirmation-dialog/confirmation-dialog.type';
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { Access, User } from '@ghostfolio/common/interfaces';
import { routes } from '@ghostfolio/common/routes/routes';
import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { Clipboard } from '@angular/cdk/clipboard';
import {
@ -55,7 +55,7 @@ export class AccessTableComponent implements OnChanges {
public getPublicUrl(aId: string): string {
const languageCode = this.user.settings.language;
return `${this.baseUrl}/${languageCode}/${routes.public}/${aId}`;
return `${this.baseUrl}/${languageCode}/${publicRoutes.public.path}/${aId}`;
}
public onCopyUrlToClipboard(aId: string): void {

4
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts

@ -174,7 +174,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
balance,
currency,
name,
Platform,
platform,
transactionCount,
value,
valueInBaseCurrency
@ -189,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;

16
apps/client/src/app/components/accounts-table/accounts-table.component.html

@ -43,11 +43,11 @@
<ng-container i18n>Name</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
@if (element.Platform?.url) {
@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) {
@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

83
apps/client/src/app/components/admin-settings/admin-settings.component.html

@ -2,12 +2,50 @@
<div class="mb-5 row">
<div class="col">
<h2 class="text-center" i18n>Data Providers</h2>
@if (isGhostfolioApiKeyValid === false) {
<mat-card appearance="outlined" class="my-3 special">
<mat-card-header>
<mat-card-title class="align-items-center d-flex m-0"
>Ghostfolio Premium
<gf-premium-indicator
class="d-inline-block mx-1"
[enableLink]="false"
/></mat-card-title>
</mat-card-header>
<mat-card-content class="gf-text-wrap-balance py-3" i18n>
Fuel your <strong>self-hosted Ghostfolio</strong> with a
<strong>powerful data provider</strong> to access
<strong>80,000+ tickers</strong> from over
<strong>50 exchanges</strong> worldwide.
</mat-card-content>
<mat-card-actions class="pb-3 pt-0 px-3">
<a
class="special"
href="mailto:hi@ghostfol.io?Subject=Ghostfolio Premium Data Provider&body=Hello,%0D%0DI am interested in the Ghostfolio Premium data provider. Could you please give me access so I can try it for some time?%0D%0DKind regards"
mat-flat-button
>
<ng-container i18n>Get Access</ng-container>
</a>
<div class="mx-3 text-muted">
<small i18n>or</small>
</div>
<a
color="accent"
mat-stroked-button
target="_blank"
[href]="pricingUrl"
>
<ng-container i18n>Learn more</ng-container>
</a>
</mat-card-actions>
</mat-card>
}
<table class="gf-table w-100" mat-table [dataSource]="dataSource">
<ng-container matColumnDef="name">
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>Name</ng-container>
</th>
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
<td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex align-items-center">
<gf-entity-logo class="mr-1" [url]="element.url" />
<div>
@ -23,8 +61,10 @@
[enableLink]="false"
/>
@if (isGhostfolioApiKeyValid === false) {
<span class="badge badge-light early-access ml-2" i18n
>Early Access</span
<span
class="badge badge-light ml-2 new text-uppercase"
i18n
>new</span
>
}
</a>
@ -47,11 +87,25 @@
</td>
</ng-container>
<ng-container matColumnDef="status">
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>Status</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
@if (
!isGhostfolioDataProvider(element) ||
isGhostfolioApiKeyValid === true
) {
<gf-data-provider-status [dataSource]="element.dataSource" />
}
</td>
</ng-container>
<ng-container matColumnDef="assetProfileCount">
<th *matHeaderCellDef class="px-1 py-2 text-right" mat-header-cell>
<th *matHeaderCellDef class="px-1 text-right" mat-header-cell>
<ng-container i18n>Asset Profiles</ng-container>
</th>
<td *matCellDef="let element" class="px-1 py-2 text-right" mat-cell>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
<gf-value
class="d-inline-block justify-content-end"
[locale]="user?.settings?.locale"
@ -60,11 +114,13 @@
</td>
</ng-container>
<ng-container matColumnDef="status">
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell></th>
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
@if (isGhostfolioDataProvider(element)) {
@if (isGhostfolioApiKeyValid === true) {
<ng-container matColumnDef="usage">
<th *matHeaderCellDef class="px-1" mat-header-cell></th>
<td *matCellDef="let element" class="px-1" mat-cell>
@if (
isGhostfolioDataProvider(element) &&
isGhostfolioApiKeyValid === true
) {
<mat-progress-bar
mode="determinate"
[value]="
@ -81,14 +137,13 @@
<ng-container i18n>daily requests</ng-container>
</small>
}
}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell></th>
<th *matHeaderCellDef class="px-1" mat-header-cell></th>
<td *matCellDef="let element" class="px-1 py-2 text-right" mat-cell>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
@if (isGhostfolioDataProvider(element)) {
@if (isGhostfolioApiKeyValid === true) {
<button

20
apps/client/src/app/components/admin-settings/admin-settings.component.scss

@ -1,16 +1,26 @@
:host {
display: block;
a,
button {
&.special {
background: linear-gradient(45deg, rgb(228, 94, 237), rgb(104, 94, 237));
background: linear-gradient(45deg, var(--oc-pink-5), var(--oc-violet-5));
color: #fff;
}
}
.badge {
&.early-access {
&.new {
border: 1px solid var(--mat-table-row-item-outline-color);
padding-bottom: 0.05rem;
}
}
.mat-mdc-card {
--mdc-outlined-card-container-color: whitesmoke;
.mat-mdc-card-actions {
min-height: 0;
}
}
@ -26,3 +36,9 @@
}
}
}
:host-context(.theme-dark) {
.mat-mdc-card {
--mdc-outlined-card-container-color: #222222;
}
}

45
apps/client/src/app/components/admin-settings/admin-settings.component.ts

@ -19,14 +19,9 @@ import {
OnDestroy,
OnInit
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatTableDataSource } from '@angular/material/table';
import { DeviceDetectorService } from 'ngx-device-detector';
import { catchError, filter, of, Subject, takeUntil } from 'rxjs';
import { GfGhostfolioPremiumApiDialogComponent } from './ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.component';
import { GhostfolioPremiumApiDialogParams } from './ghostfolio-premium-api-dialog/interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-admin-settings',
@ -37,13 +32,18 @@ import { GhostfolioPremiumApiDialogParams } from './ghostfolio-premium-api-dialo
export class AdminSettingsComponent implements OnDestroy, OnInit {
public dataSource = new MatTableDataSource<DataProviderInfo>();
public defaultDateFormat: string;
public displayedColumns = ['name', 'assetProfileCount', 'status', 'actions'];
public displayedColumns = [
'name',
'status',
'assetProfileCount',
'usage',
'actions'
];
public ghostfolioApiStatus: DataProviderGhostfolioStatusResponse;
public isGhostfolioApiKeyValid: boolean;
public isLoading = false;
public pricingUrl: string;
private deviceType: string;
private unsubscribeSubject = new Subject<void>();
private user: User;
@ -51,15 +51,11 @@ export class AdminSettingsComponent implements OnDestroy, OnInit {
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private matDialog: MatDialog,
private notificationService: NotificationService,
private userService: UserService
) {}
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
@ -100,26 +96,23 @@ export class AdminSettingsComponent implements OnDestroy, OnInit {
}
public onSetGhostfolioApiKey() {
const dialogRef = this.matDialog.open(
GfGhostfolioPremiumApiDialogComponent,
{
autoFocus: false,
data: {
deviceType: this.deviceType,
pricingUrl: this.pricingUrl
} as GhostfolioPremiumApiDialogParams,
height: this.deviceType === 'mobile' ? '98vh' : undefined,
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
}
);
this.notificationService.prompt({
confirmFn: (value) => {
const ghostfolioApiKey = value?.trim();
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
if (ghostfolioApiKey) {
this.dataService
.putAdminSetting(PROPERTY_API_KEY_GHOSTFOLIO, {
value: ghostfolioApiKey
})
.subscribe(() => {
this.initialize();
});
}
},
title: $localize`Please enter your Ghostfolio API key.`
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();

4
apps/client/src/app/components/admin-settings/admin-settings.module.ts

@ -1,5 +1,6 @@
import { GfAdminPlatformModule } from '@ghostfolio/client/components/admin-platform/admin-platform.module';
import { GfAdminTagModule } from '@ghostfolio/client/components/admin-tag/admin-tag.module';
import { GfDataProviderStatusComponent } from '@ghostfolio/client/components/data-provider-status/data-provider-status.component';
import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { GfValueComponent } from '@ghostfolio/ui/value';
@ -7,6 +8,7 @@ import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatMenuModule } from '@angular/material/menu';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatTableModule } from '@angular/material/table';
@ -21,10 +23,12 @@ import { AdminSettingsComponent } from './admin-settings.component';
CommonModule,
GfAdminPlatformModule,
GfAdminTagModule,
GfDataProviderStatusComponent,
GfEntityLogoComponent,
GfPremiumIndicatorComponent,
GfValueComponent,
MatButtonModule,
MatCardModule,
MatMenuModule,
MatProgressBarModule,
MatTableModule,

60
apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.component.ts

@ -1,60 +0,0 @@
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { PROPERTY_API_KEY_GHOSTFOLIO } from '@ghostfolio/common/config';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { Component, Inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import {
MAT_DIALOG_DATA,
MatDialogModule,
MatDialogRef
} from '@angular/material/dialog';
import { GfDialogFooterModule } from '../../dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '../../dialog-header/dialog-header.module';
import { GhostfolioPremiumApiDialogParams } from './interfaces/interfaces';
@Component({
imports: [
GfDialogFooterModule,
GfDialogHeaderModule,
GfPremiumIndicatorComponent,
MatButtonModule,
MatDialogModule
],
selector: 'gf-ghostfolio-premium-api-dialog',
styleUrls: ['./ghostfolio-premium-api-dialog.scss'],
templateUrl: './ghostfolio-premium-api-dialog.html'
})
export class GfGhostfolioPremiumApiDialogComponent {
public constructor(
@Inject(MAT_DIALOG_DATA) public data: GhostfolioPremiumApiDialogParams,
private dataService: DataService,
public dialogRef: MatDialogRef<GfGhostfolioPremiumApiDialogComponent>,
private notificationService: NotificationService
) {}
public onCancel() {
this.dialogRef.close();
}
public onSetGhostfolioApiKey() {
this.notificationService.prompt({
confirmFn: (value) => {
const ghostfolioApiKey = value?.trim();
if (ghostfolioApiKey) {
this.dataService
.putAdminSetting(PROPERTY_API_KEY_GHOSTFOLIO, {
value: ghostfolioApiKey
})
.subscribe(() => {
this.dialogRef.close();
});
}
},
title: $localize`Please enter your Ghostfolio API key.`
});
}
}

49
apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.html

@ -1,49 +0,0 @@
<gf-dialog-header
mat-dialog-title
position="center"
title="Ghostfolio Premium Data Provider"
[deviceType]="data.deviceType"
(closeButtonClicked)="onCancel()"
/>
<div class="text-center" mat-dialog-content>
<p class="gf-text-wrap-balance">
Early access to the official
<a
class="align-items-center d-inline-flex"
target="_blank"
[href]="data.pricingUrl"
>Ghostfolio Premium
<gf-premium-indicator class="d-inline-block ml-1" [enableLink]="false" />
</a>
data provider <strong>for self-hosters</strong>, offering
<strong>80’000+ tickers</strong> from over <strong>50 exchanges</strong>, is
ready now!
</p>
<div>
<a
color="primary"
href="mailto:hi@ghostfol.io?Subject=Ghostfolio Premium Data Provider&body=Hello%0D%0DI am interested in the Ghostfolio Premium data provider. Could you please give me early access so I can try it for some time?%0D%0DKind regards"
i18n
mat-flat-button
>Get Early Access</a
>
<div>
<small class="text-muted" i18n>or</small>
</div>
<button
color="accent"
i18n
mat-stroked-button
(click)="onSetGhostfolioApiKey()"
>
I have an API key
</button>
</div>
</div>
<gf-dialog-footer
mat-dialog-actions
[deviceType]="data.deviceType"
(closeButtonClicked)="onCancel()"
/>

2
apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.scss

@ -1,2 +0,0 @@
:host {
}

4
apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/interfaces/interfaces.ts

@ -1,4 +0,0 @@
export interface GhostfolioPremiumApiDialogParams {
deviceType: string;
pricingUrl: string;
}

15
apps/client/src/app/components/data-provider-status/data-provider-status.component.html

@ -0,0 +1,15 @@
@if (status$ | async; as status) {
@if (status.isHealthy) {
<span class="text-success" i18n>Available</span>
} @else {
<span class="text-danger" i18n>Unavailable</span>
}
} @else {
<ngx-skeleton-loader
animation="pulse"
[theme]="{
height: '1.5rem',
width: '100%'
}"
/>
}

51
apps/client/src/app/components/data-provider-status/data-provider-status.component.ts

@ -0,0 +1,51 @@
import { DataService } from '@ghostfolio/client/services/data.service';
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
Input,
OnDestroy,
OnInit
} from '@angular/core';
import type { DataSource } from '@prisma/client';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { catchError, map, type Observable, of, Subject, takeUntil } from 'rxjs';
import { DataProviderStatus } from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, NgxSkeletonLoaderModule],
selector: 'gf-data-provider-status',
standalone: true,
templateUrl: './data-provider-status.component.html'
})
export class GfDataProviderStatusComponent implements OnDestroy, OnInit {
@Input() dataSource: DataSource;
public status$: Observable<DataProviderStatus>;
private unsubscribeSubject = new Subject<void>();
public constructor(private dataService: DataService) {}
public ngOnInit() {
this.status$ = this.dataService
.fetchDataProviderHealth(this.dataSource)
.pipe(
catchError(() => {
return of({ isHealthy: false });
}),
map(() => {
return { isHealthy: true };
}),
takeUntil(this.unsubscribeSubject)
);
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

3
apps/client/src/app/components/data-provider-status/interfaces/interfaces.ts

@ -0,0 +1,3 @@
export interface DataProviderStatus {
isHealthy: boolean;
}

13
apps/client/src/app/components/header/header.component.html

@ -65,8 +65,10 @@
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === routes.adminControl,
'text-decoration-underline': currentRoute === routes.adminControl
'font-weight-bold':
currentRoute === internalRoutes.adminControl.path,
'text-decoration-underline':
currentRoute === internalRoutes.adminControl.path
}"
[routerLink]="routerLinkAdminControl"
>Admin Control</a
@ -268,7 +270,9 @@
<a
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === routes.account }"
[ngClass]="{
'font-weight-bold': currentRoute === internalRoutes.account.path
}"
[routerLink]="routerLinkAccount"
>My Ghostfolio</a
>
@ -278,7 +282,8 @@
i18n
mat-menu-item
[ngClass]="{
'font-weight-bold': currentRoute === routes.adminControl
'font-weight-bold':
currentRoute === internalRoutes.adminControl.path
}"
[routerLink]="routerLinkAdminControl"
>Admin Control</a

7
apps/client/src/app/components/header/header.component.ts

@ -12,11 +12,7 @@ import { TokenStorageService } from '@ghostfolio/client/services/token-storage.s
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { Filter, InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import {
internalRoutes,
publicRoutes,
routes
} from '@ghostfolio/common/routes/routes';
import { internalRoutes, publicRoutes } from '@ghostfolio/common/routes/routes';
import { DateRange } from '@ghostfolio/common/types';
import { GfAssistantComponent } from '@ghostfolio/ui/assistant/assistant.component';
@ -100,7 +96,6 @@ export class HeaderComponent implements OnChanges {
public routerLinkPricing = publicRoutes.pricing.routerLink;
public routerLinkRegister = publicRoutes.register.routerLink;
public routerLinkResources = publicRoutes.resources.routerLink;
public routes = routes;
private unsubscribeSubject = new Subject<void>();

8
apps/client/src/app/core/auth.guard.ts

@ -1,11 +1,7 @@
import { DataService } from '@ghostfolio/client/services/data.service';
import { SettingsStorageService } from '@ghostfolio/client/services/settings-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
internalRoutes,
publicRoutes,
routes
} from '@ghostfolio/common/routes/routes';
import { internalRoutes, publicRoutes } from '@ghostfolio/common/routes/routes';
import { Injectable } from '@angular/core';
import {
@ -27,7 +23,7 @@ export class AuthGuard {
`/${publicRoutes.markets.path}`,
`/${publicRoutes.openStartup.path}`,
`/${publicRoutes.pricing.path}`,
`/${routes.public}`,
`/${publicRoutes.public.path}`,
`/${publicRoutes.register.path}`,
`/${publicRoutes.resources.path}`
];

12
apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.html

@ -13,11 +13,11 @@
@for (account of accounts; track account) {
<mat-option [value]="account.id">
<div class="d-flex">
@if (account.Platform?.url) {
@if (account.platform?.url) {
<gf-entity-logo
class="mr-1"
[tooltip]="account.Platform?.name"
[url]="account.Platform?.url"
[tooltip]="account.platform?.name"
[url]="account.platform?.url"
/>
}
<span>{{ account.name }}</span>
@ -34,11 +34,11 @@
@for (account of accounts; track account) {
<mat-option [value]="account.id">
<div class="d-flex">
@if (account.Platform?.url) {
@if (account.platform?.url) {
<gf-entity-logo
class="mr-1"
[tooltip]="account.Platform?.name"
[url]="account.Platform?.url"
[tooltip]="account.platform?.name"
[url]="account.platform?.url"
/>
}
<span>{{ account.name }}</span>

6
apps/client/src/app/pages/admin/admin-page.component.ts

@ -31,7 +31,11 @@ export class AdminPageComponent implements OnDestroy, OnInit {
},
{
iconName: 'settings-outline',
label: internalRoutes.adminControl.subRoutes.settings.title,
label:
internalRoutes.adminControl.subRoutes.settings.title +
'<span class="badge badge-secondary badge-pill ml-2 text-uppercase">' +
$localize`new` +
'</span>',
routerLink: internalRoutes.adminControl.subRoutes.settings.routerLink
},
{

2
apps/client/src/app/pages/admin/admin-page.html

@ -23,7 +23,7 @@
[name]="tab.iconName"
[size]="deviceType === 'mobile' ? 'large' : 'small'"
/>
<div class="d-none d-sm-block ml-2">{{ tab.label }}</div>
<div class="d-none d-sm-block ml-2" [innerHTML]="tab.label"></div>
</a>
}
}

9
apps/client/src/app/pages/home/home-page-routing.module.ts

@ -4,10 +4,7 @@ import { HomeOverviewComponent } from '@ghostfolio/client/components/home-overvi
import { HomeSummaryComponent } from '@ghostfolio/client/components/home-summary/home-summary.component';
import { HomeWatchlistComponent } from '@ghostfolio/client/components/home-watchlist/home-watchlist.component';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import {
routes as ghostfolioRoutes,
internalRoutes
} from '@ghostfolio/common/routes/routes';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
@ -33,9 +30,9 @@ const routes: Routes = [
title: internalRoutes.home.subRoutes.summary.title
},
{
path: ghostfolioRoutes.market,
path: internalRoutes.home.subRoutes.markets.path,
component: HomeMarketComponent,
title: $localize`Markets`
title: internalRoutes.home.subRoutes.markets.title
},
{
path: internalRoutes.home.subRoutes.watchlist.path,

6
apps/client/src/app/pages/home/home-page.component.ts

@ -1,7 +1,7 @@
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { TabConfiguration, User } from '@ghostfolio/common/interfaces';
import { internalRoutes, routes } from '@ghostfolio/common/routes/routes';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DeviceDetectorService } from 'ngx-device-detector';
@ -56,8 +56,8 @@ export class HomePageComponent implements OnDestroy, OnInit {
},
{
iconName: 'newspaper-outline',
label: $localize`Markets`,
routerLink: ['/' + internalRoutes.home.path, routes.market]
label: internalRoutes.home.subRoutes.markets.title,
routerLink: internalRoutes.home.subRoutes.markets.routerLink
}
];

17
apps/client/src/app/pages/i18n/i18n-page.html

@ -11,6 +11,23 @@
performance, portfolio, software, stock, trading, wealth, web3
</li>
<li i18n="@@myAccount">My Account</li>
<li i18n="@@rule.accountClusterRiskCurrentInvestment">Investment</li>
<li i18n="@@rule.accountClusterRiskCurrentInvestment.false">
Over $&#123;thresholdMax&#125;% of your current investment is at
$&#123;maxAccountName&#125; ($&#123;maxInvestmentRatio&#125;%)
</li>
<li i18n="@@rule.accountClusterRiskCurrentInvestment.true">
The major part of your current investment is at
$&#123;maxAccountName&#125; ($&#123;maxInvestmentRatio&#125;%) and does
not exceed $&#123;thresholdMax&#125;%
</li>
<li i18n="@@rule.accountClusterRiskSingleAccount">Single Account</li>
<li i18n="@@rule.accountClusterRiskSingleAccount.false">
Your net worth is managed by a single account
</li>
<li i18n="@@rule.accountClusterRiskSingleAccount.true">
Your net worth is managed by $&#123;accountsLength&#125; accounts
</li>
<li i18n="@@rule.emergencyFundSetup">Emergency Fund: Set up</li>
<li i18n="@@rule.emergencyFundSetup.false">
No emergency fund has been set up

6
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html

@ -101,11 +101,11 @@
@for (account of data.accounts; track account) {
<mat-option [value]="account.id">
<div class="d-flex">
@if (account.Platform?.url) {
@if (account.platform?.url) {
<gf-entity-logo
class="mr-1"
[tooltip]="account.Platform?.name"
[url]="account.Platform?.url"
[tooltip]="account.platform?.name"
[url]="account.platform?.url"
/>
}
<span>{{ account.name }}</span>

10
apps/client/src/app/pages/pricing/pricing-page.html

@ -305,9 +305,13 @@
}
@if (durationExtension) {
<div class="mt-3">
<div class="badge badge-warning font-weight-normal p-3 w-100">
<strong>Limited Offer!</strong> Get
{{ durationExtension }} extra
<div
class="badge badge-warning font-weight-normal line-height-1 p-3 w-100"
>
<strong i18n>Limited Offer!</strong>
<ng-container i18n
>Get {{ durationExtension }} extra</ng-container
>
</div>
</div>
}

8
apps/client/src/app/pages/resources/glossary/resources-glossary.component.ts

@ -1,7 +1,7 @@
import { DataService } from '@ghostfolio/client/services/data.service';
import { InfoItem } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { publicRoutes, routes } from '@ghostfolio/common/routes/routes';
import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { Component, OnInit } from '@angular/core';
@ -14,10 +14,8 @@ import { Component, OnInit } from '@angular/core';
export class ResourcesGlossaryPageComponent implements OnInit {
public hasPermissionForSubscription: boolean;
public info: InfoItem;
public routerLinkResourcesPersonalFinanceTools = [
'/' + publicRoutes.resources.path,
routes.personalFinanceTools
];
public routerLinkResourcesPersonalFinanceTools =
publicRoutes.resources.subRoutes.personalFinanceTools.routerLink;
public constructor(private dataService: DataService) {
this.info = this.dataService.fetchInfo();

8
apps/client/src/app/pages/resources/personal-finance-tools/personal-finance-tools-page-routing.module.ts

@ -1,6 +1,6 @@
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools';
import { routes as ghostfolioRoutes } from '@ghostfolio/common/routes/routes';
import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
@ -12,7 +12,7 @@ const routes: Routes = [
canActivate: [AuthGuard],
component: PersonalFinanceToolsPageComponent,
path: '',
title: $localize`Personal Finance Tools`
title: publicRoutes.resources.subRoutes.personalFinanceTools.title
},
...personalFinanceTools.map(({ alias, key, name }) => {
return {
@ -24,8 +24,8 @@ const routes: Routes = [
return GfProductPageComponent;
}
),
path: `${ghostfolioRoutes.openSourceAlternativeTo}-${alias ?? key}`,
title: $localize`Open Source Alternative to ${name}`
path: `${publicRoutes.resources.subRoutes.personalFinanceTools.subRoutes.product.path}-${alias ?? key}`,
title: `${publicRoutes.resources.subRoutes.personalFinanceTools.subRoutes.product.title} ${name}`
};
})
];

8
apps/client/src/app/pages/resources/personal-finance-tools/personal-finance-tools-page.component.ts

@ -1,5 +1,5 @@
import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools';
import { publicRoutes, routes } from '@ghostfolio/common/routes/routes';
import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { Component, OnDestroy } from '@angular/core';
import { Subject } from 'rxjs';
@ -12,8 +12,12 @@ import { Subject } from 'rxjs';
standalone: false
})
export class PersonalFinanceToolsPageComponent implements OnDestroy {
public pathAlternativeTo = routes.openSourceAlternativeTo + '-';
public pathAlternativeTo =
publicRoutes.resources.subRoutes.personalFinanceTools.subRoutes.product
.path + '-';
public pathResources = publicRoutes.resources.path;
public pathPersonalFinanceTools =
publicRoutes.resources.subRoutes.personalFinanceTools.path;
public personalFinanceTools = personalFinanceTools.sort((a, b) => {
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
});

4
apps/client/src/app/pages/resources/personal-finance-tools/personal-finance-tools-page.html

@ -32,8 +32,8 @@
personalFinanceTool.name
}} - {{ personalFinanceTool.slogan }}"
[routerLink]="[
pathResources,
'personal-finance-tools',
'/' + pathResources,
pathPersonalFinanceTools,
pathAlternativeTo +
(personalFinanceTool.alias ?? personalFinanceTool.key)
]"

8
apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts

@ -1,7 +1,7 @@
import { DataService } from '@ghostfolio/client/services/data.service';
import { Product } from '@ghostfolio/common/interfaces';
import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools';
import { publicRoutes, routes } from '@ghostfolio/common/routes/routes';
import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { translate } from '@ghostfolio/ui/i18n';
import { Component, OnInit } from '@angular/core';
@ -22,10 +22,8 @@ export class GfProductPageComponent implements OnInit {
public product2: Product;
public routerLinkAbout = publicRoutes.about.routerLink;
public routerLinkFeatures = publicRoutes.features.routerLink;
public routerLinkResourcesPersonalFinanceTools = [
'/' + publicRoutes.resources.path,
routes.personalFinanceTools
];
public routerLinkResourcesPersonalFinanceTools =
publicRoutes.resources.subRoutes.personalFinanceTools.routerLink;
public tags: string[];
public constructor(

11
apps/client/src/app/pages/resources/resources-page-routing.module.ts

@ -1,8 +1,5 @@
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import {
routes as ghostfolioRoutes,
publicRoutes
} from '@ghostfolio/common/routes/routes';
import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
@ -42,13 +39,13 @@ const routes: Routes = [
(m) => m.ResourcesMarketsModule
)
},
...[ghostfolioRoutes.personalFinanceTools].map((path) => ({
path,
{
path: publicRoutes.resources.subRoutes.personalFinanceTools.path,
loadChildren: () =>
import(
'./personal-finance-tools/personal-finance-tools-page.module'
).then((m) => m.PersonalFinanceToolsPageModule)
}))
}
],
path: '',
title: publicRoutes.resources.title

10
apps/client/src/app/pages/user-account/user-account-page-routing.module.ts

@ -16,17 +16,17 @@ const routes: Routes = [
{
path: '',
component: UserAccountSettingsComponent,
title: internalRoutes.userAccount.title
title: internalRoutes.account.title
},
{
path: internalRoutes.userAccount.subRoutes.membership.path,
path: internalRoutes.account.subRoutes.membership.path,
component: UserAccountMembershipComponent,
title: internalRoutes.userAccount.subRoutes.membership.title
title: internalRoutes.account.subRoutes.membership.title
},
{
path: internalRoutes.userAccount.subRoutes.access.path,
path: internalRoutes.account.subRoutes.access.path,
component: UserAccountAccessComponent,
title: internalRoutes.userAccount.subRoutes.access.title
title: internalRoutes.account.subRoutes.access.title
}
],
component: UserAccountPageComponent,

7
apps/client/src/app/services/data.service.ts

@ -30,6 +30,7 @@ import {
AssetProfileIdentifier,
BenchmarkMarketDataDetails,
BenchmarkResponse,
DataProviderHealthResponse,
Export,
Filter,
ImportResponse,
@ -380,6 +381,12 @@ export class DataService {
return this.http.get<BenchmarkResponse>('/api/v1/benchmarks');
}
public fetchDataProviderHealth(dataSource: DataSource) {
return this.http.get<DataProviderHealthResponse>(
`/api/v1/health/data-provider/${dataSource}`
);
}
public fetchExport({
activityIds,
filters

670
apps/client/src/locales/messages.ca.xlf

File diff suppressed because it is too large

676
apps/client/src/locales/messages.de.xlf

File diff suppressed because it is too large

704
apps/client/src/locales/messages.es.xlf

File diff suppressed because it is too large

674
apps/client/src/locales/messages.fr.xlf

File diff suppressed because it is too large

670
apps/client/src/locales/messages.it.xlf

File diff suppressed because it is too large

670
apps/client/src/locales/messages.nl.xlf

File diff suppressed because it is too large

670
apps/client/src/locales/messages.pl.xlf

File diff suppressed because it is too large

740
apps/client/src/locales/messages.pt.xlf

File diff suppressed because it is too large

670
apps/client/src/locales/messages.tr.xlf

File diff suppressed because it is too large

670
apps/client/src/locales/messages.uk.xlf

File diff suppressed because it is too large

646
apps/client/src/locales/messages.xlf

File diff suppressed because it is too large

670
apps/client/src/locales/messages.zh.xlf

File diff suppressed because it is too large

4
libs/common/src/lib/interfaces/index.ts

@ -41,8 +41,10 @@ import type { AccountsResponse } from './responses/accounts-response.interface';
import type { AiPromptResponse } from './responses/ai-prompt-response.interface';
import type { ApiKeyResponse } from './responses/api-key-response.interface';
import type { BenchmarkResponse } from './responses/benchmark-response.interface';
import type { DataEnhancerHealthResponse } from './responses/data-enhancer-health-response.interface';
import type { DataProviderGhostfolioAssetProfileResponse } from './responses/data-provider-ghostfolio-asset-profile-response.interface';
import type { DataProviderGhostfolioStatusResponse } from './responses/data-provider-ghostfolio-status-response.interface';
import type { DataProviderHealthResponse } from './responses/data-provider-health-response.interface';
import type { DividendsResponse } from './responses/dividends-response.interface';
import type { ResponseError } from './responses/errors.interface';
import type { HistoricalResponse } from './responses/historical-response.interface';
@ -88,8 +90,10 @@ export {
BenchmarkProperty,
BenchmarkResponse,
Coupon,
DataEnhancerHealthResponse,
DataProviderGhostfolioAssetProfileResponse,
DataProviderGhostfolioStatusResponse,
DataProviderHealthResponse,
DataProviderInfo,
DividendsResponse,
EnhancedSymbolProfile,

3
libs/common/src/lib/interfaces/responses/data-enhancer-health-response.interface.ts

@ -0,0 +1,3 @@
export interface DataEnhancerHealthResponse {
status: string;
}

3
libs/common/src/lib/interfaces/responses/data-provider-health-response.interface.ts

@ -0,0 +1,3 @@
export interface DataProviderHealthResponse {
status: string;
}

4
libs/common/src/lib/routes/interfaces/interfaces.ts

@ -1,5 +1,7 @@
import { User } from '@ghostfolio/common/interfaces';
export interface IRoute {
excludeFromAssistant?: boolean;
excludeFromAssistant?: boolean | ((aUser: User) => boolean);
path: string;
routerLink: string[];
subRoutes?: Record<string, IRoute>;

139
libs/common/src/lib/routes/routes.ts

@ -1,18 +1,10 @@
import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import '@angular/localize/init';
import { IRoute } from './interfaces/interfaces';
export const routes = {
api: 'api',
i18n: 'i18n',
market: 'market',
personalFinanceTools: 'personal-finance-tools',
public: 'p',
// Publicly accessible pages
openSourceAlternativeTo: $localize`:kebab-case:open-source-alternative-to`
};
export const internalRoutes: Record<string, IRoute> = {
account: {
path: 'account',
@ -32,7 +24,9 @@ export const internalRoutes: Record<string, IRoute> = {
title: $localize`Settings`
},
adminControl: {
excludeFromAssistant: true,
excludeFromAssistant: (aUser: User) => {
return hasPermission(aUser?.permissions, permissions.accessAdminControl);
},
path: 'admin',
routerLink: ['/admin'],
subRoutes: {
@ -64,6 +58,12 @@ export const internalRoutes: Record<string, IRoute> = {
routerLink: ['/accounts'],
title: $localize`Accounts`
},
api: {
excludeFromAssistant: true,
path: 'api',
routerLink: ['/api'],
title: 'Ghostfolio API'
},
auth: {
excludeFromAssistant: true,
path: 'auth',
@ -79,6 +79,11 @@ export const internalRoutes: Record<string, IRoute> = {
routerLink: ['/home', 'holdings'],
title: $localize`Holdings`
},
markets: {
path: 'markets',
routerLink: ['/home', 'markets'],
title: $localize`Markets`
},
summary: {
path: 'summary',
routerLink: ['/home', 'summary'],
@ -92,6 +97,12 @@ export const internalRoutes: Record<string, IRoute> = {
},
title: $localize`Overview`
},
i18n: {
excludeFromAssistant: true,
path: 'i18n',
routerLink: ['/i18n'],
title: $localize`Internationalization`
},
portfolio: {
path: 'portfolio',
routerLink: ['/portfolio'],
@ -147,43 +158,46 @@ export const internalRoutes: Record<string, IRoute> = {
export const publicRoutes = {
about: {
path: $localize`:kebab-case:about`,
routerLink: ['/' + $localize`:kebab-case:about`],
path: $localize`:kebab-case@@routes.about:about`,
routerLink: ['/' + $localize`:kebab-case@@routes.about:about`],
subRoutes: {
changelog: {
path: $localize`:kebab-case:changelog`,
path: $localize`:kebab-case@@routes.about.changelog:changelog`,
routerLink: [
'/' + $localize`:kebab-case:about`,
$localize`:kebab-case:changelog`
'/' + $localize`:kebab-case@@routes.about:about`,
$localize`:kebab-case@@routes.about.changelog:changelog`
],
title: $localize`Changelog`
},
license: {
path: $localize`:kebab-case:license`,
path: $localize`:kebab-case@@routes.about.license:license`,
routerLink: [
'/' + $localize`:kebab-case:about`,
$localize`:kebab-case:license`
'/' + $localize`:kebab-case@@routes.about:about`,
$localize`:kebab-case@@routes.about.license:license`
],
title: $localize`License`
},
ossFriends: {
path: 'oss-friends',
routerLink: ['/' + $localize`:kebab-case:about`, 'oss-friends'],
routerLink: [
'/' + $localize`:kebab-case@@routes.about:about`,
'oss-friends'
],
title: 'OSS Friends'
},
privacyPolicy: {
path: $localize`:kebab-case:privacy-policy`,
path: $localize`:kebab-case@@routes.about.privacyPolicy:privacy-policy`,
routerLink: [
'/' + $localize`:kebab-case:about`,
$localize`:kebab-case:privacy-policy`
'/' + $localize`:kebab-case@@routes.about:about`,
$localize`:kebab-case@@routes.about.privacyPolicy:privacy-policy`
],
title: $localize`Privacy Policy`
},
termsOfService: {
path: $localize`:kebab-case:terms-of-service`,
path: $localize`:kebab-case@@routes.about.termsOfService:terms-of-service`,
routerLink: [
'/' + $localize`:kebab-case:about`,
$localize`:kebab-case:terms-of-service`
'/' + $localize`:kebab-case@@routes.about:about`,
$localize`:kebab-case@@routes.about.termsOfService:terms-of-service`
],
title: $localize`Terms of Service`
}
@ -196,24 +210,25 @@ export const publicRoutes = {
title: $localize`Blog`
},
demo: {
excludeFromSitemap: true,
path: 'demo',
routerLink: ['/demo'],
title: $localize`Live Demo`
},
faq: {
path: $localize`:kebab-case:faq`,
routerLink: ['/' + $localize`:kebab-case:faq`],
path: $localize`:kebab-case@@routes.faq:faq`,
routerLink: ['/' + $localize`:kebab-case@@routes.faq:faq`],
subRoutes: {
saas: {
path: 'saas',
routerLink: ['/' + $localize`:kebab-case:faq`, 'saas'],
routerLink: ['/' + $localize`:kebab-case@@routes.faq:faq`, 'saas'],
title: $localize`Cloud` + ' (SaaS)'
},
selfHosting: {
path: $localize`:kebab-case:self-hosting`,
path: $localize`:kebab-case@@routes.faq.selfHosting:self-hosting`,
routerLink: [
'/' + $localize`:kebab-case:faq`,
$localize`:kebab-case:self-hosting`
'/' + $localize`:kebab-case@@routes.faq:faq`,
$localize`:kebab-case@@routes.faq.selfHosting:self-hosting`
],
title: $localize`Self-Hosting`
}
@ -221,13 +236,13 @@ export const publicRoutes = {
title: $localize`Frequently Asked Questions (FAQ)`
},
features: {
path: $localize`:kebab-case:features`,
routerLink: ['/' + $localize`:kebab-case:features`],
path: $localize`:kebab-case@@routes.features:features`,
routerLink: ['/' + $localize`:kebab-case@@routes.features:features`],
title: $localize`Features`
},
markets: {
path: $localize`:kebab-case:markets`,
routerLink: ['/' + $localize`:kebab-case:markets`],
path: $localize`:kebab-case@@routes.markets:markets`,
routerLink: ['/' + $localize`:kebab-case@@routes.markets:markets`],
title: $localize`Markets`
},
openStartup: {
@ -236,42 +251,62 @@ export const publicRoutes = {
title: 'Open Startup'
},
pricing: {
path: $localize`:kebab-case:pricing`,
routerLink: ['/' + $localize`:kebab-case:pricing`],
path: $localize`:kebab-case@@routes.pricing:pricing`,
routerLink: ['/' + $localize`:kebab-case@@routes.pricing:pricing`],
title: $localize`Pricing`
},
public: {
excludeFromSitemap: true,
path: 'p',
routerLink: ['/p']
},
register: {
path: $localize`:kebab-case:register`,
routerLink: ['/' + $localize`:kebab-case:register`],
path: $localize`:kebab-case@@routes.register:register`,
routerLink: ['/' + $localize`:kebab-case@@routes.register:register`],
title: $localize`Registration`
},
resources: {
path: $localize`:kebab-case:resources`,
routerLink: ['/' + $localize`:kebab-case:resources`],
path: $localize`:kebab-case@@routes.resources:resources`,
routerLink: ['/' + $localize`:kebab-case@@routes.resources:resources`],
subRoutes: {
glossary: {
path: $localize`:kebab-case:glossary`,
path: $localize`:kebab-case@@routes.resources.glossary:glossary`,
routerLink: [
'/' + $localize`:kebab-case:resources`,
$localize`:kebab-case:glossary`
'/' + $localize`:kebab-case@@routes.resources:resources`,
$localize`:kebab-case@@routes.resources.glossary:glossary`
],
title: $localize`Glossary`
},
guides: {
path: $localize`:kebab-case:guides`,
path: $localize`:kebab-case@@routes.resources.guides:guides`,
routerLink: [
'/' + $localize`:kebab-case:resources`,
$localize`:kebab-case:guides`
'/' + $localize`:kebab-case@@routes.resources:resources`,
$localize`:kebab-case@@routes.resources.guides:guides`
],
title: $localize`Guides`
},
markets: {
path: $localize`:kebab-case:markets`,
path: $localize`:kebab-case@@routes.resources.markets:markets`,
routerLink: [
'/' + $localize`:kebab-case:resources`,
$localize`:kebab-case:markets`
'/' + $localize`:kebab-case@@routes.resources:resources`,
$localize`:kebab-case@@routes.resources.markets:markets`
],
title: $localize`Markets`
},
personalFinanceTools: {
path: $localize`:kebab-case@@routes.resources.personalFinanceTools:personal-finance-tools`,
routerLink: [
'/' + $localize`:kebab-case@@routes.resources:resources`,
$localize`:kebab-case@@routes.resources.personalFinanceTools:personal-finance-tools`
],
subRoutes: {
excludeFromSitemap: true,
product: {
path: $localize`:kebab-case@@routes.resources.personalFinanceTools.openSourceAlternativeTo:open-source-alternative-to`,
title: $localize`Open Source Alternative to`
}
},
title: $localize`Personal Finance Tools`
}
},
title: $localize`Resources`

2
libs/common/src/lib/types/access-with-grantee-user.type.ts

@ -1,3 +1,3 @@
import { Access, User } from '@prisma/client';
export type AccessWithGranteeUser = Access & { GranteeUser?: User };
export type AccessWithGranteeUser = Access & { granteeUser?: User };

2
libs/common/src/lib/types/account-with-platform.type.ts

@ -1,3 +1,3 @@
import { Account, Platform } from '@prisma/client';
export type AccountWithPlatform = Account & { Platform?: Platform };
export type AccountWithPlatform = Account & { platform?: Platform };

2
libs/common/src/lib/types/account-with-value.type.ts

@ -2,7 +2,7 @@ import { Account as AccountModel, Platform } from '@prisma/client';
export type AccountWithValue = AccountModel & {
balanceInBaseCurrency: number;
Platform?: Platform;
platform?: Platform;
transactionCount: number;
value: number;
valueInBaseCurrency: number;

6
libs/ui/src/lib/activities-table/activities-table.component.html

@ -309,11 +309,11 @@
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex">
@if (element.Account?.Platform?.url) {
@if (element.Account?.platform?.url) {
<gf-entity-logo
class="mr-1"
[tooltip]="element.Account?.Platform?.name"
[url]="element.Account?.Platform?.url"
[tooltip]="element.Account?.platform?.name"
[url]="element.Account?.platform?.url"
/>
}
<span class="d-none d-lg-block">{{ element.Account?.name }}</span>

40
libs/ui/src/lib/assistant/assistant.component.ts

@ -40,6 +40,8 @@ import { MatMenuTrigger } from '@angular/material/menu';
import { MatSelectModule } from '@angular/material/select';
import { RouterModule } from '@angular/router';
import { Account, AssetClass, DataSource } from '@prisma/client';
import { differenceInYears } from 'date-fns';
import { isFunction } from 'lodash';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { EMPTY, Observable, Subject, merge, of } from 'rxjs';
import {
@ -333,7 +335,10 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
this.accounts = this.user?.accounts ?? [];
this.dateRangeOptions = [
{ label: $localize`Today`, value: '1d' },
{
label: $localize`Today`,
value: '1d'
},
{
label: $localize`Week to date` + ' (' + $localize`WTD` + ')',
value: 'wtd'
@ -358,12 +363,18 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
{
label: $localize`Year to date` + ' (' + $localize`YTD` + ')',
value: 'ytd'
},
{
}
];
if (
this.user?.dateOfFirstActivity &&
differenceInYears(new Date(), this.user.dateOfFirstActivity) >= 1
) {
this.dateRangeOptions.push({
label: '1 ' + $localize`year` + ' (' + $localize`1Y` + ')',
value: '1y'
});
}
];
// TODO
// if (this.user?.settings?.isExperimentalFeatures) {
@ -380,13 +391,20 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
// );
// }
this.dateRangeOptions = this.dateRangeOptions.concat([
{
if (
this.user?.dateOfFirstActivity &&
differenceInYears(new Date(), this.user.dateOfFirstActivity) >= 5
) {
this.dateRangeOptions.push({
label: '5 ' + $localize`years` + ' (' + $localize`5Y` + ')',
value: '5y'
},
{ label: $localize`Max`, value: 'max' }
]);
});
}
this.dateRangeOptions.push({
label: $localize`Max`,
value: 'max'
});
this.dateRangeFormControl.disable({ emitEvent: false });
@ -623,6 +641,10 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
const allRoutes = Object.values(internalRoutes)
.filter(({ excludeFromAssistant }) => {
if (isFunction(excludeFromAssistant)) {
return excludeFromAssistant(this.user);
}
return !excludeFromAssistant;
})
.reduce((acc, route) => {

17
libs/ui/src/lib/assistant/assistant.html

@ -36,10 +36,8 @@
</button>
}
</div>
<div
*ngIf="searchFormControl.value"
class="overflow-auto py-2 result-container"
>
@if (searchFormControl.value) {
<div class="overflow-auto py-2 result-container">
@if (searchResults?.quickLinks?.length !== 0 || isLoading.quickLinks) {
<div class="mb-2">
<div class="font-weight-bold px-3 text-muted title" i18n>
@ -67,7 +65,9 @@
</div>
}
<div>
<div class="font-weight-bold px-3 text-muted title" i18n>Holdings</div>
<div class="font-weight-bold px-3 text-muted title" i18n>
Holdings
</div>
@for (
searchResultItem of searchResults?.holdings;
track searchResultItem
@ -123,6 +123,7 @@
</div>
}
</div>
}
</div>
<form [formGroup]="filterForm">
@if (!searchFormControl.value) {
@ -148,11 +149,11 @@
@for (account of accounts; track account.id) {
<mat-option [value]="account.id">
<div class="d-flex">
@if (account.Platform?.url) {
@if (account.platform?.url) {
<gf-entity-logo
class="mr-1"
[tooltip]="account.Platform?.name"
[url]="account.Platform?.url"
[tooltip]="account.platform?.name"
[url]="account.platform?.url"
/>
}
<span>{{ account.name }}</span>

2
libs/ui/src/lib/entity-logo/entity-logo.component.ts

@ -1,3 +1,4 @@
import { CommonModule } from '@angular/common';
import {
CUSTOM_ELEMENTS_SCHEMA,
ChangeDetectionStrategy,
@ -9,6 +10,7 @@ import { DataSource } from '@prisma/client';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-entity-logo',
styleUrls: ['./entity-logo.component.scss'],

11
libs/ui/src/lib/value/value.component.html

@ -5,12 +5,12 @@
}
<div class="d-flex flex-column w-100">
<ng-template #label><ng-content></ng-content></ng-template>
<ng-container *ngIf="value || value === 0 || value === null">
@if (value || value === 0 || value === null) {
<div
class="align-items-center d-flex"
[ngClass]="position === 'end' ? 'justify-content-end' : ''"
>
<ng-container *ngIf="isNumber || value === null">
@if (isNumber || value === null) {
@if (colorizeSign && !useAbsoluteValue) {
@if (+value > 0) {
<div class="mr-1 text-success">+</div>
@ -59,7 +59,7 @@
</div>
}
}
</ng-container>
}
@if (isString) {
<div
class="mb-0 text-truncate value"
@ -72,7 +72,7 @@
</div>
}
</div>
</ng-container>
}
@if (value === undefined) {
<ngx-skeleton-loader
@ -94,7 +94,8 @@
<span class="text-muted"> {{ subLabel }}</span>
}
</div>
<small *ngIf="size !== 'large'" class="d-block text-truncate">
} @else {
<small class="d-block text-truncate">
<ng-container *ngTemplateOutlet="label"></ng-container>
</small>
}

86
package-lock.json

@ -1,12 +1,12 @@
{
"name": "ghostfolio",
"version": "2.171.0",
"version": "2.174.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ghostfolio",
"version": "2.171.0",
"version": "2.174.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
@ -42,7 +42,7 @@
"@nestjs/platform-express": "11.1.3",
"@nestjs/schedule": "6.0.0",
"@nestjs/serve-static": "5.0.3",
"@prisma/client": "6.9.0",
"@prisma/client": "6.10.1",
"@simplewebauthn/browser": "13.1.0",
"@simplewebauthn/server": "13.1.1",
"@stripe/stripe-js": "7.3.1",
@ -58,7 +58,7 @@
"chartjs-plugin-datalabels": "2.2.0",
"cheerio": "1.0.0",
"class-transformer": "0.5.1",
"class-validator": "0.14.1",
"class-validator": "0.14.2",
"color": "5.0.0",
"countries-and-timezones": "3.7.2",
"countries-list": "3.1.1",
@ -149,7 +149,7 @@
"nx": "21.1.2",
"prettier": "3.5.3",
"prettier-plugin-organize-attributes": "1.0.0",
"prisma": "6.9.0",
"prisma": "6.10.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"replace-in-file": "8.3.0",
@ -9677,9 +9677,9 @@
"license": "MIT"
},
"node_modules/@prisma/client": {
"version": "6.9.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.9.0.tgz",
"integrity": "sha512-Gg7j1hwy3SgF1KHrh0PZsYvAaykeR0PaxusnLXydehS96voYCGt1U5zVR31NIouYc63hWzidcrir1a7AIyCsNQ==",
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.10.1.tgz",
"integrity": "sha512-Re4pMlcUsQsUTAYMK7EJ4Bw2kg3WfZAAlr8GjORJaK4VOP6LxRQUQ1TuLnxcF42XqGkWQ36q5CQF1yVadANQ6w==",
"hasInstallScript": true,
"license": "Apache-2.0",
"engines": {
@ -9699,9 +9699,9 @@
}
},
"node_modules/@prisma/config": {
"version": "6.9.0",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.9.0.tgz",
"integrity": "sha512-Wcfk8/lN3WRJd5w4jmNQkUwhUw0eksaU/+BlAJwPQKW10k0h0LC9PD/6TQFmqKVbHQL0vG2z266r0S1MPzzhbA==",
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.10.1.tgz",
"integrity": "sha512-kz4/bnqrOrzWo8KzYguN0cden4CzLJJ+2VSpKtF8utHS3l1JS0Lhv6BLwpOX6X9yNreTbZQZwewb+/BMPDCIYQ==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
@ -9719,53 +9719,53 @@
}
},
"node_modules/@prisma/debug": {
"version": "6.9.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.9.0.tgz",
"integrity": "sha512-bFeur/qi/Q+Mqk4JdQ3R38upSYPebv5aOyD1RKywVD+rAMLtRkmTFn28ZuTtVOnZHEdtxnNOCH+bPIeSGz1+Fg==",
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.10.1.tgz",
"integrity": "sha512-k2YT53cWxv9OLjW4zSYTZ6Z7j0gPfCzcr2Mj99qsuvlxr8WAKSZ2NcSR0zLf/mP4oxnYG842IMj3utTgcd7CaA==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/engines": {
"version": "6.9.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.9.0.tgz",
"integrity": "sha512-im0X0bwDLA0244CDf8fuvnLuCQcBBdAGgr+ByvGfQY9wWl6EA+kRGwVk8ZIpG65rnlOwtaWIr/ZcEU5pNVvq9g==",
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.10.1.tgz",
"integrity": "sha512-Q07P5rS2iPwk2IQr/rUQJ42tHjpPyFcbiH7PXZlV81Ryr9NYIgdxcUrwgVOWVm5T7ap02C0dNd1dpnNcSWig8A==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.9.0",
"@prisma/engines-version": "6.9.0-10.81e4af48011447c3cc503a190e86995b66d2a28e",
"@prisma/fetch-engine": "6.9.0",
"@prisma/get-platform": "6.9.0"
"@prisma/debug": "6.10.1",
"@prisma/engines-version": "6.10.1-1.9b628578b3b7cae625e8c927178f15a170e74a9c",
"@prisma/fetch-engine": "6.10.1",
"@prisma/get-platform": "6.10.1"
}
},
"node_modules/@prisma/engines-version": {
"version": "6.9.0-10.81e4af48011447c3cc503a190e86995b66d2a28e",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.9.0-10.81e4af48011447c3cc503a190e86995b66d2a28e.tgz",
"integrity": "sha512-Qp9gMoBHgqhKlrvumZWujmuD7q4DV/gooEyPCLtbkc13EZdSz2RsGUJ5mHb3RJgAbk+dm6XenqG7obJEhXcJ6Q==",
"version": "6.10.1-1.9b628578b3b7cae625e8c927178f15a170e74a9c",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.10.1-1.9b628578b3b7cae625e8c927178f15a170e74a9c.tgz",
"integrity": "sha512-ZJFTsEqapiTYVzXya6TUKYDFnSWCNegfUiG5ik9fleQva5Sk3DNyyUi7X1+0ZxWFHwHDr6BZV5Vm+iwP+LlciA==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine": {
"version": "6.9.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.9.0.tgz",
"integrity": "sha512-PMKhJdl4fOdeE3J3NkcWZ+tf3W6rx3ht/rLU8w4SXFRcLhd5+3VcqY4Kslpdm8osca4ej3gTfB3+cSk5pGxgFg==",
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.10.1.tgz",
"integrity": "sha512-clmbG/Jgmrc/n6Y77QcBmAUlq9LrwI9Dbgy4pq5jeEARBpRCWJDJ7PWW1P8p0LfFU0i5fsyO7FqRzRB8mkdS4g==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.9.0",
"@prisma/engines-version": "6.9.0-10.81e4af48011447c3cc503a190e86995b66d2a28e",
"@prisma/get-platform": "6.9.0"
"@prisma/debug": "6.10.1",
"@prisma/engines-version": "6.10.1-1.9b628578b3b7cae625e8c927178f15a170e74a9c",
"@prisma/get-platform": "6.10.1"
}
},
"node_modules/@prisma/get-platform": {
"version": "6.9.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.9.0.tgz",
"integrity": "sha512-/B4n+5V1LI/1JQcHp+sUpyRT1bBgZVPHbsC4lt4/19Xp4jvNIVcq5KYNtQDk5e/ukTSjo9PZVAxxy9ieFtlpTQ==",
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.10.1.tgz",
"integrity": "sha512-4CY5ndKylcsce9Mv+VWp5obbR2/86SHOLVV053pwIkhVtT9C9A83yqiqI/5kJM9T1v1u1qco/bYjDKycmei9HA==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.9.0"
"@prisma/debug": "6.10.1"
}
},
"node_modules/@redis/client": {
@ -15485,13 +15485,13 @@
"license": "MIT"
},
"node_modules/class-validator": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.1.tgz",
"integrity": "sha512-2VEG9JICxIqTpoK1eMzZqaV+u/EiwEJkMGzTrZf6sU/fwsnOITVgYJ8yojSy6CaXtO9V0Cc6ZQZ8h8m4UBuLwQ==",
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.2.tgz",
"integrity": "sha512-3kMVRF2io8N8pY1IFIXlho9r8IPUUIfHe2hYVtiebvAzU2XeQFXTv+XI4WX+TnXmtwXMDcjngcpkiPM0O9PvLw==",
"license": "MIT",
"dependencies": {
"@types/validator": "^13.11.8",
"libphonenumber-js": "^1.10.53",
"libphonenumber-js": "^1.11.1",
"validator": "^13.9.0"
}
},
@ -29051,15 +29051,15 @@
}
},
"node_modules/prisma": {
"version": "6.9.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.9.0.tgz",
"integrity": "sha512-resJAwMyZREC/I40LF6FZ6rZTnlrlrYrb63oW37Gq+U+9xHwbyMSPJjKtM7VZf3gTO86t/Oyz+YeSXr3CmAY1Q==",
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.10.1.tgz",
"integrity": "sha512-khhlC/G49E4+uyA3T3H5PRBut486HD2bDqE2+rvkU0pwk9IAqGFacLFUyIx9Uw+W2eCtf6XGwsp+/strUwMNPw==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/config": "6.9.0",
"@prisma/engines": "6.9.0"
"@prisma/config": "6.10.1",
"@prisma/engines": "6.10.1"
},
"bin": {
"prisma": "build/index.js"

8
package.json

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "2.171.0",
"version": "2.174.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio",
@ -89,7 +89,7 @@
"@nestjs/platform-express": "11.1.3",
"@nestjs/schedule": "6.0.0",
"@nestjs/serve-static": "5.0.3",
"@prisma/client": "6.9.0",
"@prisma/client": "6.10.1",
"@simplewebauthn/browser": "13.1.0",
"@simplewebauthn/server": "13.1.1",
"@stripe/stripe-js": "7.3.1",
@ -105,7 +105,7 @@
"chartjs-plugin-datalabels": "2.2.0",
"cheerio": "1.0.0",
"class-transformer": "0.5.1",
"class-validator": "0.14.1",
"class-validator": "0.14.2",
"color": "5.0.0",
"countries-and-timezones": "3.7.2",
"countries-list": "3.1.1",
@ -196,7 +196,7 @@
"nx": "21.1.2",
"prettier": "3.5.3",
"prettier-plugin-organize-attributes": "1.0.0",
"prisma": "6.9.0",
"prisma": "6.10.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"replace-in-file": "8.3.0",

4
prisma/schema.prisma

@ -12,12 +12,12 @@ datasource db {
model Access {
alias String?
createdAt DateTime @default(now())
granteeUser User? @relation("accessGet", fields: [granteeUserId], onDelete: Cascade, references: [id])
granteeUserId String?
id String @id @default(uuid())
permissions AccessPermission[] @default([READ_RESTRICTED])
updatedAt DateTime @updatedAt
userId String
GranteeUser User? @relation("accessGet", fields: [granteeUserId], onDelete: Cascade, references: [id])
user User @relation("accessGive", fields: [userId], onDelete: Cascade, references: [id])
@@index([alias])
@ -35,11 +35,11 @@ model Account {
id String @default(uuid())
isExcluded Boolean @default(false)
name String?
platform Platform? @relation(fields: [platformId], references: [id])
platformId String?
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], onDelete: Cascade, references: [id])
userId String
Platform Platform? @relation(fields: [platformId], references: [id])
@@id([id, userId])
@@index([currency])

Loading…
Cancel
Save