Browse Source

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

pull/5027/head
Dan 1 week 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. 113
      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. 53
      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. 42
      libs/ui/src/lib/assistant/assistant.component.ts
  83. 131
      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/), 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). 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/assistant` component to control flow
- Migrated the `@ghostfolio/ui/value` 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 Chinese (`zh`)
- Improved the language localization for Español (`es`)
- Improved the language localization for German (`de`)
- Improved the language localization for Portuguese (`pt`) - Improved the language localization for Portuguese (`pt`)
## 2.171.0 - 2025-06-15 ## 2.171.0 - 2025-06-15

8
DEVELOPMENT.md

@ -30,7 +30,13 @@ Run `npm run start:server`
### Start Client ### 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_ ### Start _Storybook_

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

@ -37,20 +37,20 @@ export class AccessController {
public async getAllAccesses(): Promise<Access[]> { public async getAllAccesses(): Promise<Access[]> {
const accessesWithGranteeUser = await this.accessService.accesses({ const accessesWithGranteeUser = await this.accessService.accesses({
include: { include: {
GranteeUser: true granteeUser: true
}, },
orderBy: { granteeUserId: 'asc' }, orderBy: { granteeUserId: 'asc' },
where: { userId: this.request.user.id } where: { userId: this.request.user.id }
}); });
return accessesWithGranteeUser.map( return accessesWithGranteeUser.map(
({ alias, GranteeUser, id, permissions }) => { ({ alias, granteeUser, id, permissions }) => {
if (GranteeUser) { if (granteeUser) {
return { return {
alias, alias,
id, id,
permissions, permissions,
grantee: GranteeUser?.id, grantee: granteeUser?.id,
type: 'PRIVATE' type: 'PRIVATE'
}; };
} }
@ -85,7 +85,7 @@ export class AccessController {
try { try {
return this.accessService.createAccess({ return this.accessService.createAccess({
alias: data.alias || undefined, alias: data.alias || undefined,
GranteeUser: data.granteeUserId granteeUser: data.granteeUserId
? { connect: { id: data.granteeUserId } } ? { connect: { id: data.granteeUserId } }
: undefined, : undefined,
permissions: data.permissions, permissions: data.permissions,

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

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

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

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

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

@ -65,7 +65,7 @@ export class AccountService {
(Account & { (Account & {
activities?: Order[]; activities?: Order[];
balances?: AccountBalance[]; balances?: AccountBalance[];
Platform?: Platform; platform?: Platform;
})[] })[]
> { > {
const { include = {}, skip, take, cursor, where, orderBy } = params; const { include = {}, skip, take, cursor, where, orderBy } = params;
@ -141,7 +141,10 @@ export class AccountService {
public async getAccounts(aUserId: string): Promise<Account[]> { public async getAccounts(aUserId: string): Promise<Account[]> {
const accounts = await this.accounts({ const accounts = await this.accounts({
include: { activities: true, Platform: true }, include: {
activities: true,
platform: true
},
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
where: { userId: aUserId } 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 { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module';
import { MarketDataModule } from './endpoints/market-data/market-data.module'; import { MarketDataModule } from './endpoints/market-data/market-data.module';
import { PublicModule } from './endpoints/public/public.module'; import { PublicModule } from './endpoints/public/public.module';
import { SitemapModule } from './endpoints/sitemap/sitemap.module';
import { TagsModule } from './endpoints/tags/tags.module'; import { TagsModule } from './endpoints/tags/tags.module';
import { WatchlistModule } from './endpoints/watchlist/watchlist.module'; import { WatchlistModule } from './endpoints/watchlist/watchlist.module';
import { ExchangeRateModule } from './exchange-rate/exchange-rate.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 { PlatformModule } from './platform/platform.module';
import { PortfolioModule } from './portfolio/portfolio.module'; import { PortfolioModule } from './portfolio/portfolio.module';
import { RedisCacheModule } from './redis-cache/redis-cache.module'; import { RedisCacheModule } from './redis-cache/redis-cache.module';
import { SitemapModule } from './sitemap/sitemap.module';
import { SubscriptionModule } from './subscription/subscription.module'; import { SubscriptionModule } from './subscription/subscription.module';
import { SymbolModule } from './symbol/symbol.module'; import { SymbolModule } from './symbol/symbol.module';
import { UserModule } from './user/user.module'; import { UserModule } from './user/user.module';
@ -141,6 +141,6 @@ import { UserModule } from './user/user.module';
}) })
export class AppModule implements NestModule { export class AppModule implements NestModule {
public configure(consumer: MiddlewareConsumer) { 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 = [ 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) ...Object.values(holdings)
.sort((a, b) => { .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 { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { SitemapController } from './sitemap.controller'; import { SitemapController } from './sitemap.controller';
import { SitemapService } from './sitemap.service';
@Module({ @Module({
controllers: [SitemapController], controllers: [SitemapController],
imports: [ConfigurationModule] imports: [ConfigurationModule, I18nModule],
providers: [SitemapService]
}) })
export class SitemapModule {} 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({ await this.accountService.accounts({
include: { include: {
balances: true, balances: true,
Platform: true platform: true
}, },
orderBy: { orderBy: {
name: 'asc' name: 'asc'
@ -72,7 +72,7 @@ export class ExportService {
id, id,
isExcluded, isExcluded,
name, name,
Platform: platform, platform,
platformId platformId
}) => { }) => {
if (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 { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import {
DataEnhancerHealthResponse,
DataProviderHealthResponse
} from '@ghostfolio/common/interfaces';
import { import {
Controller, Controller,
@ -37,23 +41,30 @@ export class HealthController {
} }
@Get('data-enhancer/:name') @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 = const hasResponse =
await this.healthService.hasResponseFromDataEnhancer(name); await this.healthService.hasResponseFromDataEnhancer(name);
if (hasResponse !== true) { if (hasResponse) {
throw new HttpException( return response.status(HttpStatus.OK).json({
getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE), status: getReasonPhrase(StatusCodes.OK)
StatusCodes.SERVICE_UNAVAILABLE });
); } else {
return response
.status(HttpStatus.SERVICE_UNAVAILABLE)
.json({ status: getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE) });
} }
} }
@Get('data-provider/:dataSource') @Get('data-provider/:dataSource')
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getHealthOfDataProvider( public async getHealthOfDataProvider(
@Param('dataSource') dataSource: DataSource @Param('dataSource') dataSource: DataSource,
) { @Res() response: Response
): Promise<Response<DataProviderHealthResponse>> {
if (!DataSource[dataSource]) { if (!DataSource[dataSource]) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND), getReasonPhrase(StatusCodes.NOT_FOUND),
@ -64,11 +75,14 @@ export class HealthController {
const hasResponse = const hasResponse =
await this.healthService.hasResponseFromDataProvider(dataSource); await this.healthService.hasResponseFromDataProvider(dataSource);
if (hasResponse !== true) { if (hasResponse) {
throw new HttpException( return response
getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE), .status(HttpStatus.OK)
StatusCodes.SERVICE_UNAVAILABLE .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 = {
...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 // eslint-disable-next-line @typescript-eslint/naming-convention
Account: { Account: {
include: { include: {
Platform: true platform: true
} }
}, },
// eslint-disable-next-line @typescript-eslint/naming-convention // 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([ const [accounts, details] = await Promise.all([
this.accountService.accounts({ this.accountService.accounts({
where, where,
include: { activities: true, Platform: true }, include: {
activities: true,
platform: true
},
orderBy: { name: 'asc' } orderBy: { name: 'asc' }
}), }),
this.getDetails({ this.getDetails({
@ -1272,10 +1275,14 @@ export class PortfolioService {
[ [
new AccountClusterRiskCurrentInvestment( new AccountClusterRiskCurrentInvestment(
this.exchangeRateDataService, this.exchangeRateDataService,
this.i18nService,
userSettings.language,
accounts accounts
), ),
new AccountClusterRiskSingleAccount( new AccountClusterRiskSingleAccount(
this.exchangeRateDataService, this.exchangeRateDataService,
this.i18nService,
userSettings.language,
accounts accounts
) )
], ],
@ -1876,14 +1883,14 @@ export class PortfolioService {
let currentAccounts: (Account & { let currentAccounts: (Account & {
Order?: Order[]; Order?: Order[];
Platform?: Platform; platform?: Platform;
})[] = []; })[] = [];
if (filters.length === 0) { if (filters.length === 0) {
currentAccounts = await this.accountService.getAccounts(userId); currentAccounts = await this.accountService.getAccounts(userId);
} else if (filters.length === 1 && filters[0].type === 'ACCOUNT') { } else if (filters.length === 1 && filters[0].type === 'ACCOUNT') {
currentAccounts = await this.accountService.accounts({ currentAccounts = await this.accountService.accounts({
include: { Platform: true }, include: { platform: true },
where: { id: filters[0].id } where: { id: filters[0].id }
}); });
} else { } else {
@ -1900,7 +1907,7 @@ export class PortfolioService {
); );
currentAccounts = await this.accountService.accounts({ currentAccounts = await this.accountService.accounts({
include: { Platform: true }, include: { platform: true },
where: { id: { in: accountIds } } where: { id: { in: accountIds } }
}); });
} }
@ -1925,18 +1932,18 @@ export class PortfolioService {
) )
}; };
if (platforms[account.Platform?.id || UNKNOWN_KEY]?.valueInBaseCurrency) { if (platforms[account.platformId || UNKNOWN_KEY]?.valueInBaseCurrency) {
platforms[account.Platform?.id || UNKNOWN_KEY].valueInBaseCurrency += platforms[account.platformId || UNKNOWN_KEY].valueInBaseCurrency +=
this.exchangeRateDataService.toCurrency( this.exchangeRateDataService.toCurrency(
account.balance, account.balance,
account.currency, account.currency,
userCurrency userCurrency
); );
} else { } else {
platforms[account.Platform?.id || UNKNOWN_KEY] = { platforms[account.platformId || UNKNOWN_KEY] = {
balance: account.balance, balance: account.balance,
currency: account.currency, currency: account.currency,
name: account.Platform?.name, name: account.platform?.name,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency( valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
account.balance, account.balance,
account.currency, account.currency,
@ -1970,15 +1977,15 @@ export class PortfolioService {
} }
if ( 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; currentValueOfSymbolInBaseCurrency;
} else { } else {
platforms[Account?.Platform?.id || UNKNOWN_KEY] = { platforms[Account?.platformId || UNKNOWN_KEY] = {
balance: 0, balance: 0,
currency: Account?.currency, currency: Account?.currency,
name: account.Platform?.name, name: account.platform?.name,
valueInBaseCurrency: currentValueOfSymbolInBaseCurrency 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 user: true
}, },
orderBy: { alias: 'asc' }, orderBy: { alias: 'asc' },
where: { GranteeUser: { id } } where: { granteeUserId: id }
}), }),
this.prismaService.order.count({ this.prismaService.order.count({
where: { userId: id } where: { userId: id }
@ -196,7 +196,7 @@ export class UserService {
include: { include: {
Access: true, Access: true,
accounts: { accounts: {
include: { Platform: true } include: { platform: true }
}, },
Analytics: true, Analytics: true,
Settings: true, Settings: true,
@ -259,10 +259,15 @@ export class UserService {
(user.Settings.settings as UserSettings).xRayRules = { (user.Settings.settings as UserSettings).xRayRules = {
AccountClusterRiskCurrentInvestment: AccountClusterRiskCurrentInvestment:
new AccountClusterRiskCurrentInvestment(undefined, {}).getSettings( new AccountClusterRiskCurrentInvestment(
user.Settings.settings undefined,
), undefined,
undefined,
{}
).getSettings(user.Settings.settings),
AccountClusterRiskSingleAccount: new AccountClusterRiskSingleAccount( AccountClusterRiskSingleAccount: new AccountClusterRiskSingleAccount(
undefined,
undefined,
undefined, undefined,
{} {}
).getSettings(user.Settings.settings), ).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: [ exclude: [
'sitemap.xml', 'sitemap.xml',
...SUPPORTED_LANGUAGE_CODES.map((languageCode) => { ...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 { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule'; import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
PortfolioDetails, import { PortfolioDetails, UserSettings } from '@ghostfolio/common/interfaces';
PortfolioPosition,
UserSettings import { Account } from '@prisma/client';
} from '@ghostfolio/common/interfaces';
export class AccountClusterRiskCurrentInvestment extends Rule<Settings> { export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
private accounts: PortfolioDetails['accounts']; private accounts: PortfolioDetails['accounts'];
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, protected exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService,
languageCode: string,
accounts: PortfolioDetails['accounts'] accounts: PortfolioDetails['accounts']
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
languageCode,
key: AccountClusterRiskCurrentInvestment.name key: AccountClusterRiskCurrentInvestment.name
}); });
@ -23,54 +25,62 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
public evaluate(ruleSettings: Settings) { public evaluate(ruleSettings: Settings) {
const accounts: { const accounts: {
[symbol: string]: Pick<PortfolioPosition, 'name'> & { [symbol: string]: Pick<Account, 'name'> & {
investment: number; investment: number;
}; };
} = {}; } = {};
for (const [accountId, account] of Object.entries(this.accounts)) { for (const [accountId, account] of Object.entries(this.accounts)) {
accounts[accountId] = { 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; let totalInvestment = 0;
for (const account of Object.values(accounts)) { for (const account of Object.values(accounts)) {
if (!maxItem) { if (!maxAccount) {
maxItem = account; maxAccount = account;
} }
// Calculate total investment // Calculate total investment
totalInvestment += account.investment; totalInvestment += account.investment;
// Find maximum // Find maximum
if (account.investment > maxItem?.investment) { if (account.investment > maxAccount?.investment) {
maxItem = account; maxAccount = account;
} }
} }
const maxInvestmentRatio = maxItem?.investment / totalInvestment || 0; const maxInvestmentRatio = maxAccount?.investment / totalInvestment || 0;
if (maxInvestmentRatio > ruleSettings.thresholdMax) { if (maxInvestmentRatio > ruleSettings.thresholdMax) {
return { return {
evaluation: `Over ${ evaluation: this.i18nService.getTranslation({
ruleSettings.thresholdMax * 100 id: 'rule.accountClusterRiskCurrentInvestment.false',
}% of your current investment is at ${maxItem.name} (${( languageCode: this.getLanguageCode(),
maxInvestmentRatio * 100 placeholders: {
).toPrecision(3)}%)`, maxAccountName: maxAccount.name,
maxInvestmentRatio: (maxInvestmentRatio * 100).toPrecision(3),
thresholdMax: ruleSettings.thresholdMax * 100
}
}),
value: false value: false
}; };
} }
return { return {
evaluation: `The major part of your current investment is at ${ evaluation: this.i18nService.getTranslation({
maxItem.name id: 'rule.accountClusterRiskCurrentInvestment.true',
} (${(maxInvestmentRatio * 100).toPrecision(3)}%) and does not exceed ${ languageCode: this.getLanguageCode(),
ruleSettings.thresholdMax * 100 placeholders: {
}%`, maxAccountName: maxAccount.name,
maxInvestmentRatio: (maxInvestmentRatio * 100).toPrecision(3),
thresholdMax: ruleSettings.thresholdMax * 100
}
}),
value: true value: true
}; };
} }
@ -88,7 +98,10 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
} }
public getName() { public getName() {
return 'Investment'; return this.i18nService.getTranslation({
id: 'rule.accountClusterRiskCurrentInvestment',
languageCode: this.getLanguageCode()
});
} }
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { 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 { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule'; import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { PortfolioDetails, UserSettings } from '@ghostfolio/common/interfaces'; import { PortfolioDetails, UserSettings } from '@ghostfolio/common/interfaces';
export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> { export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
@ -8,9 +9,12 @@ export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, protected exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService,
languageCode: string,
accounts: PortfolioDetails['accounts'] accounts: PortfolioDetails['accounts']
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
languageCode,
key: AccountClusterRiskSingleAccount.name key: AccountClusterRiskSingleAccount.name
}); });
@ -22,13 +26,22 @@ export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
if (accounts.length === 1) { if (accounts.length === 1) {
return { return {
evaluation: `Your net worth is managed by a single account`, evaluation: this.i18nService.getTranslation({
id: 'rule.accountClusterRiskSingleAccount.false',
languageCode: this.getLanguageCode()
}),
value: false value: false
}; };
} }
return { 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 value: true
}; };
} }
@ -38,6 +51,10 @@ export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
} }
public getName() { public getName() {
return this.i18nService.getTranslation({
id: 'rule.accountClusterRiskSingleAccount',
languageCode: this.getLanguageCode()
});
return 'Single Account'; 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 { hasRole } from '@ghostfolio/common/permissions';
import type { Granularity, UserWithSettings } from '@ghostfolio/common/types'; 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 { DataSource, MarketData, SymbolProfile } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { eachDayOfInterval, format, isBefore, isValid } from 'date-fns'; import { eachDayOfInterval, format, isBefore, isValid } from 'date-fns';
@ -38,7 +38,7 @@ import { groupBy, isEmpty, isNumber, uniqWith } from 'lodash';
import ms from 'ms'; import ms from 'ms';
@Injectable() @Injectable()
export class DataProviderService { export class DataProviderService implements OnModuleInit {
private dataProviderMapping: { [dataProviderName: string]: string }; private dataProviderMapping: { [dataProviderName: string]: string };
public constructor( public constructor(
@ -49,11 +49,9 @@ export class DataProviderService {
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService private readonly redisCacheService: RedisCacheService
) { ) {}
this.initialize();
}
public async initialize() { public async onModuleInit() {
this.dataProviderMapping = this.dataProviderMapping =
((await this.propertyService.getByKey(PROPERTY_DATA_SOURCE_MAPPING)) as { ((await this.propertyService.getByKey(PROPERTY_DATA_SOURCE_MAPPING)) as {
[dataProviderName: string]: string; [dataProviderName: string]: string;

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

@ -16,7 +16,7 @@ export class ImpersonationService {
if (this.request.user) { if (this.request.user) {
const accessObject = await this.prismaService.access.findFirst({ const accessObject = await this.prismaService.access.findFirst({
where: { where: {
GranteeUser: { id: this.request.user.id }, granteeUserId: this.request.user.id,
id: aId id: aId
} }
}); });
@ -35,7 +35,7 @@ export class ImpersonationService {
// Public access // Public access
const accessObject = await this.prismaService.access.findFirst({ const accessObject = await this.prismaService.access.findFirst({
where: { where: {
GranteeUser: null, granteeUserId: null,
user: { id: aId } user: { id: aId }
} }
}); });

3
apps/client/project.json

@ -73,7 +73,8 @@
"styles": [ "styles": [
"apps/client/src/assets/fonts/inter.css", "apps/client/src/assets/fonts/inter.css",
"apps/client/src/styles/theme.scss", "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"], "scripts": ["node_modules/marked/marked.min.js"],
"vendorChunk": true, "vendorChunk": true,

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

@ -1,10 +1,6 @@
import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { PageTitleStrategy } from '@ghostfolio/client/services/page-title.strategy'; import { PageTitleStrategy } from '@ghostfolio/client/services/page-title.strategy';
import { import { publicRoutes, internalRoutes } from '@ghostfolio/common/routes/routes';
publicRoutes,
routes as ghostfolioRoutes,
internalRoutes
} from '@ghostfolio/common/routes/routes';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { RouterModule, Routes, TitleStrategy } from '@angular/router'; import { RouterModule, Routes, TitleStrategy } from '@angular/router';
@ -42,8 +38,8 @@ const routes: Routes = [
import('./pages/api/api-page.component').then( import('./pages/api/api-page.component').then(
(c) => c.GfApiPageComponent (c) => c.GfApiPageComponent
), ),
path: ghostfolioRoutes.api, path: internalRoutes.api.path,
title: 'Ghostfolio API' title: internalRoutes.api.title
}, },
{ {
path: internalRoutes.auth.path, path: internalRoutes.auth.path,
@ -89,8 +85,8 @@ const routes: Routes = [
import('./pages/i18n/i18n-page.component').then( import('./pages/i18n/i18n-page.component').then(
(c) => c.GfI18nPageComponent (c) => c.GfI18nPageComponent
), ),
path: ghostfolioRoutes.i18n, path: internalRoutes.i18n.path,
title: $localize`Internationalization` title: internalRoutes.i18n.title
}, },
{ {
path: publicRoutes.markets.path, path: publicRoutes.markets.path,
@ -119,7 +115,7 @@ const routes: Routes = [
) )
}, },
{ {
path: ghostfolioRoutes.public, path: publicRoutes.public.path,
loadChildren: () => loadChildren: () =>
import('./pages/public/public-page.module').then( import('./pages/public/public-page.module').then(
(m) => m.PublicPageModule (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 { getCssVariable } from '@ghostfolio/common/helper';
import { InfoItem, User } from '@ghostfolio/common/interfaces'; import { InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { import { internalRoutes, publicRoutes } from '@ghostfolio/common/routes/routes';
internalRoutes,
publicRoutes,
routes
} from '@ghostfolio/common/routes/routes';
import { ColorScheme } from '@ghostfolio/common/types'; import { ColorScheme } from '@ghostfolio/common/types';
import { DOCUMENT } from '@angular/common'; import { DOCUMENT } from '@angular/common';
@ -213,7 +209,7 @@ export class AppComponent implements OnDestroy, OnInit {
this.currentRoute === publicRoutes.features.path || this.currentRoute === publicRoutes.features.path ||
this.currentRoute === publicRoutes.markets.path || this.currentRoute === publicRoutes.markets.path ||
this.currentRoute === publicRoutes.openStartup.path || this.currentRoute === publicRoutes.openStartup.path ||
this.currentRoute === routes.public || this.currentRoute === publicRoutes.public.path ||
this.currentRoute === publicRoutes.pricing.path || this.currentRoute === publicRoutes.pricing.path ||
this.currentRoute === publicRoutes.register.path || this.currentRoute === publicRoutes.register.path ||
this.currentRoute === publicRoutes.start.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 { ConfirmationDialogType } from '@ghostfolio/client/core/notification/confirmation-dialog/confirmation-dialog.type';
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service'; import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { Access, User } from '@ghostfolio/common/interfaces'; 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 { Clipboard } from '@angular/cdk/clipboard';
import { import {
@ -55,7 +55,7 @@ export class AccessTableComponent implements OnChanges {
public getPublicUrl(aId: string): string { public getPublicUrl(aId: string): string {
const languageCode = this.user.settings.language; 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 { 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, balance,
currency, currency,
name, name,
Platform, platform,
transactionCount, transactionCount,
value, value,
valueInBaseCurrency valueInBaseCurrency
@ -189,7 +189,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
} }
this.name = name; this.name = name;
this.platformName = Platform?.name ?? '-'; this.platformName = platform?.name ?? '-';
this.transactionCount = transactionCount; this.transactionCount = transactionCount;
this.valueInBaseCurrency = valueInBaseCurrency; 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> <ng-container i18n>Name</ng-container>
</th> </th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
@if (element.Platform?.url) { @if (element.platform?.url) {
<gf-entity-logo <gf-entity-logo
class="d-inline d-sm-none mr-1" class="d-inline d-sm-none mr-1"
[tooltip]="element.Platform?.name" [tooltip]="element.platform?.name"
[url]="element.Platform.url" [url]="element.platform.url"
/> />
} }
<span>{{ element.name }}</span> <span>{{ element.name }}</span>
@ -81,7 +81,7 @@
*matHeaderCellDef *matHeaderCellDef
class="d-none d-lg-table-cell px-1" class="d-none d-lg-table-cell px-1"
mat-header-cell mat-header-cell
mat-sort-header="Platform.name" mat-sort-header="platform.name"
> >
<ng-container i18n>Platform</ng-container> <ng-container i18n>Platform</ng-container>
</th> </th>
@ -91,14 +91,14 @@
mat-cell mat-cell
> >
<div class="d-flex"> <div class="d-flex">
@if (element.Platform?.url) { @if (element.platform?.url) {
<gf-entity-logo <gf-entity-logo
class="mr-1" class="mr-1"
[tooltip]="element.Platform?.name" [tooltip]="element.platform?.name"
[url]="element.Platform.url" [url]="element.platform.url"
/> />
} }
<span>{{ element.Platform?.name }}</span> <span>{{ element.platform?.name }}</span>
</div> </div>
</td> </td>
<td <td

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

@ -2,12 +2,50 @@
<div class="mb-5 row"> <div class="mb-5 row">
<div class="col"> <div class="col">
<h2 class="text-center" i18n>Data Providers</h2> <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"> <table class="gf-table w-100" mat-table [dataSource]="dataSource">
<ng-container matColumnDef="name"> <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> <ng-container i18n>Name</ng-container>
</th> </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"> <div class="d-flex align-items-center">
<gf-entity-logo class="mr-1" [url]="element.url" /> <gf-entity-logo class="mr-1" [url]="element.url" />
<div> <div>
@ -23,8 +61,10 @@
[enableLink]="false" [enableLink]="false"
/> />
@if (isGhostfolioApiKeyValid === false) { @if (isGhostfolioApiKeyValid === false) {
<span class="badge badge-light early-access ml-2" i18n <span
>Early Access</span class="badge badge-light ml-2 new text-uppercase"
i18n
>new</span
> >
} }
</a> </a>
@ -47,11 +87,25 @@
</td> </td>
</ng-container> </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"> <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> <ng-container i18n>Asset Profiles</ng-container>
</th> </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 <gf-value
class="d-inline-block justify-content-end" class="d-inline-block justify-content-end"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
@ -60,35 +114,36 @@
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="status"> <ng-container matColumnDef="usage">
<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" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
@if (isGhostfolioDataProvider(element)) { @if (
@if (isGhostfolioApiKeyValid === true) { isGhostfolioDataProvider(element) &&
<mat-progress-bar isGhostfolioApiKeyValid === true
mode="determinate" ) {
[value]=" <mat-progress-bar
100 - mode="determinate"
(ghostfolioApiStatus.dailyRequests / [value]="
ghostfolioApiStatus.dailyRequestsMax) * 100 -
100 (ghostfolioApiStatus.dailyRequests /
" ghostfolioApiStatus.dailyRequestsMax) *
/> 100
<small class="text-muted"> "
{{ ghostfolioApiStatus.dailyRequests }} />
<ng-container i18n>of</ng-container> <small class="text-muted">
{{ ghostfolioApiStatus.dailyRequestsMax }} {{ ghostfolioApiStatus.dailyRequests }}
<ng-container i18n>daily requests</ng-container> <ng-container i18n>of</ng-container>
</small> {{ ghostfolioApiStatus.dailyRequestsMax }}
} <ng-container i18n>daily requests</ng-container>
</small>
} }
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="actions"> <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 (isGhostfolioDataProvider(element)) {
@if (isGhostfolioApiKeyValid === true) { @if (isGhostfolioApiKeyValid === true) {
<button <button

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

@ -1,16 +1,26 @@
:host { :host {
display: block; display: block;
a,
button { button {
&.special { &.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; color: #fff;
} }
} }
.badge { .badge {
&.early-access { &.new {
border: 1px solid var(--mat-table-row-item-outline-color); 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;
}
}

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

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

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 { GfAdminPlatformModule } from '@ghostfolio/client/components/admin-platform/admin-platform.module';
import { GfAdminTagModule } from '@ghostfolio/client/components/admin-tag/admin-tag.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 { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { GfValueComponent } from '@ghostfolio/ui/value'; import { GfValueComponent } from '@ghostfolio/ui/value';
@ -7,6 +8,7 @@ import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatTableModule } from '@angular/material/table'; import { MatTableModule } from '@angular/material/table';
@ -21,10 +23,12 @@ import { AdminSettingsComponent } from './admin-settings.component';
CommonModule, CommonModule,
GfAdminPlatformModule, GfAdminPlatformModule,
GfAdminTagModule, GfAdminTagModule,
GfDataProviderStatusComponent,
GfEntityLogoComponent, GfEntityLogoComponent,
GfPremiumIndicatorComponent, GfPremiumIndicatorComponent,
GfValueComponent, GfValueComponent,
MatButtonModule, MatButtonModule,
MatCardModule,
MatMenuModule, MatMenuModule,
MatProgressBarModule, MatProgressBarModule,
MatTableModule, 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 i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
'font-weight-bold': currentRoute === routes.adminControl, 'font-weight-bold':
'text-decoration-underline': currentRoute === routes.adminControl currentRoute === internalRoutes.adminControl.path,
'text-decoration-underline':
currentRoute === internalRoutes.adminControl.path
}" }"
[routerLink]="routerLinkAdminControl" [routerLink]="routerLinkAdminControl"
>Admin Control</a >Admin Control</a
@ -268,7 +270,9 @@
<a <a
i18n i18n
mat-menu-item mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === routes.account }" [ngClass]="{
'font-weight-bold': currentRoute === internalRoutes.account.path
}"
[routerLink]="routerLinkAccount" [routerLink]="routerLinkAccount"
>My Ghostfolio</a >My Ghostfolio</a
> >
@ -278,7 +282,8 @@
i18n i18n
mat-menu-item mat-menu-item
[ngClass]="{ [ngClass]="{
'font-weight-bold': currentRoute === routes.adminControl 'font-weight-bold':
currentRoute === internalRoutes.adminControl.path
}" }"
[routerLink]="routerLinkAdminControl" [routerLink]="routerLinkAdminControl"
>Admin Control</a >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 { UserService } from '@ghostfolio/client/services/user/user.service';
import { Filter, InfoItem, User } from '@ghostfolio/common/interfaces'; import { Filter, InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { import { internalRoutes, publicRoutes } from '@ghostfolio/common/routes/routes';
internalRoutes,
publicRoutes,
routes
} from '@ghostfolio/common/routes/routes';
import { DateRange } from '@ghostfolio/common/types'; import { DateRange } from '@ghostfolio/common/types';
import { GfAssistantComponent } from '@ghostfolio/ui/assistant/assistant.component'; import { GfAssistantComponent } from '@ghostfolio/ui/assistant/assistant.component';
@ -100,7 +96,6 @@ export class HeaderComponent implements OnChanges {
public routerLinkPricing = publicRoutes.pricing.routerLink; public routerLinkPricing = publicRoutes.pricing.routerLink;
public routerLinkRegister = publicRoutes.register.routerLink; public routerLinkRegister = publicRoutes.register.routerLink;
public routerLinkResources = publicRoutes.resources.routerLink; public routerLinkResources = publicRoutes.resources.routerLink;
public routes = routes;
private unsubscribeSubject = new Subject<void>(); 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 { DataService } from '@ghostfolio/client/services/data.service';
import { SettingsStorageService } from '@ghostfolio/client/services/settings-storage.service'; import { SettingsStorageService } from '@ghostfolio/client/services/settings-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { import { internalRoutes, publicRoutes } from '@ghostfolio/common/routes/routes';
internalRoutes,
publicRoutes,
routes
} from '@ghostfolio/common/routes/routes';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { import {
@ -27,7 +23,7 @@ export class AuthGuard {
`/${publicRoutes.markets.path}`, `/${publicRoutes.markets.path}`,
`/${publicRoutes.openStartup.path}`, `/${publicRoutes.openStartup.path}`,
`/${publicRoutes.pricing.path}`, `/${publicRoutes.pricing.path}`,
`/${routes.public}`, `/${publicRoutes.public.path}`,
`/${publicRoutes.register.path}`, `/${publicRoutes.register.path}`,
`/${publicRoutes.resources.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) { @for (account of accounts; track account) {
<mat-option [value]="account.id"> <mat-option [value]="account.id">
<div class="d-flex"> <div class="d-flex">
@if (account.Platform?.url) { @if (account.platform?.url) {
<gf-entity-logo <gf-entity-logo
class="mr-1" class="mr-1"
[tooltip]="account.Platform?.name" [tooltip]="account.platform?.name"
[url]="account.Platform?.url" [url]="account.platform?.url"
/> />
} }
<span>{{ account.name }}</span> <span>{{ account.name }}</span>
@ -34,11 +34,11 @@
@for (account of accounts; track account) { @for (account of accounts; track account) {
<mat-option [value]="account.id"> <mat-option [value]="account.id">
<div class="d-flex"> <div class="d-flex">
@if (account.Platform?.url) { @if (account.platform?.url) {
<gf-entity-logo <gf-entity-logo
class="mr-1" class="mr-1"
[tooltip]="account.Platform?.name" [tooltip]="account.platform?.name"
[url]="account.Platform?.url" [url]="account.platform?.url"
/> />
} }
<span>{{ account.name }}</span> <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', 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 routerLink: internalRoutes.adminControl.subRoutes.settings.routerLink
}, },
{ {

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

@ -23,7 +23,7 @@
[name]="tab.iconName" [name]="tab.iconName"
[size]="deviceType === 'mobile' ? 'large' : 'small'" [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> </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 { HomeSummaryComponent } from '@ghostfolio/client/components/home-summary/home-summary.component';
import { HomeWatchlistComponent } from '@ghostfolio/client/components/home-watchlist/home-watchlist.component'; import { HomeWatchlistComponent } from '@ghostfolio/client/components/home-watchlist/home-watchlist.component';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { import { internalRoutes } from '@ghostfolio/common/routes/routes';
routes as ghostfolioRoutes,
internalRoutes
} from '@ghostfolio/common/routes/routes';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router'; import { RouterModule, Routes } from '@angular/router';
@ -33,9 +30,9 @@ const routes: Routes = [
title: internalRoutes.home.subRoutes.summary.title title: internalRoutes.home.subRoutes.summary.title
}, },
{ {
path: ghostfolioRoutes.market, path: internalRoutes.home.subRoutes.markets.path,
component: HomeMarketComponent, component: HomeMarketComponent,
title: $localize`Markets` title: internalRoutes.home.subRoutes.markets.title
}, },
{ {
path: internalRoutes.home.subRoutes.watchlist.path, 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 { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { TabConfiguration, User } from '@ghostfolio/common/interfaces'; 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 { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
@ -56,8 +56,8 @@ export class HomePageComponent implements OnDestroy, OnInit {
}, },
{ {
iconName: 'newspaper-outline', iconName: 'newspaper-outline',
label: $localize`Markets`, label: internalRoutes.home.subRoutes.markets.title,
routerLink: ['/' + internalRoutes.home.path, routes.market] 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 performance, portfolio, software, stock, trading, wealth, web3
</li> </li>
<li i18n="@@myAccount">My Account</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">Emergency Fund: Set up</li>
<li i18n="@@rule.emergencyFundSetup.false"> <li i18n="@@rule.emergencyFundSetup.false">
No emergency fund has been set up 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) { @for (account of data.accounts; track account) {
<mat-option [value]="account.id"> <mat-option [value]="account.id">
<div class="d-flex"> <div class="d-flex">
@if (account.Platform?.url) { @if (account.platform?.url) {
<gf-entity-logo <gf-entity-logo
class="mr-1" class="mr-1"
[tooltip]="account.Platform?.name" [tooltip]="account.platform?.name"
[url]="account.Platform?.url" [url]="account.platform?.url"
/> />
} }
<span>{{ account.name }}</span> <span>{{ account.name }}</span>

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

@ -305,9 +305,13 @@
} }
@if (durationExtension) { @if (durationExtension) {
<div class="mt-3"> <div class="mt-3">
<div class="badge badge-warning font-weight-normal p-3 w-100"> <div
<strong>Limited Offer!</strong> Get class="badge badge-warning font-weight-normal line-height-1 p-3 w-100"
{{ durationExtension }} extra >
<strong i18n>Limited Offer!</strong>
<ng-container i18n
>Get {{ durationExtension }} extra</ng-container
>
</div> </div>
</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 { DataService } from '@ghostfolio/client/services/data.service';
import { InfoItem } from '@ghostfolio/common/interfaces'; import { InfoItem } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; 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'; import { Component, OnInit } from '@angular/core';
@ -14,10 +14,8 @@ import { Component, OnInit } from '@angular/core';
export class ResourcesGlossaryPageComponent implements OnInit { export class ResourcesGlossaryPageComponent implements OnInit {
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
public info: InfoItem; public info: InfoItem;
public routerLinkResourcesPersonalFinanceTools = [ public routerLinkResourcesPersonalFinanceTools =
'/' + publicRoutes.resources.path, publicRoutes.resources.subRoutes.personalFinanceTools.routerLink;
routes.personalFinanceTools
];
public constructor(private dataService: DataService) { public constructor(private dataService: DataService) {
this.info = this.dataService.fetchInfo(); 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 { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools'; 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 { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router'; import { RouterModule, Routes } from '@angular/router';
@ -12,7 +12,7 @@ const routes: Routes = [
canActivate: [AuthGuard], canActivate: [AuthGuard],
component: PersonalFinanceToolsPageComponent, component: PersonalFinanceToolsPageComponent,
path: '', path: '',
title: $localize`Personal Finance Tools` title: publicRoutes.resources.subRoutes.personalFinanceTools.title
}, },
...personalFinanceTools.map(({ alias, key, name }) => { ...personalFinanceTools.map(({ alias, key, name }) => {
return { return {
@ -24,8 +24,8 @@ const routes: Routes = [
return GfProductPageComponent; return GfProductPageComponent;
} }
), ),
path: `${ghostfolioRoutes.openSourceAlternativeTo}-${alias ?? key}`, path: `${publicRoutes.resources.subRoutes.personalFinanceTools.subRoutes.product.path}-${alias ?? key}`,
title: $localize`Open Source Alternative to ${name}` 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 { 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 { Component, OnDestroy } from '@angular/core';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
@ -12,8 +12,12 @@ import { Subject } from 'rxjs';
standalone: false standalone: false
}) })
export class PersonalFinanceToolsPageComponent implements OnDestroy { export class PersonalFinanceToolsPageComponent implements OnDestroy {
public pathAlternativeTo = routes.openSourceAlternativeTo + '-'; public pathAlternativeTo =
publicRoutes.resources.subRoutes.personalFinanceTools.subRoutes.product
.path + '-';
public pathResources = publicRoutes.resources.path; public pathResources = publicRoutes.resources.path;
public pathPersonalFinanceTools =
publicRoutes.resources.subRoutes.personalFinanceTools.path;
public personalFinanceTools = personalFinanceTools.sort((a, b) => { public personalFinanceTools = personalFinanceTools.sort((a, b) => {
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); 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.name
}} - {{ personalFinanceTool.slogan }}" }} - {{ personalFinanceTool.slogan }}"
[routerLink]="[ [routerLink]="[
pathResources, '/' + pathResources,
'personal-finance-tools', pathPersonalFinanceTools,
pathAlternativeTo + pathAlternativeTo +
(personalFinanceTool.alias ?? personalFinanceTool.key) (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 { DataService } from '@ghostfolio/client/services/data.service';
import { Product } from '@ghostfolio/common/interfaces'; import { Product } from '@ghostfolio/common/interfaces';
import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools'; 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 { translate } from '@ghostfolio/ui/i18n';
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
@ -22,10 +22,8 @@ export class GfProductPageComponent implements OnInit {
public product2: Product; public product2: Product;
public routerLinkAbout = publicRoutes.about.routerLink; public routerLinkAbout = publicRoutes.about.routerLink;
public routerLinkFeatures = publicRoutes.features.routerLink; public routerLinkFeatures = publicRoutes.features.routerLink;
public routerLinkResourcesPersonalFinanceTools = [ public routerLinkResourcesPersonalFinanceTools =
'/' + publicRoutes.resources.path, publicRoutes.resources.subRoutes.personalFinanceTools.routerLink;
routes.personalFinanceTools
];
public tags: string[]; public tags: string[];
public constructor( 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 { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { import { publicRoutes } from '@ghostfolio/common/routes/routes';
routes as ghostfolioRoutes,
publicRoutes
} from '@ghostfolio/common/routes/routes';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router'; import { RouterModule, Routes } from '@angular/router';
@ -42,13 +39,13 @@ const routes: Routes = [
(m) => m.ResourcesMarketsModule (m) => m.ResourcesMarketsModule
) )
}, },
...[ghostfolioRoutes.personalFinanceTools].map((path) => ({ {
path, path: publicRoutes.resources.subRoutes.personalFinanceTools.path,
loadChildren: () => loadChildren: () =>
import( import(
'./personal-finance-tools/personal-finance-tools-page.module' './personal-finance-tools/personal-finance-tools-page.module'
).then((m) => m.PersonalFinanceToolsPageModule) ).then((m) => m.PersonalFinanceToolsPageModule)
})) }
], ],
path: '', path: '',
title: publicRoutes.resources.title 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: '', path: '',
component: UserAccountSettingsComponent, component: UserAccountSettingsComponent,
title: internalRoutes.userAccount.title title: internalRoutes.account.title
}, },
{ {
path: internalRoutes.userAccount.subRoutes.membership.path, path: internalRoutes.account.subRoutes.membership.path,
component: UserAccountMembershipComponent, 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, component: UserAccountAccessComponent,
title: internalRoutes.userAccount.subRoutes.access.title title: internalRoutes.account.subRoutes.access.title
} }
], ],
component: UserAccountPageComponent, component: UserAccountPageComponent,

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

@ -30,6 +30,7 @@ import {
AssetProfileIdentifier, AssetProfileIdentifier,
BenchmarkMarketDataDetails, BenchmarkMarketDataDetails,
BenchmarkResponse, BenchmarkResponse,
DataProviderHealthResponse,
Export, Export,
Filter, Filter,
ImportResponse, ImportResponse,
@ -380,6 +381,12 @@ export class DataService {
return this.http.get<BenchmarkResponse>('/api/v1/benchmarks'); 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({ public fetchExport({
activityIds, activityIds,
filters 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 { AiPromptResponse } from './responses/ai-prompt-response.interface';
import type { ApiKeyResponse } from './responses/api-key-response.interface'; import type { ApiKeyResponse } from './responses/api-key-response.interface';
import type { BenchmarkResponse } from './responses/benchmark-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 { DataProviderGhostfolioAssetProfileResponse } from './responses/data-provider-ghostfolio-asset-profile-response.interface';
import type { DataProviderGhostfolioStatusResponse } from './responses/data-provider-ghostfolio-status-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 { DividendsResponse } from './responses/dividends-response.interface';
import type { ResponseError } from './responses/errors.interface'; import type { ResponseError } from './responses/errors.interface';
import type { HistoricalResponse } from './responses/historical-response.interface'; import type { HistoricalResponse } from './responses/historical-response.interface';
@ -88,8 +90,10 @@ export {
BenchmarkProperty, BenchmarkProperty,
BenchmarkResponse, BenchmarkResponse,
Coupon, Coupon,
DataEnhancerHealthResponse,
DataProviderGhostfolioAssetProfileResponse, DataProviderGhostfolioAssetProfileResponse,
DataProviderGhostfolioStatusResponse, DataProviderGhostfolioStatusResponse,
DataProviderHealthResponse,
DataProviderInfo, DataProviderInfo,
DividendsResponse, DividendsResponse,
EnhancedSymbolProfile, 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 { export interface IRoute {
excludeFromAssistant?: boolean; excludeFromAssistant?: boolean | ((aUser: User) => boolean);
path: string; path: string;
routerLink: string[]; routerLink: string[];
subRoutes?: Record<string, IRoute>; 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 '@angular/localize/init';
import { IRoute } from './interfaces/interfaces'; 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> = { export const internalRoutes: Record<string, IRoute> = {
account: { account: {
path: 'account', path: 'account',
@ -32,7 +24,9 @@ export const internalRoutes: Record<string, IRoute> = {
title: $localize`Settings` title: $localize`Settings`
}, },
adminControl: { adminControl: {
excludeFromAssistant: true, excludeFromAssistant: (aUser: User) => {
return hasPermission(aUser?.permissions, permissions.accessAdminControl);
},
path: 'admin', path: 'admin',
routerLink: ['/admin'], routerLink: ['/admin'],
subRoutes: { subRoutes: {
@ -64,6 +58,12 @@ export const internalRoutes: Record<string, IRoute> = {
routerLink: ['/accounts'], routerLink: ['/accounts'],
title: $localize`Accounts` title: $localize`Accounts`
}, },
api: {
excludeFromAssistant: true,
path: 'api',
routerLink: ['/api'],
title: 'Ghostfolio API'
},
auth: { auth: {
excludeFromAssistant: true, excludeFromAssistant: true,
path: 'auth', path: 'auth',
@ -79,6 +79,11 @@ export const internalRoutes: Record<string, IRoute> = {
routerLink: ['/home', 'holdings'], routerLink: ['/home', 'holdings'],
title: $localize`Holdings` title: $localize`Holdings`
}, },
markets: {
path: 'markets',
routerLink: ['/home', 'markets'],
title: $localize`Markets`
},
summary: { summary: {
path: 'summary', path: 'summary',
routerLink: ['/home', 'summary'], routerLink: ['/home', 'summary'],
@ -92,6 +97,12 @@ export const internalRoutes: Record<string, IRoute> = {
}, },
title: $localize`Overview` title: $localize`Overview`
}, },
i18n: {
excludeFromAssistant: true,
path: 'i18n',
routerLink: ['/i18n'],
title: $localize`Internationalization`
},
portfolio: { portfolio: {
path: 'portfolio', path: 'portfolio',
routerLink: ['/portfolio'], routerLink: ['/portfolio'],
@ -147,43 +158,46 @@ export const internalRoutes: Record<string, IRoute> = {
export const publicRoutes = { export const publicRoutes = {
about: { about: {
path: $localize`:kebab-case:about`, path: $localize`:kebab-case@@routes.about:about`,
routerLink: ['/' + $localize`:kebab-case:about`], routerLink: ['/' + $localize`:kebab-case@@routes.about:about`],
subRoutes: { subRoutes: {
changelog: { changelog: {
path: $localize`:kebab-case:changelog`, path: $localize`:kebab-case@@routes.about.changelog:changelog`,
routerLink: [ routerLink: [
'/' + $localize`:kebab-case:about`, '/' + $localize`:kebab-case@@routes.about:about`,
$localize`:kebab-case:changelog` $localize`:kebab-case@@routes.about.changelog:changelog`
], ],
title: $localize`Changelog` title: $localize`Changelog`
}, },
license: { license: {
path: $localize`:kebab-case:license`, path: $localize`:kebab-case@@routes.about.license:license`,
routerLink: [ routerLink: [
'/' + $localize`:kebab-case:about`, '/' + $localize`:kebab-case@@routes.about:about`,
$localize`:kebab-case:license` $localize`:kebab-case@@routes.about.license:license`
], ],
title: $localize`License` title: $localize`License`
}, },
ossFriends: { ossFriends: {
path: 'oss-friends', path: 'oss-friends',
routerLink: ['/' + $localize`:kebab-case:about`, 'oss-friends'], routerLink: [
'/' + $localize`:kebab-case@@routes.about:about`,
'oss-friends'
],
title: 'OSS Friends' title: 'OSS Friends'
}, },
privacyPolicy: { privacyPolicy: {
path: $localize`:kebab-case:privacy-policy`, path: $localize`:kebab-case@@routes.about.privacyPolicy:privacy-policy`,
routerLink: [ routerLink: [
'/' + $localize`:kebab-case:about`, '/' + $localize`:kebab-case@@routes.about:about`,
$localize`:kebab-case:privacy-policy` $localize`:kebab-case@@routes.about.privacyPolicy:privacy-policy`
], ],
title: $localize`Privacy Policy` title: $localize`Privacy Policy`
}, },
termsOfService: { termsOfService: {
path: $localize`:kebab-case:terms-of-service`, path: $localize`:kebab-case@@routes.about.termsOfService:terms-of-service`,
routerLink: [ routerLink: [
'/' + $localize`:kebab-case:about`, '/' + $localize`:kebab-case@@routes.about:about`,
$localize`:kebab-case:terms-of-service` $localize`:kebab-case@@routes.about.termsOfService:terms-of-service`
], ],
title: $localize`Terms of Service` title: $localize`Terms of Service`
} }
@ -196,24 +210,25 @@ export const publicRoutes = {
title: $localize`Blog` title: $localize`Blog`
}, },
demo: { demo: {
excludeFromSitemap: true,
path: 'demo', path: 'demo',
routerLink: ['/demo'], routerLink: ['/demo'],
title: $localize`Live Demo` title: $localize`Live Demo`
}, },
faq: { faq: {
path: $localize`:kebab-case:faq`, path: $localize`:kebab-case@@routes.faq:faq`,
routerLink: ['/' + $localize`:kebab-case:faq`], routerLink: ['/' + $localize`:kebab-case@@routes.faq:faq`],
subRoutes: { subRoutes: {
saas: { saas: {
path: 'saas', path: 'saas',
routerLink: ['/' + $localize`:kebab-case:faq`, 'saas'], routerLink: ['/' + $localize`:kebab-case@@routes.faq:faq`, 'saas'],
title: $localize`Cloud` + ' (SaaS)' title: $localize`Cloud` + ' (SaaS)'
}, },
selfHosting: { selfHosting: {
path: $localize`:kebab-case:self-hosting`, path: $localize`:kebab-case@@routes.faq.selfHosting:self-hosting`,
routerLink: [ routerLink: [
'/' + $localize`:kebab-case:faq`, '/' + $localize`:kebab-case@@routes.faq:faq`,
$localize`:kebab-case:self-hosting` $localize`:kebab-case@@routes.faq.selfHosting:self-hosting`
], ],
title: $localize`Self-Hosting` title: $localize`Self-Hosting`
} }
@ -221,13 +236,13 @@ export const publicRoutes = {
title: $localize`Frequently Asked Questions (FAQ)` title: $localize`Frequently Asked Questions (FAQ)`
}, },
features: { features: {
path: $localize`:kebab-case:features`, path: $localize`:kebab-case@@routes.features:features`,
routerLink: ['/' + $localize`:kebab-case:features`], routerLink: ['/' + $localize`:kebab-case@@routes.features:features`],
title: $localize`Features` title: $localize`Features`
}, },
markets: { markets: {
path: $localize`:kebab-case:markets`, path: $localize`:kebab-case@@routes.markets:markets`,
routerLink: ['/' + $localize`:kebab-case:markets`], routerLink: ['/' + $localize`:kebab-case@@routes.markets:markets`],
title: $localize`Markets` title: $localize`Markets`
}, },
openStartup: { openStartup: {
@ -236,42 +251,62 @@ export const publicRoutes = {
title: 'Open Startup' title: 'Open Startup'
}, },
pricing: { pricing: {
path: $localize`:kebab-case:pricing`, path: $localize`:kebab-case@@routes.pricing:pricing`,
routerLink: ['/' + $localize`:kebab-case:pricing`], routerLink: ['/' + $localize`:kebab-case@@routes.pricing:pricing`],
title: $localize`Pricing` title: $localize`Pricing`
}, },
public: {
excludeFromSitemap: true,
path: 'p',
routerLink: ['/p']
},
register: { register: {
path: $localize`:kebab-case:register`, path: $localize`:kebab-case@@routes.register:register`,
routerLink: ['/' + $localize`:kebab-case:register`], routerLink: ['/' + $localize`:kebab-case@@routes.register:register`],
title: $localize`Registration` title: $localize`Registration`
}, },
resources: { resources: {
path: $localize`:kebab-case:resources`, path: $localize`:kebab-case@@routes.resources:resources`,
routerLink: ['/' + $localize`:kebab-case:resources`], routerLink: ['/' + $localize`:kebab-case@@routes.resources:resources`],
subRoutes: { subRoutes: {
glossary: { glossary: {
path: $localize`:kebab-case:glossary`, path: $localize`:kebab-case@@routes.resources.glossary:glossary`,
routerLink: [ routerLink: [
'/' + $localize`:kebab-case:resources`, '/' + $localize`:kebab-case@@routes.resources:resources`,
$localize`:kebab-case:glossary` $localize`:kebab-case@@routes.resources.glossary:glossary`
], ],
title: $localize`Glossary` title: $localize`Glossary`
}, },
guides: { guides: {
path: $localize`:kebab-case:guides`, path: $localize`:kebab-case@@routes.resources.guides:guides`,
routerLink: [ routerLink: [
'/' + $localize`:kebab-case:resources`, '/' + $localize`:kebab-case@@routes.resources:resources`,
$localize`:kebab-case:guides` $localize`:kebab-case@@routes.resources.guides:guides`
], ],
title: $localize`Guides` title: $localize`Guides`
}, },
markets: { markets: {
path: $localize`:kebab-case:markets`, path: $localize`:kebab-case@@routes.resources.markets:markets`,
routerLink: [ routerLink: [
'/' + $localize`:kebab-case:resources`, '/' + $localize`:kebab-case@@routes.resources:resources`,
$localize`:kebab-case:markets` $localize`:kebab-case@@routes.resources.markets:markets`
], ],
title: $localize`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` 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'; 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'; 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 & { export type AccountWithValue = AccountModel & {
balanceInBaseCurrency: number; balanceInBaseCurrency: number;
Platform?: Platform; platform?: Platform;
transactionCount: number; transactionCount: number;
value: number; value: number;
valueInBaseCurrency: number; valueInBaseCurrency: number;

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

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

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

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

@ -36,69 +36,40 @@
</button> </button>
} }
</div> </div>
<div @if (searchFormControl.value) {
*ngIf="searchFormControl.value" <div class="overflow-auto py-2 result-container">
class="overflow-auto py-2 result-container" @if (searchResults?.quickLinks?.length !== 0 || isLoading.quickLinks) {
> <div class="mb-2">
@if (searchResults?.quickLinks?.length !== 0 || isLoading.quickLinks) { <div class="font-weight-bold px-3 text-muted title" i18n>
<div class="mb-2"> Quick Links
<div class="font-weight-bold px-3 text-muted title" i18n> </div>
Quick Links @for (
searchResultItem of searchResults?.quickLinks;
track searchResultItem
) {
<gf-assistant-list-item
[item]="searchResultItem"
(clicked)="onCloseAssistant()"
/>
}
@if (isLoading.quickLinks) {
<ngx-skeleton-loader
animation="pulse"
class="mx-3"
[theme]="{
height: '1.5rem',
width: '100%'
}"
/>
}
</div> </div>
@for (
searchResultItem of searchResults?.quickLinks;
track searchResultItem
) {
<gf-assistant-list-item
[item]="searchResultItem"
(clicked)="onCloseAssistant()"
/>
}
@if (isLoading.quickLinks) {
<ngx-skeleton-loader
animation="pulse"
class="mx-3"
[theme]="{
height: '1.5rem',
width: '100%'
}"
/>
}
</div>
}
<div>
<div class="font-weight-bold px-3 text-muted title" i18n>Holdings</div>
@for (
searchResultItem of searchResults?.holdings;
track searchResultItem
) {
<gf-assistant-list-item
[item]="searchResultItem"
(clicked)="onCloseAssistant()"
/>
} }
@if (searchResults?.holdings?.length === 0) { <div>
@if (isLoading.holdings) {
<ngx-skeleton-loader
animation="pulse"
class="mx-3"
[theme]="{
height: '1.5rem',
width: '100%'
}"
/>
} @else {
<div class="px-3 py-1 text-muted" i18n>No entries...</div>
}
}
</div>
@if (hasPermissionToAccessAdminControl) {
<div class="mt-2">
<div class="font-weight-bold px-3 text-muted title" i18n> <div class="font-weight-bold px-3 text-muted title" i18n>
Asset Profiles Holdings
</div> </div>
@for ( @for (
searchResultItem of searchResults?.assetProfiles; searchResultItem of searchResults?.holdings;
track searchResultItem track searchResultItem
) { ) {
<gf-assistant-list-item <gf-assistant-list-item
@ -106,8 +77,8 @@
(clicked)="onCloseAssistant()" (clicked)="onCloseAssistant()"
/> />
} }
@if (searchResults?.assetProfiles?.length === 0) { @if (searchResults?.holdings?.length === 0) {
@if (isLoading.assetProfiles) { @if (isLoading.holdings) {
<ngx-skeleton-loader <ngx-skeleton-loader
animation="pulse" animation="pulse"
class="mx-3" class="mx-3"
@ -121,8 +92,38 @@
} }
} }
</div> </div>
} @if (hasPermissionToAccessAdminControl) {
</div> <div class="mt-2">
<div class="font-weight-bold px-3 text-muted title" i18n>
Asset Profiles
</div>
@for (
searchResultItem of searchResults?.assetProfiles;
track searchResultItem
) {
<gf-assistant-list-item
[item]="searchResultItem"
(clicked)="onCloseAssistant()"
/>
}
@if (searchResults?.assetProfiles?.length === 0) {
@if (isLoading.assetProfiles) {
<ngx-skeleton-loader
animation="pulse"
class="mx-3"
[theme]="{
height: '1.5rem',
width: '100%'
}"
/>
} @else {
<div class="px-3 py-1 text-muted" i18n>No entries...</div>
}
}
</div>
}
</div>
}
</div> </div>
<form [formGroup]="filterForm"> <form [formGroup]="filterForm">
@if (!searchFormControl.value) { @if (!searchFormControl.value) {
@ -148,11 +149,11 @@
@for (account of accounts; track account.id) { @for (account of accounts; track account.id) {
<mat-option [value]="account.id"> <mat-option [value]="account.id">
<div class="d-flex"> <div class="d-flex">
@if (account.Platform?.url) { @if (account.platform?.url) {
<gf-entity-logo <gf-entity-logo
class="mr-1" class="mr-1"
[tooltip]="account.Platform?.name" [tooltip]="account.platform?.name"
[url]="account.Platform?.url" [url]="account.platform?.url"
/> />
} }
<span>{{ account.name }}</span> <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 { import {
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
ChangeDetectionStrategy, ChangeDetectionStrategy,
@ -9,6 +10,7 @@ import { DataSource } from '@prisma/client';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA], schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-entity-logo', selector: 'gf-entity-logo',
styleUrls: ['./entity-logo.component.scss'], 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"> <div class="d-flex flex-column w-100">
<ng-template #label><ng-content></ng-content></ng-template> <ng-template #label><ng-content></ng-content></ng-template>
<ng-container *ngIf="value || value === 0 || value === null"> @if (value || value === 0 || value === null) {
<div <div
class="align-items-center d-flex" class="align-items-center d-flex"
[ngClass]="position === 'end' ? 'justify-content-end' : ''" [ngClass]="position === 'end' ? 'justify-content-end' : ''"
> >
<ng-container *ngIf="isNumber || value === null"> @if (isNumber || value === null) {
@if (colorizeSign && !useAbsoluteValue) { @if (colorizeSign && !useAbsoluteValue) {
@if (+value > 0) { @if (+value > 0) {
<div class="mr-1 text-success">+</div> <div class="mr-1 text-success">+</div>
@ -59,7 +59,7 @@
</div> </div>
} }
} }
</ng-container> }
@if (isString) { @if (isString) {
<div <div
class="mb-0 text-truncate value" class="mb-0 text-truncate value"
@ -72,7 +72,7 @@
</div> </div>
} }
</div> </div>
</ng-container> }
@if (value === undefined) { @if (value === undefined) {
<ngx-skeleton-loader <ngx-skeleton-loader
@ -94,7 +94,8 @@
<span class="text-muted"> {{ subLabel }}</span> <span class="text-muted"> {{ subLabel }}</span>
} }
</div> </div>
<small *ngIf="size !== 'large'" class="d-block text-truncate"> } @else {
<small class="d-block text-truncate">
<ng-container *ngTemplateOutlet="label"></ng-container> <ng-container *ngTemplateOutlet="label"></ng-container>
</small> </small>
} }

86
package-lock.json

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

8
package.json

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

4
prisma/schema.prisma

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

Loading…
Cancel
Save