diff --git a/.github/workflows/build-code.yml b/.github/workflows/build-code.yml index 0c3f292ba3..2a3f277143 100644 --- a/.github/workflows/build-code.yml +++ b/.github/workflows/build-code.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: node_version: - - 22.23.2 + - 22.23.4 steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ca9831e67..45a150a576 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Added a link to the holdings tab in the holdings table on the overview tab of the home page (experimental) + ### Changed +- Improved the style of the close button in the dialog footer component +- Improved the style of the close button in the dialog header component - Improved the instructions of the server of the Model Context Protocol (MCP) to give the limits of the Basic subscription only to a user with the Basic subscription (experimental) +- Upgraded `prettier` from version `3.9.9` to `3.9.10` + +## 3.90.0 - 2026-10-21 + +### Changed + +- Extended the variations of the interstitials for the subscription by a trial +- Deprecated the `api/v1/auth-device` endpoint in favor of the `api/v1/auth-devices` endpoint +- Deprecated the `api/v1/exchange-rate` endpoint in favor of the `api/v1/exchange-rates` endpoint +- Upgraded the `Node.js` engine from version `>=22.22.3` to `>=22.23.4` (`package.json`) - Upgraded `@modelcontextprotocol/core` and `@modelcontextprotocol/server` from version `2.0.0` to `2.3.1` - Upgraded `@modelcontextprotocol/node` from version `2.0.0` to `2.1.1` - Upgraded `@rekog/mcp-nest` from version `2.0.7` to `2.0.8` +- Upgraded `undici` from version `8.11.2` to `8.12.2` ## 3.89.0 - 2026-10-20 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 5b1b36afbb..0d43b42fbd 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -5,7 +5,7 @@ ### Prerequisites - [Docker](https://www.docker.com/products/docker-desktop) -- [Node.js](https://nodejs.org/en/download) (version `>=22.18.0`) +- [Node.js](https://nodejs.org/en/download) (version `>=22.23.4`) - Create a local copy of this Git repository (clone) - Copy the file `.env.dev` to `.env` and populate it with your data (`cp .env.dev .env`) diff --git a/apps/api/src/app/__snapshots__/app.module.spec.ts.snap b/apps/api/src/app/__snapshots__/app.module.spec.ts.snap index 68defd9062..f5e8b793d4 100644 --- a/apps/api/src/app/__snapshots__/app.module.spec.ts.snap +++ b/apps/api/src/app/__snapshots__/app.module.spec.ts.snap @@ -116,6 +116,15 @@ exports[`AppModule should keep the security of each route 1`] = ` "interceptors": [], "permission": "deleteAuthDevice", }, + "DELETE /auth-devices/:id": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "deleteAuthDevice", + }, "DELETE /benchmarks/:dataSource/:symbol": { "allowDuringImpersonation": true, "guards": [ @@ -489,6 +498,13 @@ exports[`AppModule should keep the security of each route 1`] = ` ], "interceptors": [], }, + "GET /exchange-rates/:symbol/:dateString": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + }, "GET /export": { "guards": [ "AuthGuard('jwt')", diff --git a/apps/api/src/app/activities/activities.module.ts b/apps/api/src/app/activities/activities.module.ts index 869a4d3650..6eb437af87 100644 --- a/apps/api/src/app/activities/activities.module.ts +++ b/apps/api/src/app/activities/activities.module.ts @@ -1,6 +1,5 @@ import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service'; -import { CacheModule } from '@ghostfolio/api/app/cache/cache.module'; import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module'; import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; @@ -29,7 +28,6 @@ import { ActivitiesService } from './activities.service'; ApiModule, AssetProfileSplitModule, BenchmarkModule, - CacheModule, DataGatheringQueueModule, DataProviderModule, ExchangeRateDataModule, diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 4286bf7870..3d262814fe 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -40,16 +40,18 @@ import { AccountModule } from './account/account.module'; import { ActivitiesModule } from './activities/activities.module'; import { AdminModule } from './admin/admin.module'; import { AppController } from './app.controller'; -import { AssetModule } from './asset/asset.module'; -import { AuthDeviceModule } from './auth-device/auth-device.module'; import { AuthModule } from './auth/auth.module'; -import { CacheModule } from './cache/cache.module'; import { AiModule } from './endpoints/ai/ai.module'; import { ApiKeysModule } from './endpoints/api-keys/api-keys.module'; import { AssetProfilesModule } from './endpoints/asset-profiles/asset-profiles.module'; import { AssetsModule } from './endpoints/assets/assets.module'; +import { AuthDevicesModule } from './endpoints/auth-devices/auth-devices.module'; import { BenchmarksModule } from './endpoints/benchmarks/benchmarks.module'; +import { CacheModule } from './endpoints/cache/cache.module'; import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module'; +import { ExchangeRatesModule } from './endpoints/exchange-rates/exchange-rates.module'; +import { InfoModule } from './endpoints/info/info.module'; +import { LogoModule } from './endpoints/logo/logo.module'; import { MarketDataModule } from './endpoints/market-data/market-data.module'; import { McpModule } from './endpoints/mcp/mcp.module'; import { PlatformsModule } from './endpoints/platforms/platforms.module'; @@ -57,12 +59,9 @@ import { PublicModule } from './endpoints/public/public.module'; import { SitemapModule } from './endpoints/sitemap/sitemap.module'; import { TagsModule } from './endpoints/tags/tags.module'; import { WatchlistModule } from './endpoints/watchlist/watchlist.module'; -import { ExchangeRateModule } from './exchange-rate/exchange-rate.module'; import { ExportModule } from './export/export.module'; import { HealthModule } from './health/health.module'; import { ImportModule } from './import/import.module'; -import { InfoModule } from './info/info.module'; -import { LogoModule } from './logo/logo.module'; import { PortfolioModule } from './portfolio/portfolio.module'; import { SubscriptionModule } from './subscription/subscription.module'; import { SymbolModule } from './symbol/symbol.module'; @@ -78,9 +77,8 @@ import { UserModule } from './user/user.module'; AiModule, ApiKeysModule, AssetProfilesModule, - AssetModule, AssetsModule, - AuthDeviceModule, + AuthDevicesModule, AuthModule, BenchmarksModule, BullBoardModule.forRoot({ @@ -119,8 +117,8 @@ import { UserModule } from './user/user.module'; DataProviderModule, EventEmitterModule.forRoot(), EventsModule, - ExchangeRateModule, ExchangeRateDataModule, + ExchangeRatesModule, ExportModule, GhostfolioModule, HealthModule, diff --git a/apps/api/src/app/asset/asset.module.ts b/apps/api/src/app/asset/asset.module.ts deleted file mode 100644 index 311c8694f5..0000000000 --- a/apps/api/src/app/asset/asset.module.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { AssetProfilesModule } from '@ghostfolio/api/app/endpoints/asset-profiles/asset-profiles.module'; -import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; -import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; - -import { Module } from '@nestjs/common'; - -import { AssetController } from './asset.controller'; - -@Module({ - controllers: [AssetController], - imports: [ - AssetProfilesModule, - TransformDataSourceInRequestModule, - TransformDataSourceInResponseModule - ] -}) -export class AssetModule {} diff --git a/apps/api/src/app/auth-device/auth-device.module.ts b/apps/api/src/app/auth-device/auth-device.module.ts deleted file mode 100644 index 515efa155c..0000000000 --- a/apps/api/src/app/auth-device/auth-device.module.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller'; -import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; -import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; - -import { Module } from '@nestjs/common'; -import { JwtModule } from '@nestjs/jwt'; - -@Module({ - controllers: [AuthDeviceController], - imports: [ - JwtModule.register({ - secret: process.env.JWT_SECRET_KEY, - signOptions: { expiresIn: '180 days' } - }), - PrismaModule - ], - providers: [AuthDeviceService] -}) -export class AuthDeviceModule {} diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts index 1eafdb6091..afeebe98d0 100644 --- a/apps/api/src/app/auth/auth.module.ts +++ b/apps/api/src/app/auth/auth.module.ts @@ -1,9 +1,9 @@ -import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service'; import { ApiModule } from '@ghostfolio/api/services/api/api.module'; +import { AuthDeviceModule } from '@ghostfolio/api/services/auth-device/auth-device.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { FetchModule } from '@ghostfolio/api/services/fetch/fetch.module'; @@ -29,6 +29,7 @@ import { OidcStrategy } from './oidc.strategy'; controllers: [AuthController], imports: [ ApiModule, + AuthDeviceModule, ConfigurationModule, FetchModule, JwtModule.register({ @@ -45,7 +46,6 @@ import { OidcStrategy } from './oidc.strategy'; providers: [ ApiKeyService, ApiKeyStrategy, - AuthDeviceService, AuthService, GoogleStrategy, JwtStrategy, diff --git a/apps/api/src/app/auth/web-auth.service.ts b/apps/api/src/app/auth/web-auth.service.ts index fe9a9467b2..3bae3de88f 100644 --- a/apps/api/src/app/auth/web-auth.service.ts +++ b/apps/api/src/app/auth/web-auth.service.ts @@ -1,7 +1,7 @@ -import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface'; import { UserService } from '@ghostfolio/api/app/user/user.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; +import { AuthDeviceService } from '@ghostfolio/api/services/auth-device/auth-device.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { RedisCacheService } from '@ghostfolio/api/services/redis-cache/redis-cache.service'; diff --git a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.module.ts b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.module.ts index 9df342f5f6..d065b9ab4b 100644 --- a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.module.ts +++ b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.module.ts @@ -15,9 +15,10 @@ import { Module } from '@nestjs/common'; import { AssetProfilesController } from './asset-profiles.controller'; import { AssetProfilesService } from './asset-profiles.service'; +import { AssetController } from './asset.controller'; @Module({ - controllers: [AssetProfilesController], + controllers: [AssetController, AssetProfilesController], exports: [AssetProfilesService], imports: [ ActivitiesModule, diff --git a/apps/api/src/app/asset/asset.controller.ts b/apps/api/src/app/endpoints/asset-profiles/asset.controller.ts similarity index 92% rename from apps/api/src/app/asset/asset.controller.ts rename to apps/api/src/app/endpoints/asset-profiles/asset.controller.ts index 352d3e18b1..ea7e9b798d 100644 --- a/apps/api/src/app/asset/asset.controller.ts +++ b/apps/api/src/app/endpoints/asset-profiles/asset.controller.ts @@ -1,4 +1,3 @@ -import { AssetProfilesService } from '@ghostfolio/api/app/endpoints/asset-profiles/asset-profiles.service'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import type { AssetResponse } from '@ghostfolio/common/interfaces'; @@ -7,6 +6,8 @@ import { Controller, Get, Param, UseInterceptors } from '@nestjs/common'; import { DataSource } from '@prisma/client'; import { pick } from 'lodash-es'; +import { AssetProfilesService } from './asset-profiles.service'; + @Controller('asset') export class AssetController { public constructor( diff --git a/apps/api/src/app/auth-device/auth-device.controller.ts b/apps/api/src/app/endpoints/auth-devices/auth-devices.controller.ts similarity index 87% rename from apps/api/src/app/auth-device/auth-device.controller.ts rename to apps/api/src/app/endpoints/auth-devices/auth-devices.controller.ts index 509746ca97..7577c16901 100644 --- a/apps/api/src/app/auth-device/auth-device.controller.ts +++ b/apps/api/src/app/endpoints/auth-devices/auth-devices.controller.ts @@ -1,7 +1,7 @@ -import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { AuthDeviceService } from '@ghostfolio/api/services/auth-device/auth-device.service'; import { permissions } from '@ghostfolio/common/permissions'; import type { RequestWithUser } from '@ghostfolio/common/types'; @@ -18,8 +18,12 @@ import { AuthGuard } from '@nestjs/passport'; import { getReasonPhrase, StatusCodes } from 'http-status-codes'; @AllowDuringImpersonation() -@Controller('auth-device') -export class AuthDeviceController { +@Controller([ + 'auth-devices', + /** @deprecated */ + 'auth-device' +]) +export class AuthDevicesController { public constructor( private readonly authDeviceService: AuthDeviceService, @Inject(REQUEST) private readonly request: RequestWithUser diff --git a/apps/api/src/app/endpoints/auth-devices/auth-devices.module.ts b/apps/api/src/app/endpoints/auth-devices/auth-devices.module.ts new file mode 100644 index 0000000000..ce5042d264 --- /dev/null +++ b/apps/api/src/app/endpoints/auth-devices/auth-devices.module.ts @@ -0,0 +1,11 @@ +import { AuthDeviceModule } from '@ghostfolio/api/services/auth-device/auth-device.module'; + +import { Module } from '@nestjs/common'; + +import { AuthDevicesController } from './auth-devices.controller'; + +@Module({ + controllers: [AuthDevicesController], + imports: [AuthDeviceModule] +}) +export class AuthDevicesModule {} diff --git a/apps/api/src/app/cache/cache.controller.ts b/apps/api/src/app/endpoints/cache/cache.controller.ts similarity index 100% rename from apps/api/src/app/cache/cache.controller.ts rename to apps/api/src/app/endpoints/cache/cache.controller.ts diff --git a/apps/api/src/app/cache/cache.module.ts b/apps/api/src/app/endpoints/cache/cache.module.ts similarity index 100% rename from apps/api/src/app/cache/cache.module.ts rename to apps/api/src/app/endpoints/cache/cache.module.ts diff --git a/apps/api/src/app/exchange-rate/exchange-rate.controller.ts b/apps/api/src/app/endpoints/exchange-rates/exchange-rates.controller.ts similarity index 65% rename from apps/api/src/app/exchange-rate/exchange-rate.controller.ts rename to apps/api/src/app/endpoints/exchange-rates/exchange-rates.controller.ts index 239b4b27a1..7a873b11db 100644 --- a/apps/api/src/app/exchange-rate/exchange-rate.controller.ts +++ b/apps/api/src/app/endpoints/exchange-rates/exchange-rates.controller.ts @@ -1,5 +1,5 @@ import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; -import { DataProviderHistoricalResponse } from '@ghostfolio/common/interfaces'; +import { ExchangeRateResponse } from '@ghostfolio/common/interfaces'; import { Controller, @@ -12,12 +12,16 @@ import { AuthGuard } from '@nestjs/passport'; import { parseISO } from 'date-fns'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; -import { ExchangeRateService } from './exchange-rate.service'; +import { ExchangeRatesService } from './exchange-rates.service'; -@Controller('exchange-rate') -export class ExchangeRateController { +@Controller([ + 'exchange-rates', + /** @deprecated */ + 'exchange-rate' +]) +export class ExchangeRatesController { public constructor( - private readonly exchangeRateService: ExchangeRateService + private readonly exchangeRatesService: ExchangeRatesService ) {} @Get(':symbol/:dateString') @@ -25,10 +29,10 @@ export class ExchangeRateController { public async getExchangeRate( @Param('dateString') dateString: string, @Param('symbol') symbol: string - ): Promise { + ): Promise { const date = parseISO(dateString); - const exchangeRate = await this.exchangeRateService.getExchangeRate({ + const exchangeRate = await this.exchangeRatesService.getExchangeRate({ date, symbol }); diff --git a/apps/api/src/app/endpoints/exchange-rates/exchange-rates.module.ts b/apps/api/src/app/endpoints/exchange-rates/exchange-rates.module.ts new file mode 100644 index 0000000000..c143ddc336 --- /dev/null +++ b/apps/api/src/app/endpoints/exchange-rates/exchange-rates.module.ts @@ -0,0 +1,13 @@ +import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; + +import { Module } from '@nestjs/common'; + +import { ExchangeRatesController } from './exchange-rates.controller'; +import { ExchangeRatesService } from './exchange-rates.service'; + +@Module({ + controllers: [ExchangeRatesController], + imports: [ExchangeRateDataModule], + providers: [ExchangeRatesService] +}) +export class ExchangeRatesModule {} diff --git a/apps/api/src/app/exchange-rate/exchange-rate.service.ts b/apps/api/src/app/endpoints/exchange-rates/exchange-rates.service.ts similarity index 94% rename from apps/api/src/app/exchange-rate/exchange-rate.service.ts rename to apps/api/src/app/endpoints/exchange-rates/exchange-rates.service.ts index d70fd534c0..e66cc591ae 100644 --- a/apps/api/src/app/exchange-rate/exchange-rate.service.ts +++ b/apps/api/src/app/endpoints/exchange-rates/exchange-rates.service.ts @@ -3,7 +3,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate- import { Injectable } from '@nestjs/common'; @Injectable() -export class ExchangeRateService { +export class ExchangeRatesService { public constructor( private readonly exchangeRateDataService: ExchangeRateDataService ) {} diff --git a/apps/api/src/app/info/info.controller.ts b/apps/api/src/app/endpoints/info/info.controller.ts similarity index 100% rename from apps/api/src/app/info/info.controller.ts rename to apps/api/src/app/endpoints/info/info.controller.ts diff --git a/apps/api/src/app/info/info.module.ts b/apps/api/src/app/endpoints/info/info.module.ts similarity index 100% rename from apps/api/src/app/info/info.module.ts rename to apps/api/src/app/endpoints/info/info.module.ts diff --git a/apps/api/src/app/info/info.service.ts b/apps/api/src/app/endpoints/info/info.service.ts similarity index 100% rename from apps/api/src/app/info/info.service.ts rename to apps/api/src/app/endpoints/info/info.service.ts diff --git a/apps/api/src/app/logo/get-logo.dto.ts b/apps/api/src/app/endpoints/logo/get-logo.dto.ts similarity index 100% rename from apps/api/src/app/logo/get-logo.dto.ts rename to apps/api/src/app/endpoints/logo/get-logo.dto.ts diff --git a/apps/api/src/app/logo/logo.controller.ts b/apps/api/src/app/endpoints/logo/logo.controller.ts similarity index 100% rename from apps/api/src/app/logo/logo.controller.ts rename to apps/api/src/app/endpoints/logo/logo.controller.ts diff --git a/apps/api/src/app/logo/logo.module.ts b/apps/api/src/app/endpoints/logo/logo.module.ts similarity index 100% rename from apps/api/src/app/logo/logo.module.ts rename to apps/api/src/app/endpoints/logo/logo.module.ts diff --git a/apps/api/src/app/logo/logo.service.ts b/apps/api/src/app/endpoints/logo/logo.service.ts similarity index 100% rename from apps/api/src/app/logo/logo.service.ts rename to apps/api/src/app/endpoints/logo/logo.service.ts diff --git a/apps/api/src/app/exchange-rate/exchange-rate.module.ts b/apps/api/src/app/exchange-rate/exchange-rate.module.ts deleted file mode 100644 index e1d9d18917..0000000000 --- a/apps/api/src/app/exchange-rate/exchange-rate.module.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; - -import { Module } from '@nestjs/common'; - -import { ExchangeRateController } from './exchange-rate.controller'; -import { ExchangeRateService } from './exchange-rate.service'; - -@Module({ - controllers: [ExchangeRateController], - exports: [ExchangeRateService], - imports: [ExchangeRateDataModule], - providers: [ExchangeRateService] -}) -export class ExchangeRateModule {} diff --git a/apps/api/src/app/import/import.module.ts b/apps/api/src/app/import/import.module.ts index f972448863..00a386b6e2 100644 --- a/apps/api/src/app/import/import.module.ts +++ b/apps/api/src/app/import/import.module.ts @@ -1,6 +1,5 @@ import { AccountModule } from '@ghostfolio/api/app/account/account.module'; import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module'; -import { CacheModule } from '@ghostfolio/api/app/cache/cache.module'; import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'; import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; @@ -28,7 +27,6 @@ import { ImportService } from './import.service'; AccountModule, ActivitiesModule, ApiModule, - CacheModule, ConfigurationModule, DataGatheringQueueModule, DataProviderModule, diff --git a/apps/api/src/services/auth-device/auth-device.module.ts b/apps/api/src/services/auth-device/auth-device.module.ts new file mode 100644 index 0000000000..0594fccb7d --- /dev/null +++ b/apps/api/src/services/auth-device/auth-device.module.ts @@ -0,0 +1,12 @@ +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; + +import { Module } from '@nestjs/common'; + +import { AuthDeviceService } from './auth-device.service'; + +@Module({ + exports: [AuthDeviceService], + imports: [PrismaModule], + providers: [AuthDeviceService] +}) +export class AuthDeviceModule {} diff --git a/apps/api/src/app/auth-device/auth-device.service.ts b/apps/api/src/services/auth-device/auth-device.service.ts similarity index 100% rename from apps/api/src/app/auth-device/auth-device.service.ts rename to apps/api/src/services/auth-device/auth-device.service.ts diff --git a/apps/client/src/app/components/home-overview/home-overview.component.ts b/apps/client/src/app/components/home-overview/home-overview.component.ts index c7b880efce..5e6601886c 100644 --- a/apps/client/src/app/components/home-overview/home-overview.component.ts +++ b/apps/client/src/app/components/home-overview/home-overview.component.ts @@ -36,6 +36,9 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { Router, RouterModule } from '@angular/router'; +import { IonIcon } from '@ionic/angular/standalone'; +import { addIcons } from 'ionicons'; +import { arrowForwardOutline } from 'ionicons/icons'; import { DeviceDetectorService } from 'ngx-device-detector'; @Component({ @@ -44,6 +47,7 @@ import { DeviceDetectorService } from 'ngx-device-detector'; GfHoldingsTableComponent, GfLineChartComponent, GfPortfolioPerformanceComponent, + IonIcon, MatButtonModule, MatCardModule, RouterModule @@ -71,9 +75,15 @@ export class GfHomeOverviewComponent implements OnInit { protected readonly user = signal(null); protected readonly routerLinkAccounts = internalRoutes.accounts.routerLink; + + protected readonly routerLinkHomeHoldings = + internalRoutes.home.subRoutes.holdings.routerLink; + protected readonly routerLinkPortfolio = internalRoutes.portfolio.routerLink; + protected readonly routerLinkPortfolioActivities = internalRoutes.portfolio.subRoutes.activities.routerLink; + protected readonly routerLinkPortfolioActivitiesCreate = internalRoutes.portfolio.subRoutes.activities.subRoutes.create.routerLink; @@ -134,6 +144,8 @@ export class GfHomeOverviewComponent implements OnInit { this.update(); } }); + + addIcons({ arrowForwardOutline }); } public ngOnInit() { diff --git a/apps/client/src/app/components/home-overview/home-overview.html b/apps/client/src/app/components/home-overview/home-overview.html index 64789ddc7d..0f9e1bcfae 100644 --- a/apps/client/src/app/components/home-overview/home-overview.html +++ b/apps/client/src/app/components/home-overview/home-overview.html @@ -101,8 +101,20 @@ appearance="outlined" class="h-100 holdings position-absolute w-100" > - + Holdings + + + ) { - this.variantIndex = Math.floor( - Math.random() * GfSubscriptionInterstitialDialogComponent.VARIANTS_COUNT - ); + const isEligibleForTrial = + !this.data.user?.subscription?.subscribedAt && + hasPermission( + this.data.user?.permissions, + permissions.updateUserSettings + ); + + const variantsCount = isEligibleForTrial + ? GfSubscriptionInterstitialDialogComponent.VARIANTS_COUNT + 1 + : GfSubscriptionInterstitialDialogComponent.VARIANTS_COUNT; + + this.variantIndex = Math.floor(Math.random() * variantsCount); addIcons({ arrowForwardOutline, checkmarkCircleOutline }); } diff --git a/apps/client/src/app/components/subscription-interstitial-dialog/subscription-interstitial-dialog.html b/apps/client/src/app/components/subscription-interstitial-dialog/subscription-interstitial-dialog.html index 8280067a87..5bac0e9baa 100644 --- a/apps/client/src/app/components/subscription-interstitial-dialog/subscription-interstitial-dialog.html +++ b/apps/client/src/app/components/subscription-interstitial-dialog/subscription-interstitial-dialog.html @@ -195,4 +195,46 @@ } + @case (4) { +
+
+
+
+
+
+
+
+ Ghostfolio Premium + +
+
+ Request a free trial +
+

+ No credit card required. The trial ends automatically. +

+
+
+
+ + + Try Premium + + +
+ } } diff --git a/apps/client/src/app/components/user-account-membership/user-account-membership.component.ts b/apps/client/src/app/components/user-account-membership/user-account-membership.component.ts index defe751ee8..20425d831e 100644 --- a/apps/client/src/app/components/user-account-membership/user-account-membership.component.ts +++ b/apps/client/src/app/components/user-account-membership/user-account-membership.component.ts @@ -1,5 +1,5 @@ import { UserService } from '@ghostfolio/client/services/user/user.service'; -import { E_MAIL_LINE_BREAK } from '@ghostfolio/common/config'; +import { TRY_SUBSCRIPTION_MAIL_HREF } from '@ghostfolio/common/config'; import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { getDateFormatString } from '@ghostfolio/common/helper'; import { User } from '@ghostfolio/common/interfaces'; @@ -49,13 +49,7 @@ export class GfUserAccountMembershipComponent { protected hasPermissionToCreateApiKey: boolean; protected hasPermissionToUpdateUserSettings: boolean; protected price: number; - protected readonly trySubscriptionMailHref = `mailto:hi@ghostfol.io?subject=Ghostfolio Premium Trial&body=${[ - 'Hello', - '', - 'I am interested in Ghostfolio Premium. Can you please send me a coupon code to try it for some time?', - '', - 'Kind regards' - ].join(E_MAIL_LINE_BREAK)}`; + protected readonly trySubscriptionMailHref = TRY_SUBSCRIPTION_MAIL_HREF; protected user: User; private couponId: string | undefined; diff --git a/apps/client/src/app/components/user-account-membership/user-account-membership.html b/apps/client/src/app/components/user-account-membership/user-account-membership.html index f18da13ed9..336f386139 100644 --- a/apps/client/src/app/components/user-account-membership/user-account-membership.html +++ b/apps/client/src/app/components/user-account-membership/user-account-membership.html @@ -50,7 +50,10 @@ } }
- @if (!user?.subscription?.subscribedAt) { + @if ( + !user?.subscription?.subscribedAt && + hasPermissionToUpdateUserSettings + ) { (`/api/v1/auth-device/${deviceId}`) + .delete(`/api/v1/auth-devices/${deviceId}`) .pipe( catchError((error) => { console.warn(`Could not deregister device ${deviceId}`, error); diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index 61008f1fc7..31caf6d3a1 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -422,4 +422,18 @@ export const THROTTLE_DEFAULT_TTL = ms('1 minute'); export const THROTTLE_SIGNUP_LIMIT = 5; export const THROTTLE_SIGNUP_TTL = ms('1 hour'); +export const TRY_SUBSCRIPTION_MAIL_HREF = `mailto:hi@ghostfol.io?subject=${encodeURIComponent( + 'Ghostfolio Premium Trial' +)}&body=${[ + 'Hello', + '', + 'I am interested in Ghostfolio Premium. Can you please send me a coupon code to try it for some time?', + '', + 'Kind regards' +] + .map((line) => { + return encodeURIComponent(line); + }) + .join(E_MAIL_LINE_BREAK)}`; + export const UNKNOWN_KEY = 'UNKNOWN'; diff --git a/libs/common/src/lib/interfaces/index.ts b/libs/common/src/lib/interfaces/index.ts index c39fd74c55..d17f2377b7 100644 --- a/libs/common/src/lib/interfaces/index.ts +++ b/libs/common/src/lib/interfaces/index.ts @@ -67,6 +67,7 @@ import type { } from './responses/data-provider-response.interface'; import type { DividendsResponse } from './responses/dividends-response.interface'; import type { ResponseError } from './responses/errors.interface'; +import type { ExchangeRateResponse } from './responses/exchange-rate-response.interface'; import type { ExportResponse } from './responses/export-response.interface'; import type { HistoricalResponse } from './responses/historical-response.interface'; import type { ImportResponse } from './responses/import-response.interface'; @@ -150,6 +151,7 @@ export { DataProviderResponse, DividendsResponse, EnhancedAssetProfile, + ExchangeRateResponse, ExportResponse, Filter, FilterGroup, diff --git a/libs/common/src/lib/interfaces/responses/exchange-rate-response.interface.ts b/libs/common/src/lib/interfaces/responses/exchange-rate-response.interface.ts new file mode 100644 index 0000000000..6991fed809 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/exchange-rate-response.interface.ts @@ -0,0 +1,3 @@ +export interface ExchangeRateResponse { + marketPrice: number; +} diff --git a/libs/ui/src/lib/dialog-footer/dialog-footer.component.html b/libs/ui/src/lib/dialog-footer/dialog-footer.component.html index 463354cf1c..461f839822 100644 --- a/libs/ui/src/lib/dialog-footer/dialog-footer.component.html +++ b/libs/ui/src/lib/dialog-footer/dialog-footer.component.html @@ -1,7 +1,10 @@ @if (deviceType === 'mobile') {
-
} diff --git a/libs/ui/src/lib/dialog-footer/dialog-footer.component.scss b/libs/ui/src/lib/dialog-footer/dialog-footer.component.scss index 5d4e87f30f..0f41fc37bc 100644 --- a/libs/ui/src/lib/dialog-footer/dialog-footer.component.scss +++ b/libs/ui/src/lib/dialog-footer/dialog-footer.component.scss @@ -1,3 +1,7 @@ :host { display: block; + + button[mat-icon-button] { + --mat-icon-button-touch-target-display: block; + } } diff --git a/libs/ui/src/lib/dialog-footer/dialog-footer.component.ts b/libs/ui/src/lib/dialog-footer/dialog-footer.component.ts index e230802a76..3c06f932ed 100644 --- a/libs/ui/src/lib/dialog-footer/dialog-footer.component.ts +++ b/libs/ui/src/lib/dialog-footer/dialog-footer.component.ts @@ -9,7 +9,7 @@ import { MatButtonModule } from '@angular/material/button'; import { MatDialogModule } from '@angular/material/dialog'; import { IonIcon } from '@ionic/angular/standalone'; import { addIcons } from 'ionicons'; -import { close } from 'ionicons/icons'; +import { closeOutline } from 'ionicons/icons'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -25,7 +25,7 @@ export class GfDialogFooterComponent { @Output() closeButtonClicked = new EventEmitter(); public constructor() { - addIcons({ close }); + addIcons({ closeOutline }); } public onClickCloseButton() { diff --git a/libs/ui/src/lib/dialog-header/dialog-header.component.html b/libs/ui/src/lib/dialog-header/dialog-header.component.html index 435af1b295..023548a562 100644 --- a/libs/ui/src/lib/dialog-header/dialog-header.component.html +++ b/libs/ui/src/lib/dialog-header/dialog-header.component.html @@ -1,12 +1,19 @@ -
+
{{ title() }} @if (deviceType() !== 'mobile') { - }
diff --git a/libs/ui/src/lib/dialog-header/dialog-header.component.scss b/libs/ui/src/lib/dialog-header/dialog-header.component.scss index 5d4e87f30f..c7dbf3b951 100644 --- a/libs/ui/src/lib/dialog-header/dialog-header.component.scss +++ b/libs/ui/src/lib/dialog-header/dialog-header.component.scss @@ -1,3 +1,14 @@ :host { display: block; + + button[mat-icon-button] { + margin-right: -0.8125rem; + margin-top: 0.125rem; + } + + .with-close-button { + .text-center { + padding-left: 0.9375rem; + } + } } diff --git a/libs/ui/src/lib/dialog-header/dialog-header.component.ts b/libs/ui/src/lib/dialog-header/dialog-header.component.ts index f16d66c4b5..a626e1b6f7 100644 --- a/libs/ui/src/lib/dialog-header/dialog-header.component.ts +++ b/libs/ui/src/lib/dialog-header/dialog-header.component.ts @@ -8,7 +8,7 @@ import { MatButtonModule } from '@angular/material/button'; import { MatDialogModule } from '@angular/material/dialog'; import { IonIcon } from '@ionic/angular/standalone'; import { addIcons } from 'ionicons'; -import { close } from 'ionicons/icons'; +import { closeOutline } from 'ionicons/icons'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -26,7 +26,7 @@ export class GfDialogHeaderComponent { public readonly closeButtonClicked = output(); public constructor() { - addIcons({ close }); + addIcons({ closeOutline }); } protected onClickCloseButton() { diff --git a/libs/ui/src/lib/services/data.service.ts b/libs/ui/src/lib/services/data.service.ts index ca10f91b52..6292f8770a 100644 --- a/libs/ui/src/lib/services/data.service.ts +++ b/libs/ui/src/lib/services/data.service.ts @@ -40,7 +40,6 @@ import { CreateActivityResponse, CreateStripeCheckoutSessionResponse, DataProviderHealthResponse, - DataProviderHistoricalResponse, ExportResponse, Filter, ImportResponse, @@ -309,18 +308,6 @@ export class DataService { ); } - public fetchExchangeRateForDate({ - date, - symbol - }: { - date: Date; - symbol: string; - }) { - return this.http.get( - `/api/v1/exchange-rate/${encodeURIComponent(symbol)}/${format(date, DATE_FORMAT, { in: utc })}` - ); - } - public deleteAccess(aId: string) { return this.http.delete(`/api/v1/access/${aId}`); } diff --git a/package-lock.json b/package-lock.json index faf228c31a..bc07aafd78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ghostfolio", - "version": "3.89.0", + "version": "3.90.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ghostfolio", - "version": "3.89.0", + "version": "3.90.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { @@ -104,7 +104,7 @@ "svgmap": "2.21.0", "tablemark": "4.1.0", "twitter-api-v2": "1.29.1", - "undici": "8.11.2", + "undici": "8.12.2", "uuid": "14.0.2", "yahoo-finance2": "4.0.2", "zod": "4.6.5", @@ -149,7 +149,7 @@ "@types/jest": "30.0.0", "@types/jsonpath": "0.2.4", "@types/lodash-es": "4.17.12", - "@types/node": "22.20.1", + "@types/node": "22.20.5", "@types/papaparse": "5.5.2", "@types/passport-google-oauth20": "2.0.17", "@types/passport-openidconnect": "0.1.3", @@ -163,7 +163,7 @@ "jest-environment-node": "30.3.0", "jest-preset-angular": "17.0.0", "nx": "23.2.1", - "prettier": "3.9.9", + "prettier": "3.9.10", "prettier-plugin-organize-attributes": "1.0.0", "prisma": "7.10.0", "react": "18.2.0", @@ -180,7 +180,7 @@ "webpack-cli": "7.1.0" }, "engines": { - "node": ">=22.22.3" + "node": ">=22.23.4" } }, "node_modules/@adobe/css-tools": { @@ -14306,9 +14306,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.20.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", - "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -29600,9 +29600,9 @@ } }, "node_modules/prettier": { - "version": "3.9.9", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", - "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz", + "integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==", "dev": true, "license": "MIT", "bin": { @@ -33913,9 +33913,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "8.11.2", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", - "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", + "version": "8.12.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.2.tgz", + "integrity": "sha512-XtuDWVdqTLr3NAxm2ly9/hU6kUsJOgGh1RASzMBbsw6aWCOaQzHdW+QQDfdERjfJ2V5vL5pRPa4RetO9UfivDg==", "license": "MIT", "engines": { "node": ">=22.19.0" diff --git a/package.json b/package.json index 4348407e87..b3f01f0303 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostfolio", - "version": "3.89.0", + "version": "3.90.0", "homepage": "https://ghostfol.io", "license": "AGPL-3.0", "repository": "https://github.com/ghostfolio/ghostfolio", @@ -148,7 +148,7 @@ "svgmap": "2.21.0", "tablemark": "4.1.0", "twitter-api-v2": "1.29.1", - "undici": "8.11.2", + "undici": "8.12.2", "uuid": "14.0.2", "yahoo-finance2": "4.0.2", "zod": "4.6.5", @@ -193,7 +193,7 @@ "@types/jest": "30.0.0", "@types/jsonpath": "0.2.4", "@types/lodash-es": "4.17.12", - "@types/node": "22.20.1", + "@types/node": "22.20.5", "@types/papaparse": "5.5.2", "@types/passport-google-oauth20": "2.0.17", "@types/passport-openidconnect": "0.1.3", @@ -207,7 +207,7 @@ "jest-environment-node": "30.3.0", "jest-preset-angular": "17.0.0", "nx": "23.2.1", - "prettier": "3.9.9", + "prettier": "3.9.10", "prettier-plugin-organize-attributes": "1.0.0", "prisma": "7.10.0", "react": "18.2.0", @@ -224,6 +224,6 @@ "webpack-cli": "7.1.0" }, "engines": { - "node": ">=22.22.3" + "node": ">=22.23.4" } }