From a463b5511ba62d0b70d3716ee2621b875304e361 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Mon, 20 Apr 2026 19:29:16 +0200 Subject: [PATCH 001/132] Task/upgrade countup.js to version 2.10.0 (#6636) * Upgrade countup.js to version 2.10.0 * Update changelog --- CHANGELOG.md | 6 ++++++ package-lock.json | 8 ++++---- package.json | 2 +- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aef0aee61..d32a6ea89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Changed + +- Upgraded `countup.js` from version `2.9.0` to `2.10.0` + ## 2.255.0 - 2026-03-20 ### Changed diff --git a/package-lock.json b/package-lock.json index 0e2e26a85..81a24f54f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,7 +61,7 @@ "cookie-parser": "1.4.7", "countries-and-timezones": "3.8.0", "countries-list": "3.3.0", - "countup.js": "2.9.0", + "countup.js": "2.10.0", "date-fns": "4.1.0", "dotenv": "17.2.3", "dotenv-expand": "12.0.3", @@ -18751,9 +18751,9 @@ "license": "MIT" }, "node_modules/countup.js": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/countup.js/-/countup.js-2.9.0.tgz", - "integrity": "sha512-llqrvyXztRFPp6+i8jx25phHWcVWhrHO4Nlt0uAOSKHB8778zzQswa4MU3qKBvkXfJKftRYFJuVHez67lyKdHg==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/countup.js/-/countup.js-2.10.0.tgz", + "integrity": "sha512-QQpZx7oYxsR+OeITlZe46fY/OQjV11oBqjY8wgIXzLU2jIz8GzOrbMhqKLysGY8bWI3T1ZNrYkwGzKb4JNgyzg==", "license": "MIT" }, "node_modules/create-require": { diff --git a/package.json b/package.json index 5eb3558e6..7c15c7f1d 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,7 @@ "cookie-parser": "1.4.7", "countries-and-timezones": "3.8.0", "countries-list": "3.3.0", - "countup.js": "2.9.0", + "countup.js": "2.10.0", "date-fns": "4.1.0", "dotenv": "17.2.3", "dotenv-expand": "12.0.3", From ccd81bde4b1e6086c88224c5cdfc343c8dc771c9 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:07:06 +0200 Subject: [PATCH 002/132] Task/update OSS Friends 20260421 (#6754) Update oss-friends.json --- apps/client/src/assets/oss-friends.json | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/apps/client/src/assets/oss-friends.json b/apps/client/src/assets/oss-friends.json index 2d5b994d3..7b58079ab 100644 --- a/apps/client/src/assets/oss-friends.json +++ b/apps/client/src/assets/oss-friends.json @@ -1,5 +1,5 @@ { - "createdAt": "2025-12-08T00:00:00.000Z", + "createdAt": "2026-04-21T00:00:00.000Z", "data": [ { "name": "Activepieces", @@ -21,11 +21,6 @@ "description": "Fastest LLM gateway with adaptive load balancer, cluster mode, guardrails, 1000+ models support & <100 µs overhead at 5k RPS.", "href": "https://www.getmaxim.ai/bifrost" }, - { - "name": "Cal.com", - "description": "Cal.com is a scheduling tool that helps you schedule meetings without the back-and-forth emails.", - "href": "https://cal.com" - }, { "name": "Cap", "description": "Cap is the open source alternative to Loom. Lightweight, powerful, and cross-platform. Record and share securely in seconds.", From ef7df254968dbc90f4ff16e03577aa26c7073bb0 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:07:54 +0200 Subject: [PATCH 003/132] Task/refactor subscription types (#6735) Refactor subscription types --- apps/api/src/app/access/access.controller.ts | 5 +++-- .../src/app/endpoints/public/public.controller.ts | 3 ++- apps/api/src/app/portfolio/portfolio.controller.ts | 12 +++++++----- apps/api/src/app/user/user.service.ts | 7 ++++--- .../services/data-provider/data-provider.service.ts | 7 ++++--- .../admin-overview/admin-overview.component.ts | 7 +++++-- .../app/pages/portfolio/fire/fire-page.component.ts | 5 +++-- .../pages/portfolio/x-ray/x-ray-page.component.ts | 3 ++- 8 files changed, 30 insertions(+), 19 deletions(-) diff --git a/apps/api/src/app/access/access.controller.ts b/apps/api/src/app/access/access.controller.ts index 5056a6d71..28b459203 100644 --- a/apps/api/src/app/access/access.controller.ts +++ b/apps/api/src/app/access/access.controller.ts @@ -2,6 +2,7 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos'; +import { SubscriptionType } from '@ghostfolio/common/enums'; import { Access } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; import type { RequestWithUser } from '@ghostfolio/common/types'; @@ -75,7 +76,7 @@ export class AccessController { ): Promise { if ( this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && - this.request.user.subscription.type === 'Basic' + this.request.user.subscription.type === SubscriptionType.Basic ) { throw new HttpException( getReasonPhrase(StatusCodes.FORBIDDEN), @@ -130,7 +131,7 @@ export class AccessController { ): Promise { if ( this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && - this.request.user.subscription.type === 'Basic' + this.request.user.subscription.type === SubscriptionType.Basic ) { throw new HttpException( getReasonPhrase(StatusCodes.FORBIDDEN), diff --git a/apps/api/src/app/endpoints/public/public.controller.ts b/apps/api/src/app/endpoints/public/public.controller.ts index b97640cab..33c6052a6 100644 --- a/apps/api/src/app/endpoints/public/public.controller.ts +++ b/apps/api/src/app/endpoints/public/public.controller.ts @@ -7,6 +7,7 @@ import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interc import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; +import { SubscriptionType } from '@ghostfolio/common/enums'; import { getSum } from '@ghostfolio/common/helper'; import { PublicPortfolioResponse } from '@ghostfolio/common/interfaces'; import type { RequestWithUser } from '@ghostfolio/common/types'; @@ -58,7 +59,7 @@ export class PublicController { }); if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { - hasDetails = user.subscription.type === 'Premium'; + hasDetails = user.subscription.type === SubscriptionType.Premium; } const [ diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 9c41aecb9..cb6f21e8a 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -17,6 +17,7 @@ import { HEADER_KEY_IMPERSONATION, UNKNOWN_KEY } from '@ghostfolio/common/config'; +import { SubscriptionType } from '@ghostfolio/common/enums'; import { PortfolioDetails, PortfolioDividendsResponse, @@ -92,7 +93,8 @@ export class PortfolioController { let hasError = false; if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { - hasDetails = this.request.user.subscription.type === 'Premium'; + hasDetails = + this.request.user.subscription.type === SubscriptionType.Premium; } const filters = this.apiService.buildFiltersFromQueryParams({ @@ -356,7 +358,7 @@ export class PortfolioController { if ( this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && - this.request.user.subscription.type === 'Basic' + this.request.user.subscription.type === SubscriptionType.Basic ) { dividends = dividends.map((item) => { return nullifyValuesInObject(item, ['investment']); @@ -484,7 +486,7 @@ export class PortfolioController { if ( this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && - this.request.user.subscription.type === 'Basic' + this.request.user.subscription.type === SubscriptionType.Basic ) { investments = investments.map((item) => { return nullifyValuesInObject(item, ['investment']); @@ -596,7 +598,7 @@ export class PortfolioController { if ( this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && - this.request.user.subscription.type === 'Basic' + this.request.user.subscription.type === SubscriptionType.Basic ) { performanceInformation.chart = performanceInformation.chart.map( (item) => { @@ -624,7 +626,7 @@ export class PortfolioController { if ( this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && - this.request.user.subscription.type === 'Basic' + this.request.user.subscription.type === SubscriptionType.Basic ) { for (const category of report.xRay.categories) { category.rules = null; diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 370f5d422..0c73833f7 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -32,6 +32,7 @@ import { TAG_ID_EXCLUDE_FROM_ANALYSIS, locale as defaultLocale } from '@ghostfolio/common/config'; +import { SubscriptionType } from '@ghostfolio/common/enums'; import { User as IUser, SystemMessage, @@ -156,7 +157,7 @@ export class UserService { if ( this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && - subscription.type === 'Basic' + subscription.type === SubscriptionType.Basic ) { tags = []; } @@ -443,7 +444,7 @@ export class UserService { createdAt: user.createdAt }); - if (user.subscription?.type === 'Basic') { + if (user.subscription?.type === SubscriptionType.Basic) { const daysSinceRegistration = differenceInDays( new Date(), user.createdAt @@ -485,7 +486,7 @@ export class UserService { // Reset holdings view mode user.settings.settings.holdingsViewMode = undefined; - } else if (user.subscription?.type === 'Premium') { + } else if (user.subscription?.type === SubscriptionType.Premium) { if (!hasRole(user, Role.DEMO)) { currentPermissions.push(permissions.createApiKey); currentPermissions.push(permissions.enableDataProviderGhostfolio); diff --git a/apps/api/src/services/data-provider/data-provider.service.ts b/apps/api/src/services/data-provider/data-provider.service.ts index a6b12cce2..e7c7e8a2e 100644 --- a/apps/api/src/services/data-provider/data-provider.service.ts +++ b/apps/api/src/services/data-provider/data-provider.service.ts @@ -12,6 +12,7 @@ import { PROPERTY_DATA_SOURCE_MAPPING } from '@ghostfolio/common/config'; import { CreateOrderDto } from '@ghostfolio/common/dtos'; +import { SubscriptionType } from '@ghostfolio/common/enums'; import { DATE_FORMAT, getAssetProfileIdentifier, @@ -227,7 +228,7 @@ export class DataProviderService implements OnModuleInit { if ( this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && - user.subscription.type === 'Basic' + user.subscription.type === SubscriptionType.Basic ) { const dataProvider = this.getDataProvider(DataSource[dataSource]); @@ -591,7 +592,7 @@ export class DataProviderService implements OnModuleInit { } else if ( dataProvider.getDataProviderInfo().isPremium && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && - user?.subscription.type === 'Basic' + user?.subscription.type === SubscriptionType.Basic ) { // Skip symbols of Premium data providers for users without subscription return false; @@ -780,7 +781,7 @@ export class DataProviderService implements OnModuleInit { }) .map((lookupItem) => { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { - if (user.subscription.type === 'Premium') { + if (user.subscription.type === SubscriptionType.Premium) { lookupItem.dataProviderInfo.isPremium = false; } diff --git a/apps/client/src/app/components/admin-overview/admin-overview.component.ts b/apps/client/src/app/components/admin-overview/admin-overview.component.ts index 5d4e5268e..ff55bb2bc 100644 --- a/apps/client/src/app/components/admin-overview/admin-overview.component.ts +++ b/apps/client/src/app/components/admin-overview/admin-overview.component.ts @@ -8,7 +8,10 @@ import { PROPERTY_SYSTEM_MESSAGE, ghostfolioPrefix } from '@ghostfolio/common/config'; -import { ConfirmationDialogType } from '@ghostfolio/common/enums'; +import { + ConfirmationDialogType, + SubscriptionType +} from '@ghostfolio/common/enums'; import { getDateFnsLocale } from '@ghostfolio/common/helper'; import { Coupon, @@ -255,7 +258,7 @@ export class GfAdminOverviewComponent implements OnInit { this.systemMessage ?? ({ message: '⚒️ Scheduled maintenance in progress...', - targetGroups: ['Basic', 'Premium'] + targetGroups: [SubscriptionType.Basic, SubscriptionType.Premium] } as SystemMessage) ) ); diff --git a/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts b/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts index 2f7568982..7b5b10c7d 100644 --- a/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts +++ b/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts @@ -1,5 +1,6 @@ import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; +import { SubscriptionType } from '@ghostfolio/common/enums'; import { FireCalculationCompleteEvent, FireWealth, @@ -80,7 +81,7 @@ export class GfFirePageComponent implements OnInit { : 0 } }; - if (this.user.subscription?.type === 'Basic') { + if (this.user.subscription?.type === SubscriptionType.Basic) { this.fireWealth = { today: { valueInBaseCurrency: 10000 @@ -113,7 +114,7 @@ export class GfFirePageComponent implements OnInit { this.user = state.user; this.hasPermissionToUpdateUserSettings = - this.user.subscription?.type === 'Basic' + this.user.subscription?.type === SubscriptionType.Basic ? false : hasPermission( this.user.permissions, diff --git a/apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.ts b/apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.ts index e97fd4876..650522896 100644 --- a/apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.ts +++ b/apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.ts @@ -2,6 +2,7 @@ import { GfRulesComponent } from '@ghostfolio/client/components/rules/rules.comp import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UpdateUserSettingDto } from '@ghostfolio/common/dtos'; +import { SubscriptionType } from '@ghostfolio/common/enums'; import { PortfolioReportResponse, PortfolioReportRule @@ -73,7 +74,7 @@ export class GfXRayPageComponent { this.user = state.user; this.hasPermissionToUpdateUserSettings = - this.user.subscription?.type === 'Basic' + this.user.subscription?.type === SubscriptionType.Basic ? false : hasPermission( this.user.permissions, From 8b9c8e04f2da75ccc3af473f7052d637fa5d79fe Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:32:10 +0200 Subject: [PATCH 004/132] Task/upgrade jsonpath to version 1.3.0 (#6755) * Upgrade jsonpath to version 1.3.0 * Update changelog --- CHANGELOG.md | 1 + package-lock.json | 8 ++++---- package.json | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d32a6ea89..5be79fcb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Upgraded `countup.js` from version `2.9.0` to `2.10.0` +- Upgraded `jsonpath` from version `1.2.1` to `1.3.0` ## 2.255.0 - 2026-03-20 diff --git a/package-lock.json b/package-lock.json index 81a24f54f..770273fcb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -72,7 +72,7 @@ "helmet": "7.0.0", "http-status-codes": "2.3.0", "ionicons": "8.0.13", - "jsonpath": "1.2.1", + "jsonpath": "1.3.0", "lodash": "4.18.1", "marked": "17.0.2", "ms": "3.0.0-canary.1", @@ -28659,9 +28659,9 @@ "license": "MIT" }, "node_modules/jsonpath": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/jsonpath/-/jsonpath-1.2.1.tgz", - "integrity": "sha512-Jl6Jhk0jG+kP3yk59SSeGq7LFPR4JQz1DU0K+kXTysUhMostbhU3qh5mjTuf0PqFcXpAT7kvmMt9WxV10NyIgQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/jsonpath/-/jsonpath-1.3.0.tgz", + "integrity": "sha512-0kjkYHJBkAy50Z5QzArZ7udmvxrJzkpKYW27fiF//BrMY7TQibYLl+FYIXN2BiYmwMIVzSfD8aDRj6IzgBX2/w==", "license": "MIT", "dependencies": { "esprima": "1.2.5", diff --git a/package.json b/package.json index 7c15c7f1d..9b55901c2 100644 --- a/package.json +++ b/package.json @@ -117,7 +117,7 @@ "helmet": "7.0.0", "http-status-codes": "2.3.0", "ionicons": "8.0.13", - "jsonpath": "1.2.1", + "jsonpath": "1.3.0", "lodash": "4.18.1", "marked": "17.0.2", "ms": "3.0.0-canary.1", From fb982df9299ce1742729c83d3abeab962e051e8d Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:26:49 +0200 Subject: [PATCH 005/132] Bugfix/release dates in CHANGELOG.md (#6763) Fix release dates --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5be79fcb6..b0ab2243e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Upgraded `countup.js` from version `2.9.0` to `2.10.0` - Upgraded `jsonpath` from version `1.2.1` to `1.3.0` -## 2.255.0 - 2026-03-20 +## 2.255.0 - 2026-04-20 ### Changed @@ -29,7 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed the missing value column of the accounts table component on mobile -## 2.254.0 - 2026-03-10 +## 2.254.0 - 2026-04-10 ### Added @@ -47,7 +47,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improved the style of the activity type component -## 2.253.0 - 2026-03-06 +## 2.253.0 - 2026-04-06 ### Added @@ -67,7 +67,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed the allocations by ETF provider chart on the allocations page in the _Presenter View_ - Fixed the allocations by platform chart on the allocations page in the _Presenter View_ -## 2.252.0 - 2026-03-02 +## 2.252.0 - 2026-04-02 ### Added From 9eecad153a4eecbdb2a78d50b6ae9bdae63d7e75 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:14:16 +0200 Subject: [PATCH 006/132] Task/upgrade Nx to version 22.6.5 (#6757) * Upgrade Nx to version 22.6.5 * Update changelog --- CHANGELOG.md | 1 + package-lock.json | 3479 ++++++++++++++++++++++----------------------- package.json | 22 +- 3 files changed, 1680 insertions(+), 1822 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0ab2243e..792b0cc38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Upgraded `countup.js` from version `2.9.0` to `2.10.0` - Upgraded `jsonpath` from version `1.2.1` to `1.3.0` +- Upgraded `Nx` from version `22.6.4` to `22.6.5` ## 2.255.0 - 2026-04-20 diff --git a/package-lock.json b/package-lock.json index 770273fcb..15a66dae1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -112,16 +112,16 @@ "@eslint/js": "9.35.0", "@nestjs/schematics": "11.0.9", "@nestjs/testing": "11.1.14", - "@nx/angular": "22.6.4", - "@nx/eslint-plugin": "22.6.4", - "@nx/jest": "22.6.4", - "@nx/js": "22.6.4", - "@nx/module-federation": "22.6.4", - "@nx/nest": "22.6.4", - "@nx/node": "22.6.4", - "@nx/storybook": "22.6.4", - "@nx/web": "22.6.4", - "@nx/workspace": "22.6.4", + "@nx/angular": "22.6.5", + "@nx/eslint-plugin": "22.6.5", + "@nx/jest": "22.6.5", + "@nx/js": "22.6.5", + "@nx/module-federation": "22.6.5", + "@nx/nest": "22.6.5", + "@nx/node": "22.6.5", + "@nx/storybook": "22.6.5", + "@nx/web": "22.6.5", + "@nx/workspace": "22.6.5", "@schematics/angular": "21.2.6", "@storybook/addon-docs": "10.1.10", "@storybook/angular": "10.1.10", @@ -147,7 +147,7 @@ "jest": "30.2.0", "jest-environment-jsdom": "30.2.0", "jest-preset-angular": "16.0.0", - "nx": "22.6.4", + "nx": "22.6.5", "prettier": "3.8.2", "prettier-plugin-organize-attributes": "1.0.0", "prisma": "6.19.3", @@ -3652,9 +3652,9 @@ } }, "node_modules/@colordx/core": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@colordx/core/-/core-5.0.3.tgz", - "integrity": "sha512-xBQ0MYRTNNxW3mS2sJtlQTT7C3Sasqgh1/PsHva7fyDb5uqYY+gv9V0utDdX8X80mqzbGz3u/IDJdn2d/uW09g==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@colordx/core/-/core-5.2.0.tgz", + "integrity": "sha512-wifnqsGCXRh+lJdX4975nKEPJaSk7k8rMiA/VeGrS4tOTn06WZrow6cUA7wFJKPXfcqj0rXeH4BMgGoKZvBf7g==", "dev": true, "license": "MIT" }, @@ -5217,23 +5217,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/console/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/@jest/core": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.2.0.tgz", @@ -5554,23 +5537,6 @@ "@babel/core": "^7.11.0 || ^8.0.0-beta.1" } }, - "node_modules/@jest/core/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/@jest/core/node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -6025,23 +5991,6 @@ "@sinonjs/commons": "^3.0.1" } }, - "node_modules/@jest/environment-jsdom-abstract/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/@jest/environment-jsdom-abstract/node_modules/jest-message-util": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", @@ -6167,23 +6116,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/expect/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/@jest/expect/node_modules/jest-snapshot": { "version": "30.3.0", "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.3.0.tgz", @@ -6361,23 +6293,6 @@ "@sinonjs/commons": "^3.0.1" } }, - "node_modules/@jest/globals/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/@jest/globals/node_modules/expect": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/expect/-/expect-30.2.0.tgz", @@ -6567,23 +6482,6 @@ } } }, - "node_modules/@jest/reporters/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/@jest/schemas": { "version": "30.0.5", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", @@ -6632,23 +6530,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/snapshot-utils/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/@jest/source-map": { "version": "30.0.1", "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", @@ -6722,23 +6603,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/transform/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/@jest/transform/node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -6765,23 +6629,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/types/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -7425,13 +7272,6 @@ "win32" ] }, - "node_modules/@ltd/j-toml": { - "version": "1.38.0", - "resolved": "https://registry.npmjs.org/@ltd/j-toml/-/j-toml-1.38.0.tgz", - "integrity": "sha512-lYtBcmvHustHQtg4X7TXUu1Xa/tbLC3p2wLvgQI+fWVySguVZJF60Snxijw5EiohumxZbR10kWYFFebh1zotiw==", - "dev": true, - "license": "LGPL-3.0" - }, "node_modules/@lukeed/csprng": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", @@ -7511,13 +7351,13 @@ } }, "node_modules/@module-federation/bridge-react-webpack-plugin": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@module-federation/bridge-react-webpack-plugin/-/bridge-react-webpack-plugin-2.3.1.tgz", - "integrity": "sha512-rixIHit2xeusr052t/IOfgQa9OyKc21GiJC8uE/5szmgJlJOHmiXa7QrudKb4KVDCcbd5Ad2b4+XrSYxxRUzJA==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@module-federation/bridge-react-webpack-plugin/-/bridge-react-webpack-plugin-2.3.3.tgz", + "integrity": "sha512-W2jQ3Wuqree9Dq3UAx8jGbYtvHuuYgzrd2j9FP8Bt6NaynaNU1yYG86MBnAhZJPTltex0CguudR1dgFpYdvLUg==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/sdk": "2.3.1", + "@module-federation/sdk": "2.3.3", "@types/semver": "7.5.8", "semver": "7.6.3" } @@ -7536,15 +7376,14 @@ } }, "node_modules/@module-federation/cli": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@module-federation/cli/-/cli-2.3.1.tgz", - "integrity": "sha512-9oUqFuXaZgUc1ptBPKLIUmKrzu0kog1kE05BLMEUm55JkiDtODpuzQhT/QL8h0qHBeZ70Rn12ARQQBmoZT61Aw==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@module-federation/cli/-/cli-2.3.3.tgz", + "integrity": "sha512-g3f3aEruv07zK4VcUlAllswrp2ncA/jF0P0yoEWNRa9K7N+xNCfqcdzw2aVWOJ30qNMurhLWuyzYqfDIx0LpfQ==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/dts-plugin": "2.3.1", - "@module-federation/sdk": "2.3.1", - "chalk": "3.0.0", + "@module-federation/dts-plugin": "2.3.3", + "@module-federation/sdk": "2.3.3", "commander": "11.1.0", "jiti": "2.4.2" }, @@ -7556,15 +7395,14 @@ } }, "node_modules/@module-federation/data-prefetch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@module-federation/data-prefetch/-/data-prefetch-2.3.1.tgz", - "integrity": "sha512-p/G5Nlu7buiE7TdrznHanxFS1Zik8nmzNUDLmgwfdHRIaH7Rj4+gLIgLg5Zrjtkdvae/L2UJpcC8QopJMQjv4A==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@module-federation/data-prefetch/-/data-prefetch-2.3.3.tgz", + "integrity": "sha512-ZM1QtyjbWYnhUizHFhwYjHGXlkZek3vzTpL35d5FkAhVrOU0u0Qv6zpZjdcCm0FJznqVsUQx1w0vagUyGWQf0g==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/runtime": "2.3.1", - "@module-federation/sdk": "2.3.1", - "fs-extra": "9.1.0" + "@module-federation/runtime": "2.3.3", + "@module-federation/sdk": "2.3.3" }, "peerDependencies": { "react": ">=16.9.0", @@ -7580,22 +7418,21 @@ } }, "node_modules/@module-federation/dts-plugin": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@module-federation/dts-plugin/-/dts-plugin-2.3.1.tgz", - "integrity": "sha512-6BJvu+dLDtW/ngpyuOLgpKOgtOnMUTZY51JUyargVckerKRbe7Ul+414YaHj32mu2FpsiHVMl4ig1XnxgnRg2Q==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@module-federation/dts-plugin/-/dts-plugin-2.3.3.tgz", + "integrity": "sha512-VNtURt+hvieNKCBleAqHKffLAU4clKmuuqLQIbvDkFbGe4bo7hUaq5DruTnJBWWDOizZx0OQrdQYPijCnBK6UQ==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/error-codes": "2.3.1", - "@module-federation/managers": "2.3.1", - "@module-federation/sdk": "2.3.1", - "@module-federation/third-party-dts-extractor": "2.3.1", - "adm-zip": "^0.5.10", - "ansi-colors": "^4.1.3", - "axios": "1.13.5", - "fs-extra": "9.1.0", + "@module-federation/error-codes": "2.3.3", + "@module-federation/managers": "2.3.3", + "@module-federation/sdk": "2.3.3", + "@module-federation/third-party-dts-extractor": "2.3.3", + "adm-zip": "0.5.10", + "ansi-colors": "4.1.3", "isomorphic-ws": "5.0.0", "node-schedule": "2.1.1", + "undici": "7.24.7", "ws": "8.18.0" }, "peerDependencies": { @@ -7608,26 +7445,36 @@ } } }, + "node_modules/@module-federation/dts-plugin/node_modules/undici": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", + "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/@module-federation/enhanced": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@module-federation/enhanced/-/enhanced-2.3.1.tgz", - "integrity": "sha512-zvzymtzsYVlSPt/HKjm42OGiDxUDPLce7mr6VZw4d6//AFFK3kKUEpUqwlf/bIlbg7FbwJC/7hVCmUhlF+dxgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@module-federation/bridge-react-webpack-plugin": "2.3.1", - "@module-federation/cli": "2.3.1", - "@module-federation/data-prefetch": "2.3.1", - "@module-federation/dts-plugin": "2.3.1", - "@module-federation/error-codes": "2.3.1", - "@module-federation/inject-external-runtime-core-plugin": "2.3.1", - "@module-federation/managers": "2.3.1", - "@module-federation/manifest": "2.3.1", - "@module-federation/rspack": "2.3.1", - "@module-federation/runtime-tools": "2.3.1", - "@module-federation/sdk": "2.3.1", - "@module-federation/webpack-bundler-runtime": "2.3.1", - "schema-utils": "^4.3.0", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@module-federation/enhanced/-/enhanced-2.3.3.tgz", + "integrity": "sha512-BJSs56lqO9NI9aC+hVhg2CU/UwG1TphVl1b7WBx6Jv6DYUyVQbgXeQpgqYVsxYVRKYOl7eDZmjXl2eA/n1IP/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@module-federation/bridge-react-webpack-plugin": "2.3.3", + "@module-federation/cli": "2.3.3", + "@module-federation/data-prefetch": "2.3.3", + "@module-federation/dts-plugin": "2.3.3", + "@module-federation/error-codes": "2.3.3", + "@module-federation/inject-external-runtime-core-plugin": "2.3.3", + "@module-federation/managers": "2.3.3", + "@module-federation/manifest": "2.3.3", + "@module-federation/rspack": "2.3.3", + "@module-federation/runtime-tools": "2.3.3", + "@module-federation/sdk": "2.3.3", + "@module-federation/webpack-bundler-runtime": "2.3.3", + "schema-utils": "4.3.0", "tapable": "2.3.0", "upath": "2.0.1" }, @@ -7651,60 +7498,96 @@ } } }, + "node_modules/@module-federation/enhanced/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/@module-federation/enhanced/node_modules/schema-utils": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.0.tgz", + "integrity": "sha512-Gf9qqc58SpCA/xdziiHz35F4GNIWYWZrEshUc/G/r5BnLph6xpKuLeoJoQuj5WfBIx/eQLf+hmVPYHaxJu7V2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/@module-federation/error-codes": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-2.3.1.tgz", - "integrity": "sha512-s3IjT2OYrSBNNmxdTmmrWBpsFfeNszdL6BSqjXLHb1CgXWUYLNXpb05IopnzMhRLcur6MTGuKR0ZSjJbmvQBbg==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-2.3.3.tgz", + "integrity": "sha512-UVtKBoKnRDcHgByIDvPRZSxQqjqbNH7NvJm1KHLoce33+EDiIdZYs0HvvUQv43RgESpB9s7HjrqFlq3bEcAgfQ==", "dev": true, "license": "MIT" }, "node_modules/@module-federation/inject-external-runtime-core-plugin": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@module-federation/inject-external-runtime-core-plugin/-/inject-external-runtime-core-plugin-2.3.1.tgz", - "integrity": "sha512-Q/zd3dImx4vyXLQ/UEQ0udL95yPlfbSyKcoW86tIralxA5NgnR3rEp3ccGt9MHSBbCywFrbzX5OwfKW/Jgfexw==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@module-federation/inject-external-runtime-core-plugin/-/inject-external-runtime-core-plugin-2.3.3.tgz", + "integrity": "sha512-ImSft6hOkMdnpZX8O+RydwkYENxhAwT92n1OAT3Xf01DXMrEpSO0PqBlPGgontxuiaV9dM2/xWSLGuIWaOtupA==", "dev": true, "license": "MIT", "peerDependencies": { - "@module-federation/runtime-tools": "2.3.1" + "@module-federation/runtime-tools": "2.3.3" } }, "node_modules/@module-federation/managers": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@module-federation/managers/-/managers-2.3.1.tgz", - "integrity": "sha512-kK/4FkoaIxbJbN+R6+cq+igv095hPox8oheZOKkrYA9P6Xv5FiHza+gHlCntiWTMrU8bzqJHH4VYm6gq1RB+dQ==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@module-federation/managers/-/managers-2.3.3.tgz", + "integrity": "sha512-sYL0t2guakJ+nDSQANH54uz5q1YxaNCn5C3lr+7BoRD49dX7Z6k7094yqOPEy8trzqdIoQVFpgewVA6IC/FeyQ==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/sdk": "2.3.1", - "find-pkg": "2.0.0", - "fs-extra": "9.1.0" + "@module-federation/sdk": "2.3.3", + "find-pkg": "2.0.0" } }, "node_modules/@module-federation/manifest": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@module-federation/manifest/-/manifest-2.3.1.tgz", - "integrity": "sha512-BXckns3ux6Z9XiB2Bpirj/Q3FcQnxiyKt0rx0HmF0/7V6Zy2mwR/011eoeRGHN9N2HZcIxgQcWgMtxl5FAqxyg==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@module-federation/manifest/-/manifest-2.3.3.tgz", + "integrity": "sha512-mAEXuo5sGt8FUDzftU8f0ci0PbsZIDcLRYX9AkXwbXg0JRyVEvWyiBrEKF+zZuy7YM7eRdyp6JjLJPDzufhj5w==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/dts-plugin": "2.3.1", - "@module-federation/managers": "2.3.1", - "@module-federation/sdk": "2.3.1", - "chalk": "3.0.0", + "@module-federation/dts-plugin": "2.3.3", + "@module-federation/managers": "2.3.3", + "@module-federation/sdk": "2.3.3", "find-pkg": "2.0.0" } }, "node_modules/@module-federation/node": { - "version": "2.7.39", - "resolved": "https://registry.npmjs.org/@module-federation/node/-/node-2.7.39.tgz", - "integrity": "sha512-BsfpXVIuNO5KBwvOKaTuSK4jU+vrWcDvrue2K+3YGY8lUL7mwx11W4TP4Dvrslazub93zz5NBM44qqogo+VGXA==", + "version": "2.7.41", + "resolved": "https://registry.npmjs.org/@module-federation/node/-/node-2.7.41.tgz", + "integrity": "sha512-ZaNrfX+7ua8UvnRa6qgrDtViU9Oz3oBGpFprldU8ek0NB2QWNACu9RMU7fHdTD/FKlzGVLi/LgdKnNKkmJD2TA==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/enhanced": "2.3.1", - "@module-federation/runtime": "2.3.1", - "@module-federation/sdk": "2.3.1", - "encoding": "^0.1.13", + "@module-federation/enhanced": "2.3.3", + "@module-federation/runtime": "2.3.3", + "@module-federation/sdk": "2.3.3", + "encoding": "0.1.13", "node-fetch": "2.7.0", "tapable": "2.3.0" }, @@ -7764,19 +7647,19 @@ } }, "node_modules/@module-federation/rspack": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@module-federation/rspack/-/rspack-2.3.1.tgz", - "integrity": "sha512-pZmLSDkD7nDsCc377Q8sB1Yu2iMYFj72VS/Fb8B7uTmhYwU1wqDK9zPwaxauim5Y4TqQBdy/hPzNES3f4lG33Q==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@module-federation/rspack/-/rspack-2.3.3.tgz", + "integrity": "sha512-4s3G+wXZ6J3rKe0EeZnGLQUM7y+qpiI5NM3U6ylZuxD8q7mAwQVHThbH6ruDYUNDVEOc6N0j/+/LdfGRw+e5xw==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/bridge-react-webpack-plugin": "2.3.1", - "@module-federation/dts-plugin": "2.3.1", - "@module-federation/inject-external-runtime-core-plugin": "2.3.1", - "@module-federation/managers": "2.3.1", - "@module-federation/manifest": "2.3.1", - "@module-federation/runtime-tools": "2.3.1", - "@module-federation/sdk": "2.3.1" + "@module-federation/bridge-react-webpack-plugin": "2.3.3", + "@module-federation/dts-plugin": "2.3.3", + "@module-federation/inject-external-runtime-core-plugin": "2.3.3", + "@module-federation/managers": "2.3.3", + "@module-federation/manifest": "2.3.3", + "@module-federation/runtime-tools": "2.3.3", + "@module-federation/sdk": "2.3.3" }, "peerDependencies": { "@rspack/core": "^0.7.0 || ^1.0.0 || ^2.0.0-0", @@ -7793,43 +7676,43 @@ } }, "node_modules/@module-federation/runtime": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@module-federation/runtime/-/runtime-2.3.1.tgz", - "integrity": "sha512-NiKelHKzOf1Vz8oqcxC/XRUAW224O6lKj9xD0cfp5Bp343iu6s58RlLvX1ypF+UpCl3jA4JM8npGax/3jjyifw==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@module-federation/runtime/-/runtime-2.3.3.tgz", + "integrity": "sha512-JYJ3qv9V85DtBtT/ppDuJNwBTUrYqqZDYcyiTzwY5+44dC5QPvgJ//F+BOhAhZ02WkZV0b4jsKTyLOC3vXKGqQ==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/error-codes": "2.3.1", - "@module-federation/runtime-core": "2.3.1", - "@module-federation/sdk": "2.3.1" + "@module-federation/error-codes": "2.3.3", + "@module-federation/runtime-core": "2.3.3", + "@module-federation/sdk": "2.3.3" } }, "node_modules/@module-federation/runtime-core": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@module-federation/runtime-core/-/runtime-core-2.3.1.tgz", - "integrity": "sha512-E0WgaCn32AWzD0n6SCH7VQ+kxk46XyX432PQWARgyQzCX/wyLkaT+We3A18RVNUevRT85YHLrrVIhMKJJVHgjA==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@module-federation/runtime-core/-/runtime-core-2.3.3.tgz", + "integrity": "sha512-B07LDH9KxhBO3GbULGW64mQFVQBtrEd3PoaCBm7XR1IbU8rMQUJQjDNVZgXYcyhRPBVP+3KWZuiaKFRiNb6PQw==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/error-codes": "2.3.1", - "@module-federation/sdk": "2.3.1" + "@module-federation/error-codes": "2.3.3", + "@module-federation/sdk": "2.3.3" } }, "node_modules/@module-federation/runtime-tools": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@module-federation/runtime-tools/-/runtime-tools-2.3.1.tgz", - "integrity": "sha512-JrTKnNxIglnwrycPHUz9vARHLWqdecgFJxhmu8991z5CjktHc5JIelCbQS5Ur2lABjuwBdlyw8pH2xI3EJpbOg==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@module-federation/runtime-tools/-/runtime-tools-2.3.3.tgz", + "integrity": "sha512-XODzyLbBYcy4wnYBXKIBqaHPVfBx1HshGdjZmSctDDnx9/VYgdx9DShb6UI+WuQBKJgPzTcx4xbvbCM4SdMilQ==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/runtime": "2.3.1", - "@module-federation/webpack-bundler-runtime": "2.3.1" + "@module-federation/runtime": "2.3.3", + "@module-federation/webpack-bundler-runtime": "2.3.3" } }, "node_modules/@module-federation/sdk": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@module-federation/sdk/-/sdk-2.3.1.tgz", - "integrity": "sha512-lgWxFZyLRKDXWRGlV6ROjFJ6MRaJTxs0bBnS6hS9ONfr/0TkeW4JzDbsfzrB8g4p6IgSKB+wQ9XfibJCGBI5OQ==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@module-federation/sdk/-/sdk-2.3.3.tgz", + "integrity": "sha512-mwCS+LQdqiSc6fM5iz/S60ibaFNSH6kNqlZkCRIuS4yjdZ+jgnihz+6xp1QzppvfFgKLhEHBiXOmcYOdk3Ckew==", "dev": true, "license": "MIT", "peerDependencies": { @@ -7842,27 +7725,26 @@ } }, "node_modules/@module-federation/third-party-dts-extractor": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@module-federation/third-party-dts-extractor/-/third-party-dts-extractor-2.3.1.tgz", - "integrity": "sha512-YpTLzM7H9damh31JX7eFBiCCR1mbibzS4i4JEa4fZ5ICT4hfNIuaAx1OeICGDOzSdl35TYegegCjk91oX6xCJQ==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@module-federation/third-party-dts-extractor/-/third-party-dts-extractor-2.3.3.tgz", + "integrity": "sha512-rR94TjC1QVQLQPTazI0waLc76hI8dnv6aHTl+PUEIY9s5hXp8TA85XS0QJQqIf2KTjlPgZbWAwyFjOAJluTjaw==", "dev": true, "license": "MIT", "dependencies": { "find-pkg": "2.0.0", - "fs-extra": "9.1.0", "resolve": "1.22.8" } }, "node_modules/@module-federation/webpack-bundler-runtime": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@module-federation/webpack-bundler-runtime/-/webpack-bundler-runtime-2.3.1.tgz", - "integrity": "sha512-fnsMncVdBYv7a1gN5ElNK1uA9dmGUgaNqcoNiv9xRtpxFYswchl1kbgxPTliCb8U7quihdWZos7P2lvpYeVRwg==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@module-federation/webpack-bundler-runtime/-/webpack-bundler-runtime-2.3.3.tgz", + "integrity": "sha512-W+P6ZF9J3gwnQuoF07YV0OiR1D6sI/uErUu4+c3QXxka3orANUHujkddNSsDxL1obAGoJa7Da99crZKf7u2j/w==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/error-codes": "2.3.1", - "@module-federation/runtime": "2.3.1", - "@module-federation/sdk": "2.3.1" + "@module-federation/error-codes": "2.3.3", + "@module-federation/runtime": "2.3.3", + "@module-federation/sdk": "2.3.3" } }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { @@ -8249,16 +8131,25 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz", - "integrity": "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==", + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.4.tgz", + "integrity": "sha512-9zESzOO5aDByvhIAsOy9TbpZ0Ur2AJbUI7UT73kcUTS2mxAMHOBaa1st/jAymNoCtvrit99kkzT1FZuXVcgfIQ==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "@emnapi/core": "^1.5.0", - "@emnapi/runtime": "^1.5.0", - "@tybys/wasm-util": "^0.10.1" + "@emnapi/core": "^1.1.0", + "@emnapi/runtime": "^1.1.0", + "@tybys/wasm-util": "^0.9.0" + } + }, + "node_modules/@napi-rs/wasm-runtime/node_modules/@tybys/wasm-util": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", + "integrity": "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" } }, "node_modules/@nestjs/bull": { @@ -8546,23 +8437,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@nestjs/schematics/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/@nestjs/schematics/node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -9147,20 +9021,20 @@ } }, "node_modules/@nx/angular": { - "version": "22.6.4", - "resolved": "https://registry.npmjs.org/@nx/angular/-/angular-22.6.4.tgz", - "integrity": "sha512-62Zwqk5Dlael8bXNp1r/s/W6w0cmZpkn03kxamfo5tmqgg3la2DsW06kcnoQz/2WwwoMAARzLQGPHBXsutsPLw==", + "version": "22.6.5", + "resolved": "https://registry.npmjs.org/@nx/angular/-/angular-22.6.5.tgz", + "integrity": "sha512-NPkrGGatlUUK7twHKYpv3mv6jYL6dRiqdPuqAhQfmUnuz5lA4ZhpCfwEBKUFEKsGNwOft0ZCGZdSdODliaKZzA==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.6.4", - "@nx/eslint": "22.6.4", - "@nx/js": "22.6.4", - "@nx/module-federation": "22.6.4", - "@nx/rspack": "22.6.4", - "@nx/web": "22.6.4", - "@nx/webpack": "22.6.4", - "@nx/workspace": "22.6.4", + "@nx/devkit": "22.6.5", + "@nx/eslint": "22.6.5", + "@nx/js": "22.6.5", + "@nx/module-federation": "22.6.5", + "@nx/rspack": "22.6.5", + "@nx/web": "22.6.5", + "@nx/webpack": "22.6.5", + "@nx/workspace": "22.6.5", "@phenomnomnominal/tsquery": "~6.1.4", "@typescript-eslint/type-utils": "^8.0.0", "enquirer": "~2.3.6", @@ -9208,15 +9082,15 @@ } }, "node_modules/@nx/cypress": { - "version": "22.6.4", - "resolved": "https://registry.npmjs.org/@nx/cypress/-/cypress-22.6.4.tgz", - "integrity": "sha512-yIhHTjDUE9h0VcTewiVc5UVuNlo0teuJBxk+yuIceWj9vUwuQNwVXaQ+MHyVrfgsNa8Eh9IzqGyWPgNv27MVmg==", + "version": "22.6.5", + "resolved": "https://registry.npmjs.org/@nx/cypress/-/cypress-22.6.5.tgz", + "integrity": "sha512-8pVXkVryoLRDEFjKrOIcPArY8RObf7SC1U8WMyxNS28Hs32eaENWNXtOncrXQz98jWhpi4jvmr0WPLCr9NCFsA==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.6.4", - "@nx/eslint": "22.6.4", - "@nx/js": "22.6.4", + "@nx/devkit": "22.6.5", + "@nx/eslint": "22.6.5", + "@nx/js": "22.6.5", "@phenomnomnominal/tsquery": "~6.1.4", "detect-port": "^1.5.1", "semver": "^7.6.3", @@ -9233,14 +9107,14 @@ } }, "node_modules/@nx/devkit": { - "version": "22.6.4", - "resolved": "https://registry.npmjs.org/@nx/devkit/-/devkit-22.6.4.tgz", - "integrity": "sha512-4VRND4Hl+zWSPvs68cJn0PUoxi1ADS1iqXy3VJNtUlVqjE7Y5LtZxKUC05w5OKP+2jMfU3viPTZIGwHnHuIaYA==", + "version": "22.6.5", + "resolved": "https://registry.npmjs.org/@nx/devkit/-/devkit-22.6.5.tgz", + "integrity": "sha512-9kvAI+kk2pfEXLqS8OyjI9XvWmp+Gdn7jPfxDAz8BOqxMyPy3p5hYl+jc4TIsLOWunAFl8azqrcYsHzEpaWCIA==", "dev": true, "license": "MIT", "dependencies": { "@zkochan/js-yaml": "0.0.7", - "ejs": "^3.1.7", + "ejs": "5.0.1", "enquirer": "~2.3.6", "minimatch": "10.2.4", "semver": "^7.6.3", @@ -9274,6 +9148,19 @@ "node": "18 || 20 || >=22" } }, + "node_modules/@nx/devkit/node_modules/ejs": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-5.0.1.tgz", + "integrity": "sha512-COqBPFMxuPTPspXl2DkVYaDS3HtrD1GpzOGkNTJ1IYkifq/r9h8SVEFrjA3D9/VJGOEoMQcrlhpntcSUrM8k6A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.12.18" + } + }, "node_modules/@nx/devkit/node_modules/minimatch": { "version": "10.2.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", @@ -9291,26 +9178,26 @@ } }, "node_modules/@nx/docker": { - "version": "22.6.4", - "resolved": "https://registry.npmjs.org/@nx/docker/-/docker-22.6.4.tgz", - "integrity": "sha512-Zcn4fBonuPFr34lSx8rT6GTetLO2lHkzmHT9EqCIycQ1DBcB1lrn7dM7oaIM6HJFVf49ECQv3WYN3rPVrS+vKg==", + "version": "22.6.5", + "resolved": "https://registry.npmjs.org/@nx/docker/-/docker-22.6.5.tgz", + "integrity": "sha512-kEZi8sC7hL5WsUCJkmj7hvrTahK8yOsRfgRljbSQnQ6vAZPHHZQnB36ybgrb0JbhFPciRV5Mf9UQm13Ev49M2Q==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.6.4", + "@nx/devkit": "22.6.5", "enquirer": "~2.3.6", "tslib": "^2.3.0" } }, "node_modules/@nx/eslint": { - "version": "22.6.4", - "resolved": "https://registry.npmjs.org/@nx/eslint/-/eslint-22.6.4.tgz", - "integrity": "sha512-J1MS4INO1ZK2InIhg/qBgCLu7jmkxf+FAgDpQo2XqLJQ69RcwRRiG7oArrbYc4tRSIs67iANYDPmkcy9JO1zQg==", + "version": "22.6.5", + "resolved": "https://registry.npmjs.org/@nx/eslint/-/eslint-22.6.5.tgz", + "integrity": "sha512-rEV8CveVA3CCW8MHSKauUI+6XSpQ0nZ/z64fBvBulLUoUO10/mVpkbl3NpRyhCKXzOHYhW35wwuzq6YrfSi6gA==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.6.4", - "@nx/js": "22.6.4", + "@nx/devkit": "22.6.5", + "@nx/js": "22.6.5", "semver": "^7.6.3", "tslib": "^2.3.0", "typescript": "~5.9.2" @@ -9326,14 +9213,14 @@ } }, "node_modules/@nx/eslint-plugin": { - "version": "22.6.4", - "resolved": "https://registry.npmjs.org/@nx/eslint-plugin/-/eslint-plugin-22.6.4.tgz", - "integrity": "sha512-5/tGch9wT5nwLry5FfspFiLFUaU6XEugoxtNj6nRPZBYDclQB4z4FTPPxNBwMyX7WAi1ovP2b4GgyUP23Kn3bg==", + "version": "22.6.5", + "resolved": "https://registry.npmjs.org/@nx/eslint-plugin/-/eslint-plugin-22.6.5.tgz", + "integrity": "sha512-G1DkvBMzBAqxxrJ7Zfky5HN4HQjrULKZQ5J5YCnTm5qZpah58U7xAP4g8D0aJzKMWMUJkgXAU1ojiLbeZUDJkw==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.6.4", - "@nx/js": "22.6.4", + "@nx/devkit": "22.6.5", + "@nx/js": "22.6.5", "@phenomnomnominal/tsquery": "~6.1.4", "@typescript-eslint/type-utils": "^8.0.0", "@typescript-eslint/utils": "^8.0.0", @@ -9354,23 +9241,6 @@ } } }, - "node_modules/@nx/eslint-plugin/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/@nx/eslint-plugin/node_modules/globals": { "version": "15.15.0", "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", @@ -9385,16 +9255,16 @@ } }, "node_modules/@nx/jest": { - "version": "22.6.4", - "resolved": "https://registry.npmjs.org/@nx/jest/-/jest-22.6.4.tgz", - "integrity": "sha512-Hxl1yRkOZ9ks3g1AgV1jK+Fwz7GRlsnyI+2YcZaQg8Lkj4ADl02WfzKQ58kpFWKPO+CbGRKqhvQeCPkB80W3jw==", + "version": "22.6.5", + "resolved": "https://registry.npmjs.org/@nx/jest/-/jest-22.6.5.tgz", + "integrity": "sha512-Px+ROXwl3s8tjS3OEVMtNY5krQ2zAWyK7s922zmtbXjNKGZ8bg6krnkq7n5XpQwyXTZNVxku8TXcN7jXZECGFQ==", "dev": true, "license": "MIT", "dependencies": { "@jest/reporters": "^30.0.2", "@jest/test-result": "^30.0.2", - "@nx/devkit": "22.6.4", - "@nx/js": "22.6.4", + "@nx/devkit": "22.6.5", + "@nx/js": "22.6.5", "@phenomnomnominal/tsquery": "~6.1.4", "identity-obj-proxy": "3.0.0", "jest-config": "^30.0.2", @@ -9448,9 +9318,9 @@ } }, "node_modules/@nx/js": { - "version": "22.6.4", - "resolved": "https://registry.npmjs.org/@nx/js/-/js-22.6.4.tgz", - "integrity": "sha512-5437z2lHT1Xq+xuzn9WjcaYcUnTh6qHfICO6d/rY6n9x34pYYTNdNPdabekL1pEEAeKcFAh6fCV7O8E8vIFBLQ==", + "version": "22.6.5", + "resolved": "https://registry.npmjs.org/@nx/js/-/js-22.6.5.tgz", + "integrity": "sha512-bmikz6qaBHfuAgsqPB/TfLIKfvI4g+EKIRAiU2FHnEtVWOKDAmSQXHFwE3rMS49jl2JLgxkdNjZHpg4g/OLy0g==", "dev": true, "license": "MIT", "dependencies": { @@ -9461,8 +9331,8 @@ "@babel/preset-env": "^7.23.2", "@babel/preset-typescript": "^7.22.5", "@babel/runtime": "^7.22.6", - "@nx/devkit": "22.6.4", - "@nx/workspace": "22.6.4", + "@nx/devkit": "22.6.5", + "@nx/workspace": "22.6.5", "@zkochan/js-yaml": "0.0.7", "babel-plugin-const-enum": "^1.0.1", "babel-plugin-macros": "^3.1.0", @@ -9490,23 +9360,6 @@ } } }, - "node_modules/@nx/js/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/@nx/js/node_modules/jsonc-parser": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", @@ -9536,18 +9389,18 @@ } }, "node_modules/@nx/module-federation": { - "version": "22.6.4", - "resolved": "https://registry.npmjs.org/@nx/module-federation/-/module-federation-22.6.4.tgz", - "integrity": "sha512-Mwc8vW0lF0BXCAyc5Ofb2fdcuWJjwgxHzi9EkUrC2heJsCL/2MzcwOkWHCo2vLUnaKaIHyG+btNEuGNmxVDRqw==", + "version": "22.6.5", + "resolved": "https://registry.npmjs.org/@nx/module-federation/-/module-federation-22.6.5.tgz", + "integrity": "sha512-nQS3qFGs8lQ87ZQ8hab+oL+BfjCYjNPkGrpH4fXovnFgwaRNudnQnh2vTud1+JcUl0e+sJi/wIwZH4AB75jzSA==", "dev": true, "license": "MIT", "dependencies": { "@module-federation/enhanced": "^2.1.0", "@module-federation/node": "^2.7.21", "@module-federation/sdk": "^2.1.0", - "@nx/devkit": "22.6.4", - "@nx/js": "22.6.4", - "@nx/web": "22.6.4", + "@nx/devkit": "22.6.5", + "@nx/js": "22.6.5", + "@nx/web": "22.6.5", "@rspack/core": "1.6.8", "express": "^4.21.2", "http-proxy-middleware": "^3.0.5", @@ -9648,146 +9501,6 @@ "@rspack/binding-win32-x64-msvc": "1.6.8" } }, - "node_modules/@nx/module-federation/node_modules/@rspack/binding-darwin-arm64": { - "version": "1.6.8", - "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-arm64/-/binding-darwin-arm64-1.6.8.tgz", - "integrity": "sha512-e8CTQtzaeGnf+BIzR7wRMUwKfIg0jd/sxMRc1Vd0bCMHBhSN9EsGoMuJJaKeRrSmy2nwMCNWHIG+TvT1CEKg+A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@nx/module-federation/node_modules/@rspack/binding-darwin-x64": { - "version": "1.6.8", - "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-x64/-/binding-darwin-x64-1.6.8.tgz", - "integrity": "sha512-ku1XpTEPt6Za11zhpFWhfwrTQogcgi9RJrOUVC4FESiPO9aKyd4hJ+JiPgLY0MZOqsptK6vEAgOip+uDVXrCpg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@nx/module-federation/node_modules/@rspack/binding-linux-arm64-gnu": { - "version": "1.6.8", - "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.6.8.tgz", - "integrity": "sha512-fvZX6xZPvBT8qipSpvkKMX5M7yd2BSpZNCZXcefw6gA3uC7LI3gu+er0LrDXY1PtPzVuHTyDx+abwWpagV3PiQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@nx/module-federation/node_modules/@rspack/binding-linux-arm64-musl": { - "version": "1.6.8", - "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.6.8.tgz", - "integrity": "sha512-++XMKcMNrt59HcFBLnRaJcn70k3X0GwkAegZBVpel8xYIAgvoXT5+L8P1ExId/yTFxqedaz8DbcxQnNmMozviw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@nx/module-federation/node_modules/@rspack/binding-linux-x64-gnu": { - "version": "1.6.8", - "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.6.8.tgz", - "integrity": "sha512-tv3BWkTE1TndfX+DsE1rSTg8fBevCxujNZ3MlfZ22Wfy9x1FMXTJlWG8VIOXmaaJ1wUHzv8S7cE2YUUJ2LuiCg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@nx/module-federation/node_modules/@rspack/binding-linux-x64-musl": { - "version": "1.6.8", - "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-musl/-/binding-linux-x64-musl-1.6.8.tgz", - "integrity": "sha512-DCGgZ5/in1O3FjHWqXnDsncRy+48cMhfuUAAUyl0yDj1NpsZu9pP+xfGLvGcQTiYrVl7IH9Aojf1eShP/77WGA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@nx/module-federation/node_modules/@rspack/binding-wasm32-wasi": { - "version": "1.6.8", - "resolved": "https://registry.npmjs.org/@rspack/binding-wasm32-wasi/-/binding-wasm32-wasi-1.6.8.tgz", - "integrity": "sha512-VUwdhl/lI4m6o1OGCZ9JwtMjTV/yLY5VZTQdEPKb40JMTlmZ5MBlr5xk7ByaXXYHr6I+qnqEm73iMKQvg6iknw==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "1.0.7" - } - }, - "node_modules/@nx/module-federation/node_modules/@rspack/binding-win32-arm64-msvc": { - "version": "1.6.8", - "resolved": "https://registry.npmjs.org/@rspack/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.6.8.tgz", - "integrity": "sha512-23YX7zlOZlub+nPGDBUzktb4D5D6ETUAluKjXEeHIZ9m7fSlEYBnGL66YE+3t1DHXGd0OqsdwlvrNGcyo6EXDQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@nx/module-federation/node_modules/@rspack/binding-win32-ia32-msvc": { - "version": "1.6.8", - "resolved": "https://registry.npmjs.org/@rspack/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.6.8.tgz", - "integrity": "sha512-cFgRE3APxrY4AEdooVk2LtipwNNT/9mrnjdC5lVbsIsz+SxvGbZR231bxDJEqP15+RJOaD07FO1sIjINFqXMEg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@nx/module-federation/node_modules/@rspack/binding-win32-x64-msvc": { - "version": "1.6.8", - "resolved": "https://registry.npmjs.org/@rspack/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.6.8.tgz", - "integrity": "sha512-cIuhVsZYd3o3Neo1JSAhJYw6BDvlxaBoqvgwRkG1rs0ExFmEmgYyG7ip9pFKnKNWph/tmW3rDYypmEfjs1is7g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@nx/module-federation/node_modules/@rspack/core": { "version": "1.6.8", "resolved": "https://registry.npmjs.org/@rspack/core/-/core-1.6.8.tgz", @@ -10124,41 +9837,41 @@ } }, "node_modules/@nx/nest": { - "version": "22.6.4", - "resolved": "https://registry.npmjs.org/@nx/nest/-/nest-22.6.4.tgz", - "integrity": "sha512-HeViWDRv3b0L+vuBb/OwDxZxHoGb0Zk2IcBm/+IcorCZXRv/vEnvxxREZOY0trY12PBp/4KnuNoGMzgLWENj8g==", + "version": "22.6.5", + "resolved": "https://registry.npmjs.org/@nx/nest/-/nest-22.6.5.tgz", + "integrity": "sha512-HdQVzXEccVtoSakU+Mqib44DgbFi1JKkA0BVnUp6Fmp0/Bt0xo5l2lhP2x4qS8oKQCWHtoxNznWKLkmqGLRjig==", "dev": true, "license": "MIT", "dependencies": { "@nestjs/schematics": "^11.0.0", - "@nx/devkit": "22.6.4", - "@nx/eslint": "22.6.4", - "@nx/js": "22.6.4", - "@nx/node": "22.6.4", + "@nx/devkit": "22.6.5", + "@nx/eslint": "22.6.5", + "@nx/js": "22.6.5", + "@nx/node": "22.6.5", "tslib": "^2.3.0" } }, "node_modules/@nx/node": { - "version": "22.6.4", - "resolved": "https://registry.npmjs.org/@nx/node/-/node-22.6.4.tgz", - "integrity": "sha512-uYfyL+JIPA9b6UgwYynwM1Quh6jbctXGhnibrobLi+d6st0XhIjGKIw6dGWRIVthEyz2Ze3yloibV3Lp8fy6fg==", + "version": "22.6.5", + "resolved": "https://registry.npmjs.org/@nx/node/-/node-22.6.5.tgz", + "integrity": "sha512-ahZRKpd6pqflRqBX4/oVSk5bAHtS2CM4I3T3c6iuTNenG8ehVxGmuJj8KpiC0tlLe3kJoR1oWQU1pDakXrVWow==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.6.4", - "@nx/docker": "22.6.4", - "@nx/eslint": "22.6.4", - "@nx/jest": "22.6.4", - "@nx/js": "22.6.4", + "@nx/devkit": "22.6.5", + "@nx/docker": "22.6.5", + "@nx/eslint": "22.6.5", + "@nx/jest": "22.6.5", + "@nx/js": "22.6.5", "kill-port": "^1.6.1", "tcp-port-used": "^1.0.2", "tslib": "^2.3.0" } }, "node_modules/@nx/nx-darwin-arm64": { - "version": "22.6.4", - "resolved": "https://registry.npmjs.org/@nx/nx-darwin-arm64/-/nx-darwin-arm64-22.6.4.tgz", - "integrity": "sha512-KuUQ9t8pxIO+Px1kbjA0XDLOU6XoAsijl0ssIMRYN1w5ly+0k/KglWt7qgwDockkaLRHkQ3YSR8I2LJXJE+Vig==", + "version": "22.6.5", + "resolved": "https://registry.npmjs.org/@nx/nx-darwin-arm64/-/nx-darwin-arm64-22.6.5.tgz", + "integrity": "sha512-qT77Omkg5xQuL2+pDbneX2tI+XW5ZeayMylu7UUgK8OhTrAkJLKjpuYRH4xT5XBipxbDtlxmO3aLS3Ib1pKzJQ==", "cpu": [ "arm64" ], @@ -10170,9 +9883,9 @@ ] }, "node_modules/@nx/nx-darwin-x64": { - "version": "22.6.4", - "resolved": "https://registry.npmjs.org/@nx/nx-darwin-x64/-/nx-darwin-x64-22.6.4.tgz", - "integrity": "sha512-FB2XL2+ixbRI1fddz4oW+9MhoJASoTD8Ai4q5+B1OUPftgarIPLxaqI8TWba30Bos2AiYDofMJPf9uhBmLDH5Q==", + "version": "22.6.5", + "resolved": "https://registry.npmjs.org/@nx/nx-darwin-x64/-/nx-darwin-x64-22.6.5.tgz", + "integrity": "sha512-9jICxb7vfJ56y/7Yuh3b/n1QJqWxO9xnXKYEs6SO8xPoW/KomVckILGc1C6RQSs6/3ixVJC7k1Dh1wm5tKPFrg==", "cpu": [ "x64" ], @@ -10184,9 +9897,9 @@ ] }, "node_modules/@nx/nx-freebsd-x64": { - "version": "22.6.4", - "resolved": "https://registry.npmjs.org/@nx/nx-freebsd-x64/-/nx-freebsd-x64-22.6.4.tgz", - "integrity": "sha512-qNsXhlflc77afjcRKCn7bqI8l/HPEjKhQRFs8wfKbAfNw3XEASc0EZtBV/TStLGV6PEZQldVBaId5FBMp8GW6Q==", + "version": "22.6.5", + "resolved": "https://registry.npmjs.org/@nx/nx-freebsd-x64/-/nx-freebsd-x64-22.6.5.tgz", + "integrity": "sha512-6B1wEKpqz5dI3AGMqttAVnA6M3DB/besAtuGyQiymK9ROlta1iuWgCcIYwcCQyhLn2Rx7vqj447KKcgCa8HlVw==", "cpu": [ "x64" ], @@ -10198,9 +9911,9 @@ ] }, "node_modules/@nx/nx-linux-arm-gnueabihf": { - "version": "22.6.4", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm-gnueabihf/-/nx-linux-arm-gnueabihf-22.6.4.tgz", - "integrity": "sha512-rjfnii0xGe8SQqsO/DDHeJSjbqp2H5fOEgZlaYXDGOwQeLZ1TQplEdx8hyI/ErAUwVO3YHnzoMtmachBQOlspw==", + "version": "22.6.5", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm-gnueabihf/-/nx-linux-arm-gnueabihf-22.6.5.tgz", + "integrity": "sha512-xV50B8mnDPboct7JkAHftajI02s+8FszA8WTzhore+YGR+lEKHTLpucwGEaQuMlSdLplH7pQix4B4uK5pcMhZw==", "cpu": [ "arm" ], @@ -10212,9 +9925,9 @@ ] }, "node_modules/@nx/nx-linux-arm64-gnu": { - "version": "22.6.4", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-gnu/-/nx-linux-arm64-gnu-22.6.4.tgz", - "integrity": "sha512-x6Zim1STewCXuHBCgoy2TO0586UlwH4RNCobn0mTiPd1jt7nU+fNqo3SpY8RzY1KmBfgcO48BBrfykPE9YWMpg==", + "version": "22.6.5", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-gnu/-/nx-linux-arm64-gnu-22.6.5.tgz", + "integrity": "sha512-2JkWuMGj+HpW6oPAvU5VdAx1afTnEbiM10Y3YOrl3fipWV4BiP5VDx762QTrfCraP4hl6yqTgvTe7F9xaby+jQ==", "cpu": [ "arm64" ], @@ -10226,9 +9939,9 @@ ] }, "node_modules/@nx/nx-linux-arm64-musl": { - "version": "22.6.4", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-musl/-/nx-linux-arm64-musl-22.6.4.tgz", - "integrity": "sha512-vYOqdgXIhtHFWdtnonp/jFfmfkyNPTu1JEdXuJpSxwUQdV2dWqS/l3HVPVWHXDrVKofPafK3M72jMvoWoaOQ6g==", + "version": "22.6.5", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-musl/-/nx-linux-arm64-musl-22.6.5.tgz", + "integrity": "sha512-Z/zMqFClnEyqDXouJKEPoWVhMQIif5F0YuECWBYjd3ZLwQsXGTItoh+6Wm3XF/nGMA2uLOHyTq/X7iFXQY3RzA==", "cpu": [ "arm64" ], @@ -10240,9 +9953,9 @@ ] }, "node_modules/@nx/nx-linux-x64-gnu": { - "version": "22.6.4", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-gnu/-/nx-linux-x64-gnu-22.6.4.tgz", - "integrity": "sha512-UfWUDlOzlvQNVa1mnqOFxzvUwoGfM2o9ruhwYRoFm3XJbVYnjINyQsdcHwwDJItJP04LZzLPxA1+O8sU+Oqg6A==", + "version": "22.6.5", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-gnu/-/nx-linux-x64-gnu-22.6.5.tgz", + "integrity": "sha512-FlotSyqNnaXSn0K+yWw+hRdYBwusABrPgKLyixfJIYRzsy+xPKN6pON6vZfqGwzuWF/9mEGReRz+iM8PiW0XSg==", "cpu": [ "x64" ], @@ -10254,9 +9967,9 @@ ] }, "node_modules/@nx/nx-linux-x64-musl": { - "version": "22.6.4", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-musl/-/nx-linux-x64-musl-22.6.4.tgz", - "integrity": "sha512-dwXpcyin4ScD5gH9FdhiNnOqFXclXLFBDTyRCEOlRUbOPayF9YEcH0PPIf9uWmwP3tshhAdr5sg9DLN+r7M3xg==", + "version": "22.6.5", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-musl/-/nx-linux-x64-musl-22.6.5.tgz", + "integrity": "sha512-RVOe2qcwhoIx6mxQURPjUfAW5SEOmT2gdhewvdcvX9ICq1hj5B2VarmkhTg0qroO7xiyqOqwq26mCzoV2I3NgQ==", "cpu": [ "x64" ], @@ -10268,9 +9981,9 @@ ] }, "node_modules/@nx/nx-win32-arm64-msvc": { - "version": "22.6.4", - "resolved": "https://registry.npmjs.org/@nx/nx-win32-arm64-msvc/-/nx-win32-arm64-msvc-22.6.4.tgz", - "integrity": "sha512-KqjJbFWhKJaKjET3Ep8hltXPizO0EstF4yfmp3oepWVn11poagc2MT1pf/tnRf6cdD88wd0bmw/83Ng6WUQ3Uw==", + "version": "22.6.5", + "resolved": "https://registry.npmjs.org/@nx/nx-win32-arm64-msvc/-/nx-win32-arm64-msvc-22.6.5.tgz", + "integrity": "sha512-ZqurqI8VuYnsr2Kn4K4t+Gx6j/BZdf6qz/6Tv4A7XQQ6oNYVQgTqoNEFj+CCkVaIe6aIdCWpousFLqs+ZgBqYQ==", "cpu": [ "arm64" ], @@ -10282,9 +9995,9 @@ ] }, "node_modules/@nx/nx-win32-x64-msvc": { - "version": "22.6.4", - "resolved": "https://registry.npmjs.org/@nx/nx-win32-x64-msvc/-/nx-win32-x64-msvc-22.6.4.tgz", - "integrity": "sha512-CIL9m6uilGGr/eU+41/+aVWUnEcq+j1EDynUX2A4InLTbAN0ylte4Af+72mvipNiqJgDkjKaNzOCQDnp8QBjEQ==", + "version": "22.6.5", + "resolved": "https://registry.npmjs.org/@nx/nx-win32-x64-msvc/-/nx-win32-x64-msvc-22.6.5.tgz", + "integrity": "sha512-i2QFBJIuaYg9BHxrrnBV4O7W9rVL2k0pSIdk/rRp3EYJEU93iUng+qbZiY9wh1xvmXuUCE2G7TRd+8/SG/RFKg==", "cpu": [ "x64" ], @@ -10296,16 +10009,16 @@ ] }, "node_modules/@nx/rspack": { - "version": "22.6.4", - "resolved": "https://registry.npmjs.org/@nx/rspack/-/rspack-22.6.4.tgz", - "integrity": "sha512-Q1SuqUmEeFhTdWUQsGymI9WXdWYASqfsazKStU8mBTBRCZin4hRBeQuFeohhBx8oyGiPJ+mf2nLPbF08WBGJhQ==", + "version": "22.6.5", + "resolved": "https://registry.npmjs.org/@nx/rspack/-/rspack-22.6.5.tgz", + "integrity": "sha512-ugjdD7OY4Cy7AcSlEJcfcfDWxev5PnVagb4FEEEutneITLz8hrBmQ+uY5cJg07Vsx6eauskpirkwRtXAQeHgFQ==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.6.4", - "@nx/js": "22.6.4", - "@nx/module-federation": "22.6.4", - "@nx/web": "22.6.4", + "@nx/devkit": "22.6.5", + "@nx/js": "22.6.5", + "@nx/module-federation": "22.6.5", + "@nx/web": "22.6.5", "@phenomnomnominal/tsquery": "~6.1.4", "@rspack/core": "1.6.8", "@rspack/dev-server": "^1.1.4", @@ -10417,146 +10130,6 @@ "@rspack/binding-win32-x64-msvc": "1.6.8" } }, - "node_modules/@nx/rspack/node_modules/@rspack/binding-darwin-arm64": { - "version": "1.6.8", - "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-arm64/-/binding-darwin-arm64-1.6.8.tgz", - "integrity": "sha512-e8CTQtzaeGnf+BIzR7wRMUwKfIg0jd/sxMRc1Vd0bCMHBhSN9EsGoMuJJaKeRrSmy2nwMCNWHIG+TvT1CEKg+A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@nx/rspack/node_modules/@rspack/binding-darwin-x64": { - "version": "1.6.8", - "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-x64/-/binding-darwin-x64-1.6.8.tgz", - "integrity": "sha512-ku1XpTEPt6Za11zhpFWhfwrTQogcgi9RJrOUVC4FESiPO9aKyd4hJ+JiPgLY0MZOqsptK6vEAgOip+uDVXrCpg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@nx/rspack/node_modules/@rspack/binding-linux-arm64-gnu": { - "version": "1.6.8", - "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.6.8.tgz", - "integrity": "sha512-fvZX6xZPvBT8qipSpvkKMX5M7yd2BSpZNCZXcefw6gA3uC7LI3gu+er0LrDXY1PtPzVuHTyDx+abwWpagV3PiQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@nx/rspack/node_modules/@rspack/binding-linux-arm64-musl": { - "version": "1.6.8", - "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.6.8.tgz", - "integrity": "sha512-++XMKcMNrt59HcFBLnRaJcn70k3X0GwkAegZBVpel8xYIAgvoXT5+L8P1ExId/yTFxqedaz8DbcxQnNmMozviw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@nx/rspack/node_modules/@rspack/binding-linux-x64-gnu": { - "version": "1.6.8", - "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.6.8.tgz", - "integrity": "sha512-tv3BWkTE1TndfX+DsE1rSTg8fBevCxujNZ3MlfZ22Wfy9x1FMXTJlWG8VIOXmaaJ1wUHzv8S7cE2YUUJ2LuiCg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@nx/rspack/node_modules/@rspack/binding-linux-x64-musl": { - "version": "1.6.8", - "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-musl/-/binding-linux-x64-musl-1.6.8.tgz", - "integrity": "sha512-DCGgZ5/in1O3FjHWqXnDsncRy+48cMhfuUAAUyl0yDj1NpsZu9pP+xfGLvGcQTiYrVl7IH9Aojf1eShP/77WGA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@nx/rspack/node_modules/@rspack/binding-wasm32-wasi": { - "version": "1.6.8", - "resolved": "https://registry.npmjs.org/@rspack/binding-wasm32-wasi/-/binding-wasm32-wasi-1.6.8.tgz", - "integrity": "sha512-VUwdhl/lI4m6o1OGCZ9JwtMjTV/yLY5VZTQdEPKb40JMTlmZ5MBlr5xk7ByaXXYHr6I+qnqEm73iMKQvg6iknw==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "1.0.7" - } - }, - "node_modules/@nx/rspack/node_modules/@rspack/binding-win32-arm64-msvc": { - "version": "1.6.8", - "resolved": "https://registry.npmjs.org/@rspack/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.6.8.tgz", - "integrity": "sha512-23YX7zlOZlub+nPGDBUzktb4D5D6ETUAluKjXEeHIZ9m7fSlEYBnGL66YE+3t1DHXGd0OqsdwlvrNGcyo6EXDQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@nx/rspack/node_modules/@rspack/binding-win32-ia32-msvc": { - "version": "1.6.8", - "resolved": "https://registry.npmjs.org/@rspack/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.6.8.tgz", - "integrity": "sha512-cFgRE3APxrY4AEdooVk2LtipwNNT/9mrnjdC5lVbsIsz+SxvGbZR231bxDJEqP15+RJOaD07FO1sIjINFqXMEg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@nx/rspack/node_modules/@rspack/binding-win32-x64-msvc": { - "version": "1.6.8", - "resolved": "https://registry.npmjs.org/@rspack/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.6.8.tgz", - "integrity": "sha512-cIuhVsZYd3o3Neo1JSAhJYw6BDvlxaBoqvgwRkG1rs0ExFmEmgYyG7ip9pFKnKNWph/tmW3rDYypmEfjs1is7g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@nx/rspack/node_modules/@rspack/core": { "version": "1.6.8", "resolved": "https://registry.npmjs.org/@rspack/core/-/core-1.6.8.tgz", @@ -10961,16 +10534,16 @@ } }, "node_modules/@nx/storybook": { - "version": "22.6.4", - "resolved": "https://registry.npmjs.org/@nx/storybook/-/storybook-22.6.4.tgz", - "integrity": "sha512-dmtSooGWuVMZ0ytl1eACbGoUSI+r/yYnMCshLzs0Ex/GuX0NRR9N5NSoFBMXLzC95zH34BhNBSWfyWxt7H5/ZQ==", + "version": "22.6.5", + "resolved": "https://registry.npmjs.org/@nx/storybook/-/storybook-22.6.5.tgz", + "integrity": "sha512-h0O8t7ir5hkqJhvT/g0E9KiILj3y9thZ7pOlB/Krc1G2GzYynSZJPElv//pibPhxtZl0Yk4AldkX9LqMkWtfFA==", "dev": true, "license": "MIT", "dependencies": { - "@nx/cypress": "22.6.4", - "@nx/devkit": "22.6.4", - "@nx/eslint": "22.6.4", - "@nx/js": "22.6.4", + "@nx/cypress": "22.6.5", + "@nx/devkit": "22.6.5", + "@nx/eslint": "22.6.5", + "@nx/js": "22.6.5", "@phenomnomnominal/tsquery": "~6.1.4", "semver": "^7.6.3", "tslib": "^2.3.0" @@ -10980,14 +10553,14 @@ } }, "node_modules/@nx/web": { - "version": "22.6.4", - "resolved": "https://registry.npmjs.org/@nx/web/-/web-22.6.4.tgz", - "integrity": "sha512-J1+2CS2YVu78clhFnAv9Zem4OvBDygSaP+SdEkEO50WQnv3mNQ6RuCoWXOnOPnp9aH4NmVaQfnG5dXbgpEkAkg==", + "version": "22.6.5", + "resolved": "https://registry.npmjs.org/@nx/web/-/web-22.6.5.tgz", + "integrity": "sha512-LjKPLWbgEI9FDIsMGqbW0tisVJfhme0EBi1kZfTi4cIu9Pna5nYkNBefD/d/DuK0ZrRqdONNjhRkCO3TcVbtIQ==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.6.4", - "@nx/js": "22.6.4", + "@nx/devkit": "22.6.5", + "@nx/js": "22.6.5", "detect-port": "^1.5.1", "http-server": "^14.1.0", "picocolors": "^1.1.0", @@ -10995,15 +10568,15 @@ } }, "node_modules/@nx/webpack": { - "version": "22.6.4", - "resolved": "https://registry.npmjs.org/@nx/webpack/-/webpack-22.6.4.tgz", - "integrity": "sha512-Cq17EWtsI6ihUpEnBMJ6ZWzBbLCheDiktH4mfjS6D0iDNTMWB5YeE62R8PfFJvl/GieHmZG8+FIpAtEyBJMfJw==", + "version": "22.6.5", + "resolved": "https://registry.npmjs.org/@nx/webpack/-/webpack-22.6.5.tgz", + "integrity": "sha512-LN75xxd/6U/r8vI3nzs/N5sj22nrJdBhTfDPlYlhKz2caCCWImSvQSXmprU46xNbXuYAY0DmRcZ5fkeqjHegtw==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.23.2", - "@nx/devkit": "22.6.4", - "@nx/js": "22.6.4", + "@nx/devkit": "22.6.5", + "@nx/js": "22.6.5", "@phenomnomnominal/tsquery": "~6.1.4", "ajv": "^8.12.0", "autoprefixer": "^10.4.9", @@ -11215,40 +10788,23 @@ } }, "node_modules/@nx/workspace": { - "version": "22.6.4", - "resolved": "https://registry.npmjs.org/@nx/workspace/-/workspace-22.6.4.tgz", - "integrity": "sha512-7t7sEjTjhINUC2aOze0K/hLuK6s6lyg+mH+/dSnkePfMVauZoodxhknHwZ3n50+lW9jyYEd7GqeiXn45TqpTpQ==", + "version": "22.6.5", + "resolved": "https://registry.npmjs.org/@nx/workspace/-/workspace-22.6.5.tgz", + "integrity": "sha512-/CZtv1ESSfZ1MVqSlCsmnBWysU1z5VdNlwANlqL6BV2X6RUHKDPVj4YuNPvCK+0LsqyzfJdUt3pcnBYxnT5TIg==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.6.4", + "@nx/devkit": "22.6.5", "@zkochan/js-yaml": "0.0.7", "chalk": "^4.1.0", "enquirer": "~2.3.6", - "nx": "22.6.4", + "nx": "22.6.5", "picomatch": "4.0.4", "semver": "^7.6.3", "tslib": "^2.3.0", "yargs-parser": "21.1.1" } }, - "node_modules/@nx/workspace/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/@openrouter/ai-sdk-provider": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/@openrouter/ai-sdk-provider/-/ai-sdk-provider-0.7.2.tgz", @@ -12467,6 +12023,323 @@ "@rspack/binding-win32-x64-msvc": "1.7.11" } }, + "node_modules/@rspack/binding-darwin-arm64": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-arm64/-/binding-darwin-arm64-1.6.8.tgz", + "integrity": "sha512-e8CTQtzaeGnf+BIzR7wRMUwKfIg0jd/sxMRc1Vd0bCMHBhSN9EsGoMuJJaKeRrSmy2nwMCNWHIG+TvT1CEKg+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rspack/binding-darwin-x64": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-x64/-/binding-darwin-x64-1.6.8.tgz", + "integrity": "sha512-ku1XpTEPt6Za11zhpFWhfwrTQogcgi9RJrOUVC4FESiPO9aKyd4hJ+JiPgLY0MZOqsptK6vEAgOip+uDVXrCpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rspack/binding-linux-arm64-gnu": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.6.8.tgz", + "integrity": "sha512-fvZX6xZPvBT8qipSpvkKMX5M7yd2BSpZNCZXcefw6gA3uC7LI3gu+er0LrDXY1PtPzVuHTyDx+abwWpagV3PiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-arm64-musl": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.6.8.tgz", + "integrity": "sha512-++XMKcMNrt59HcFBLnRaJcn70k3X0GwkAegZBVpel8xYIAgvoXT5+L8P1ExId/yTFxqedaz8DbcxQnNmMozviw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-x64-gnu": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.6.8.tgz", + "integrity": "sha512-tv3BWkTE1TndfX+DsE1rSTg8fBevCxujNZ3MlfZ22Wfy9x1FMXTJlWG8VIOXmaaJ1wUHzv8S7cE2YUUJ2LuiCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-x64-musl": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-musl/-/binding-linux-x64-musl-1.6.8.tgz", + "integrity": "sha512-DCGgZ5/in1O3FjHWqXnDsncRy+48cMhfuUAAUyl0yDj1NpsZu9pP+xfGLvGcQTiYrVl7IH9Aojf1eShP/77WGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-wasm32-wasi": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/@rspack/binding-wasm32-wasi/-/binding-wasm32-wasi-1.6.8.tgz", + "integrity": "sha512-VUwdhl/lI4m6o1OGCZ9JwtMjTV/yLY5VZTQdEPKb40JMTlmZ5MBlr5xk7ByaXXYHr6I+qnqEm73iMKQvg6iknw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "1.0.7" + } + }, + "node_modules/@rspack/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz", + "integrity": "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.5.0", + "@emnapi/runtime": "^1.5.0", + "@tybys/wasm-util": "^0.10.1" + } + }, + "node_modules/@rspack/binding-win32-arm64-msvc": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.6.8.tgz", + "integrity": "sha512-23YX7zlOZlub+nPGDBUzktb4D5D6ETUAluKjXEeHIZ9m7fSlEYBnGL66YE+3t1DHXGd0OqsdwlvrNGcyo6EXDQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rspack/binding-win32-ia32-msvc": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.6.8.tgz", + "integrity": "sha512-cFgRE3APxrY4AEdooVk2LtipwNNT/9mrnjdC5lVbsIsz+SxvGbZR231bxDJEqP15+RJOaD07FO1sIjINFqXMEg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rspack/binding-win32-x64-msvc": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.6.8.tgz", + "integrity": "sha512-cIuhVsZYd3o3Neo1JSAhJYw6BDvlxaBoqvgwRkG1rs0ExFmEmgYyG7ip9pFKnKNWph/tmW3rDYypmEfjs1is7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rspack/binding/node_modules/@napi-rs/wasm-runtime": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz", + "integrity": "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/core": "^1.5.0", + "@emnapi/runtime": "^1.5.0", + "@tybys/wasm-util": "^0.10.1" + } + }, + "node_modules/@rspack/binding/node_modules/@rspack/binding-darwin-arm64": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-arm64/-/binding-darwin-arm64-1.7.11.tgz", + "integrity": "sha512-oduECiZVqbO5zlVw+q7Vy65sJFth99fWPTyucwvLJJtJkPL5n17Uiql2cYP6Ijn0pkqtf1SXgK8WjiKLG5bIig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true + }, + "node_modules/@rspack/binding/node_modules/@rspack/binding-darwin-x64": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-x64/-/binding-darwin-x64-1.7.11.tgz", + "integrity": "sha512-a1+TtTE9ap6RalgFi7FGIgkJP6O4Vy6ctv+9WGJy53E4kuqHR0RygzaiVxCI/GMc/vBT9vY23hyrpWb3d1vtXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true + }, + "node_modules/@rspack/binding/node_modules/@rspack/binding-linux-arm64-gnu": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.7.11.tgz", + "integrity": "sha512-P0QrGRPbTWu6RKWfN0bDtbnEps3rXH0MWIMreZABoUrVmNQKtXR6e73J3ub6a+di5s2+K0M2LJ9Bh2/H4UsDUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rspack/binding/node_modules/@rspack/binding-linux-arm64-musl": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.7.11.tgz", + "integrity": "sha512-6ky7R43VMjWwmx3Yx7Jl7faLBBMAgMDt+/bN35RgwjiPgsIByz65EwytUVuW9rikB43BGHvA/eqlnjLrUzNBqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rspack/binding/node_modules/@rspack/binding-linux-x64-gnu": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.7.11.tgz", + "integrity": "sha512-cuOJMfCOvb2Wgsry5enXJ3iT1FGUjdPqtGUBVupQlEG4ntSYsQ2PtF4wIDVasR3wdxC5nQbipOrDiN/u6fYsdQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rspack/binding/node_modules/@rspack/binding-linux-x64-musl": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-musl/-/binding-linux-x64-musl-1.7.11.tgz", + "integrity": "sha512-CoK37hva4AmHGh3VCsQXmGr40L36m1/AdnN5LEjUX6kx5rEH7/1nEBN6Ii72pejqDVvk9anEROmPDiPw10tpFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/@rspack/binding/node_modules/@rspack/binding-wasm32-wasi": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-wasm32-wasi/-/binding-wasm32-wasi-1.7.11.tgz", + "integrity": "sha512-OtrmnPUVJMxjNa3eDMfHyPdtlLRmmp/aIm0fQHlAOATbZvlGm12q7rhPW5BXTu1yh+1rQ1/uqvz+SzKEZXuJaQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@napi-rs/wasm-runtime": "1.0.7" + } + }, + "node_modules/@rspack/binding/node_modules/@rspack/binding-win32-arm64-msvc": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.7.11.tgz", + "integrity": "sha512-lObFW6e5lCWNgTBNwT//yiEDbsxm9QG4BYUojqeXxothuzJ/L6ibXz6+gLMvbOvLGV3nKgkXmx8GvT9WDKR0mA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, + "node_modules/@rspack/binding/node_modules/@rspack/binding-win32-ia32-msvc": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.7.11.tgz", + "integrity": "sha512-0pYGnZd8PPqNR68zQ8skamqNAXEA1sUfXuAdYcknIIRq2wsbiwFzIc0Pov1cIfHYab37G7sSIPBiOUdOWF5Ivw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, + "node_modules/@rspack/binding/node_modules/@rspack/binding-win32-x64-msvc": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.7.11.tgz", + "integrity": "sha512-EeQXayoQk/uBkI3pdoXfQBXNIUrADq56L3s/DFyM2pJeUDrWmhfIw2UFIGkYPTMSCo8F2JcdcGM32FGJrSnU0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, "node_modules/@rspack/core": { "version": "1.7.11", "resolved": "https://registry.npmjs.org/@rspack/core/-/core-1.7.11.tgz", @@ -16235,13 +16108,13 @@ } }, "node_modules/adm-zip": { - "version": "0.5.17", - "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz", - "integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==", + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.10.tgz", + "integrity": "sha512-x0HvcHqVJNTPk/Bw8JbLWlWoo6Wwnsug0fnYYro1HBrjxZ3G7/AZk7Ahv8JwDe1uIcz8eBqvu86FuF1POiG7vQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=12.0" + "node": ">=6.0" } }, "node_modules/agent-base": { @@ -16688,16 +16561,6 @@ "dev": true, "license": "MIT" }, - "node_modules/at-least-node": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", - "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/autoprefixer": { "version": "10.4.27", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", @@ -16752,15 +16615,15 @@ } }, "node_modules/axios": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", - "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", "dev": true, "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "proxy-from-env": "^2.1.0" } }, "node_modules/axobject-query": { @@ -16795,23 +16658,6 @@ "@babel/core": "^7.11.0 || ^8.0.0-0" } }, - "node_modules/babel-jest/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/babel-loader": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-10.0.0.tgz", @@ -17742,9 +17588,9 @@ } }, "node_modules/chalk": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", - "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", "dependencies": { @@ -17752,7 +17598,10 @@ "supports-color": "^7.1.0" }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, "node_modules/change-case": { @@ -18952,6 +18801,554 @@ } } }, + "node_modules/css-minimizer-webpack-plugin/node_modules/cssnano": { + "version": "7.1.7", + "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.1.7.tgz", + "integrity": "sha512-N5LGn/OlhMxDTvKACwUPMzT34SSj1b022pvUAE/Vh6r2WD1aUCbc+QNIP/JjX9VVxebdJWZQ3352Lt4oF7dQ/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssnano-preset-default": "^7.0.15", + "lilconfig": "^3.1.3" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/cssnano" + }, + "peerDependencies": { + "postcss": "^8.5.10" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/cssnano-preset-default": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-7.0.15.tgz", + "integrity": "sha512-60kx7lJ40//HA85cIfQXSOJFby2D2V1pOMNHVCxue3KFWCjRzmiQyL9OvI+NAhwUlaojOfF9eK3nGvrJLCBUfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "css-declaration-sorter": "^7.2.0", + "cssnano-utils": "^5.0.2", + "postcss-calc": "^10.1.1", + "postcss-colormin": "^7.0.9", + "postcss-convert-values": "^7.0.11", + "postcss-discard-comments": "^7.0.7", + "postcss-discard-duplicates": "^7.0.3", + "postcss-discard-empty": "^7.0.2", + "postcss-discard-overridden": "^7.0.2", + "postcss-merge-longhand": "^7.0.6", + "postcss-merge-rules": "^7.0.10", + "postcss-minify-font-values": "^7.0.2", + "postcss-minify-gradients": "^7.0.4", + "postcss-minify-params": "^7.0.8", + "postcss-minify-selectors": "^7.1.0", + "postcss-normalize-charset": "^7.0.2", + "postcss-normalize-display-values": "^7.0.2", + "postcss-normalize-positions": "^7.0.3", + "postcss-normalize-repeat-style": "^7.0.3", + "postcss-normalize-string": "^7.0.2", + "postcss-normalize-timing-functions": "^7.0.2", + "postcss-normalize-unicode": "^7.0.8", + "postcss-normalize-url": "^7.0.2", + "postcss-normalize-whitespace": "^7.0.2", + "postcss-ordered-values": "^7.0.3", + "postcss-reduce-initial": "^7.0.8", + "postcss-reduce-transforms": "^7.0.2", + "postcss-svgo": "^7.1.2", + "postcss-unique-selectors": "^7.0.6" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.5.10" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/cssnano-utils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-5.0.2.tgz", + "integrity": "sha512-kt41WLK7FLKfePzPi645Y+/NtW/nNM7Su6nlNUfJyRNW3JcuU3JU7+cWJc+JexTeZ8dRBvFufefdG2XpXkIo0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.5.10" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-colormin": { + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-7.0.9.tgz", + "integrity": "sha512-EZpoUlmbXQUpe+g4ZaGM2kjGlHrQ7Bjzb3xHcNrC9ysI1tGoib6DAYvxg6aB7MGxsjgLF+Qx/jwZQkJ5cKDvXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@colordx/core": "^5.2.0", + "browserslist": "^4.28.2", + "caniuse-api": "^3.0.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.5.10" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-convert-values": { + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-7.0.11.tgz", + "integrity": "sha512-H+s7P0f9jJylSysAHs3/5MhAx7GthDO05uw1h56L2xyEqpiLTFLEqBNw3PUYzD5p/AKwWaigCXf6FGELpOw9lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.5.10" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-discard-comments": { + "version": "7.0.7", + "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-7.0.7.tgz", + "integrity": "sha512-FJhE3fSte7HaRNL4iwD8LTG9vWqj3puxXIdig6LfrFqc1TJRUhY4kXOkeTXZZfTXYny+k+SO7fd2fymj1wduJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^7.1.1" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.5.10" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-discard-duplicates": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-7.0.3.tgz", + "integrity": "sha512-9cRxXwhEM/aNZon1qZyToX4NmjbFbxOGbww+0CnbYFDbbPRGZ8jg4IbM8UlA+CzkXxM35itxyaHKNqBBg/RTDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.5.10" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-discard-empty": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-7.0.2.tgz", + "integrity": "sha512-NZFouOmOwtngJVgkNeI1LtkzFdYqIurxgy4wq3qNvIiXFURTZ3b/K7q3dP3QitlWQ5imHDQL0qSorItQhoxb1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.5.10" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-discard-overridden": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-7.0.2.tgz", + "integrity": "sha512-Ym01X4v6U3sY8X0P1J9P+RTar+7JyLTOzDrxKSeaArFsLmkVu4KcAKPBWDYRIyZ/q4jwpSPnOnekeSSqXSXKUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.5.10" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-merge-longhand": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-7.0.6.tgz", + "integrity": "sha512-lDsWeKRsssX/9vKFpingoRiuvGajtOGCJhs1kyaTJ5fzaVzs0aPPYe38UZ/ukMFEA5iuRIjQJHIkH2niYO3ubQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0", + "stylehacks": "^7.0.10" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.5.10" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-merge-rules": { + "version": "7.0.10", + "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-7.0.10.tgz", + "integrity": "sha512-UXYKxkg8Cy1so/evF7AE/25PNXZb3E0SrvjdbtbGf+MW+doLenKqRLQzz6YZW469ktiXK2MVLFWtel/DftCV0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-api": "^3.0.0", + "cssnano-utils": "^5.0.2", + "postcss-selector-parser": "^7.1.1" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.5.10" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-minify-font-values": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-7.0.2.tgz", + "integrity": "sha512-Z82NUmnvhPrvMUaHfkaAVBmWQq9F8Dox4Dy0LiwbaTxfmDUWLQtS+0WCgKViwdWCPPajiY9YzoQftgqKdXkM5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.5.10" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-minify-gradients": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-7.0.4.tgz", + "integrity": "sha512-g8MNeNyN+lbwKy8DCtJ6zU6awBL0InBsSOaKmgZ1MdRLVItLQUNFNAzzzBnOp4qowOcyyB6GetTlQ0/0UNXvag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@colordx/core": "^5.2.0", + "cssnano-utils": "^5.0.2", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.5.10" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-minify-params": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-7.0.8.tgz", + "integrity": "sha512-DIUKM5DZGTmxN7KFKT+rxt0FdPDmRrdK/k3n3+6Po+N/QYn06juwagHcfOVBG0CfCHwcnI612GAUCZc3eT+ZEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "cssnano-utils": "^5.0.2", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.5.10" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-minify-selectors": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-7.1.0.tgz", + "integrity": "sha512-HYl/6I0aL+UvpA10t65BSa7h+tVjBgE6oRI5N/3ylX3vtwvlDL67G3FT3vYDPnTksxr0riiyJcT0tBtyRVoloA==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-api": "^3.0.0", + "cssesc": "^3.0.0", + "postcss-selector-parser": "^7.1.1" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.5.10" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-normalize-charset": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-7.0.2.tgz", + "integrity": "sha512-YoINoiR4YKlzfB95Y93b0DSxWy7FLw+1SADIaznMHb88AKizpzfF80tolmiDEbYr1UM4r4Hw+NZq37SwT5f3uw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.5.10" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-normalize-display-values": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-7.0.2.tgz", + "integrity": "sha512-wu/NTSjnp8sX5TnEHVPN+eScjAtRs18ELtEduG+Ek3GxjeUDUT+VAA3PJjVIXBcVIk6fiLYFj2iKH0q99S3T2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.5.10" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-normalize-positions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-7.0.3.tgz", + "integrity": "sha512-1CJI++oA3yK/fQlPUcEngUfcSWS08Pkt9fK+jVgL53mmtHDBHi0YiuB0m3D9BXwZjmfvCc2GQmFqCAF/CVcPzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.5.10" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-normalize-repeat-style": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-7.0.3.tgz", + "integrity": "sha512-RvImJ2Ml4LZSx31qC2C8LDiz65IgBNATtwEr9r3Aue+D0cCGbj4rjNojb/uGpEm4QxnOTzFqMvaDYuKiT1Cmpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.5.10" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-normalize-string": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-7.0.2.tgz", + "integrity": "sha512-FqtrUh2BU2MnVeLeWBbJ2rwOjuDnA91XvoImc1BbgMWIxdxiPTaquflBHsmFBA3xh3pC3wPZO9W5MaIc7wU/Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.5.10" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-normalize-timing-functions": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-7.0.2.tgz", + "integrity": "sha512-5H5fpXBnMACEXzn7k9RP7qWZ1eWg8cuZkUuFygStY7icOj+UucwMWXeMmdkF/iITvTVa7fP85tdRCJeznpdFfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.5.10" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-normalize-unicode": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-7.0.8.tgz", + "integrity": "sha512-imCM3cwK3hvlAG4z1AzYM24m8BPA3/Jk/S71wfbn2I6+E2b+UwFaGvlNqydihXTSl3OFPeQXztqCzg+NGeSibQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.5.10" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-normalize-url": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-7.0.2.tgz", + "integrity": "sha512-bLnNY7t76NLRb9QQyCVmCN4qwoHxiq6vABH/CXav9wTuR6dNGHGQ72AyO/+h2quWxZk3l7BqxNL1vtDi9H6y1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.5.10" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-normalize-whitespace": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-7.0.2.tgz", + "integrity": "sha512-TNSVkuhkeOhl36WruQlflxOb7HweoeZowSusNpfsM1+ZvqJ24Mc+xksu05ecMQxlu+0zgI8pyznO2EWqDCQbLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.5.10" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-ordered-values": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-7.0.3.tgz", + "integrity": "sha512-FTt6R9RF7NAYfpOHa2XFPm89FVuo5GiIbcfwOXFy1MYF38BeiNW9ke8ybw9Pk62eEsUlRVVbxHWA3B7ERYqOOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssnano-utils": "^5.0.2", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.5.10" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-reduce-initial": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-7.0.8.tgz", + "integrity": "sha512-VeVRmbgpgTZuRcDQdqnsB4iYTeS2dBRV07UdwK6V3x61F1xTQ2pgIzHBIR4rQYRlXRNKBTGYYhEL1eNA7w9vaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-api": "^3.0.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.5.10" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-reduce-transforms": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-7.0.2.tgz", + "integrity": "sha512-OV5P9hMnf7kEkeXVXyS5ESqxbIls7a3TqFymUAV5JICO/9YCBEU+QQhQjZiDHaLwFdV7/CL481kVeBUk5FdY3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.5.10" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-svgo": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-7.1.2.tgz", + "integrity": "sha512-ixExc8m+/68yuSYQzV/1DgtTup/7nI2dN9eiDS5GMRUzeCH4q9UcqeZPwcSVhdf8ay9fRwXDUHwcY5/XzQSszQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0", + "svgo": "^4.0.1" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >= 18" + }, + "peerDependencies": { + "postcss": "^8.5.10" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-unique-selectors": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-7.0.6.tgz", + "integrity": "sha512-cDxnYw1QuBMW5w3svZ0BlYF0IA4Amr+1JoTLXzu6vDFPNwohN2QU+sPZNx15b930LR7ce+/600h28/cYoxO9vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^7.1.1" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.5.10" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/stylehacks": { + "version": "7.0.10", + "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-7.0.10.tgz", + "integrity": "sha512-sRJ7klmhe/Fl5woJcbJUa2qP1Ueffsl1CQI4ePvqXLkZmcIuAt09aP9uT/FOFPqXh9Rh8M5UkgEnwTdTKn/Aag==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "postcss-selector-parser": "^7.1.1" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.5.10" + } + }, "node_modules/css-select": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-6.0.0.tgz", @@ -19014,85 +19411,6 @@ "node": ">=4" } }, - "node_modules/cssnano": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.1.4.tgz", - "integrity": "sha512-T9PNS7y+5Nc9Qmu9mRONqfxG1RVY7Vuvky0XN6MZ+9hqplesTEwnj9r0ROtVuSwUVfaDhVlavuzWIVLUgm4hkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssnano-preset-default": "^7.0.12", - "lilconfig": "^3.1.3" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/cssnano" - }, - "peerDependencies": { - "postcss": "^8.4.32" - } - }, - "node_modules/cssnano-preset-default": { - "version": "7.0.12", - "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-7.0.12.tgz", - "integrity": "sha512-B3Eoouzw/sl2zANI0AL9KbacummJTCww+fkHaDBMZad/xuVx8bUduPLly6hKVQAlrmvYkS1jB1CVQEKm3gn0AA==", - "dev": true, - "license": "MIT", - "dependencies": { - "browserslist": "^4.28.1", - "css-declaration-sorter": "^7.2.0", - "cssnano-utils": "^5.0.1", - "postcss-calc": "^10.1.1", - "postcss-colormin": "^7.0.7", - "postcss-convert-values": "^7.0.9", - "postcss-discard-comments": "^7.0.6", - "postcss-discard-duplicates": "^7.0.2", - "postcss-discard-empty": "^7.0.1", - "postcss-discard-overridden": "^7.0.1", - "postcss-merge-longhand": "^7.0.5", - "postcss-merge-rules": "^7.0.8", - "postcss-minify-font-values": "^7.0.1", - "postcss-minify-gradients": "^7.0.2", - "postcss-minify-params": "^7.0.6", - "postcss-minify-selectors": "^7.0.6", - "postcss-normalize-charset": "^7.0.1", - "postcss-normalize-display-values": "^7.0.1", - "postcss-normalize-positions": "^7.0.1", - "postcss-normalize-repeat-style": "^7.0.1", - "postcss-normalize-string": "^7.0.1", - "postcss-normalize-timing-functions": "^7.0.1", - "postcss-normalize-unicode": "^7.0.6", - "postcss-normalize-url": "^7.0.1", - "postcss-normalize-whitespace": "^7.0.1", - "postcss-ordered-values": "^7.0.2", - "postcss-reduce-initial": "^7.0.6", - "postcss-reduce-transforms": "^7.0.1", - "postcss-svgo": "^7.1.1", - "postcss-unique-selectors": "^7.0.5" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.4.32" - } - }, - "node_modules/cssnano-utils": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-5.0.1.tgz", - "integrity": "sha512-ZIP71eQgG9JwjVZsTPSqhc6GHgEr53uJ7tK5///VfyWj6Xp2DBmixWHqJgPno+PqATzn48pL42ww9x5SSGmhZg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.4.32" - } - }, "node_modules/csso": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", @@ -19851,6 +20169,18 @@ "lodash-es": "^4.17.21" } }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 12" + } + }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -21285,23 +21615,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/eslint/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/eslint/node_modules/eslint-scope": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", @@ -21841,6 +22154,32 @@ } } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/fetch-mock-cache": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/fetch-mock-cache/-/fetch-mock-cache-2.3.1.tgz", @@ -22327,23 +22666,6 @@ "ajv": "^6.9.1" } }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/fork-ts-checker-webpack-plugin/node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -22482,6 +22804,21 @@ "node": ">= 0.6" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -22555,22 +22892,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/fs-minipass": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", @@ -23750,23 +24071,6 @@ "node": ">=12" } }, - "node_modules/http-server/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/http-status-codes": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", @@ -25008,23 +25312,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-changed-files/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/jest-changed-files/node_modules/jest-util": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", @@ -25107,23 +25394,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-circus/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/jest-circus/node_modules/cjs-module-lexer": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", @@ -25460,23 +25730,6 @@ "@babel/core": "^7.11.0 || ^8.0.0-beta.1" } }, - "node_modules/jest-cli/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/jest-cli/node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -25961,23 +26214,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jest-config/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/jest-config/node_modules/cjs-module-lexer": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", @@ -26175,23 +26411,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-diff/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/jest-docblock": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.2.0.tgz", @@ -26222,23 +26441,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-each/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/jest-environment-jsdom": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-30.2.0.tgz", @@ -26327,23 +26529,6 @@ "@sinonjs/commons": "^3.0.1" } }, - "node_modules/jest-environment-jsdom/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/jest-environment-jsdom/node_modules/data-urls": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", @@ -26621,23 +26806,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jest-environment-node/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/jest-environment-node/node_modules/jest-validate": { "version": "30.3.0", "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.3.0.tgz", @@ -26739,23 +26907,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-matcher-utils/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/jest-message-util": { "version": "30.3.0", "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.3.0.tgz", @@ -26777,23 +26928,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-message-util/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/jest-mock": { "version": "30.3.0", "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.3.0.tgz", @@ -26914,23 +27048,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jest-resolve/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/jest-resolve/node_modules/jest-validate": { "version": "30.3.0", "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.3.0.tgz", @@ -27107,23 +27224,6 @@ "@sinonjs/commons": "^3.0.1" } }, - "node_modules/jest-runner/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/jest-runner/node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -27489,23 +27589,6 @@ "@sinonjs/commons": "^3.0.1" } }, - "node_modules/jest-runtime/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/jest-runtime/node_modules/cjs-module-lexer": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", @@ -27782,23 +27865,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-snapshot/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/jest-snapshot/node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -28014,23 +28080,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-util/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/jest-validate": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.2.0.tgz", @@ -28081,23 +28130,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jest-validate/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/jest-validate/node_modules/pretty-format": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", @@ -28199,23 +28231,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-watcher/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/jest-watcher/node_modules/jest-message-util": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", @@ -28335,23 +28350,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/jiti": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", @@ -30348,6 +30346,29 @@ "license": "MIT", "optional": true }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-exports-info": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", @@ -30377,6 +30398,27 @@ "semver": "bin/semver.js" } }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/node-fetch-native": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", @@ -30657,25 +30699,24 @@ "license": "MIT" }, "node_modules/nx": { - "version": "22.6.4", - "resolved": "https://registry.npmjs.org/nx/-/nx-22.6.4.tgz", - "integrity": "sha512-WEaCnLKeO9RhQAOBMfXgYO/Lx5wL4ARCtRGiYCjJtAJIZ5kcVn4uPKL2Xz1xekpF7ef/+YNrUQSrblx47Ms9Rg==", + "version": "22.6.5", + "resolved": "https://registry.npmjs.org/nx/-/nx-22.6.5.tgz", + "integrity": "sha512-VRKhDAt684dXNSz9MNjE7MekkCfQF41P2PSx5jEWQjDEP1Z4jFZbyeygWs5ZyOroG7/n0MoWAJTe6ftvIcBOAg==", "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { - "@ltd/j-toml": "^1.38.0", "@napi-rs/wasm-runtime": "0.2.4", "@yarnpkg/lockfile": "^1.1.0", "@yarnpkg/parsers": "3.0.2", "@zkochan/js-yaml": "0.0.7", - "axios": "1.12.0", + "axios": "1.15.0", "cli-cursor": "3.1.0", "cli-spinners": "2.6.1", "cliui": "^8.0.1", "dotenv": "~16.4.5", "dotenv-expand": "~11.0.6", - "ejs": "^3.1.7", + "ejs": "5.0.1", "enquirer": "~2.3.6", "figures": "3.2.0", "flat": "^5.0.2", @@ -30691,6 +30732,7 @@ "picocolors": "^1.1.0", "resolve.exports": "2.0.3", "semver": "^7.6.3", + "smol-toml": "1.6.1", "string-width": "^4.2.3", "tar-stream": "~2.2.0", "tmp": "~0.2.1", @@ -30706,16 +30748,16 @@ "nx-cloud": "bin/nx-cloud.js" }, "optionalDependencies": { - "@nx/nx-darwin-arm64": "22.6.4", - "@nx/nx-darwin-x64": "22.6.4", - "@nx/nx-freebsd-x64": "22.6.4", - "@nx/nx-linux-arm-gnueabihf": "22.6.4", - "@nx/nx-linux-arm64-gnu": "22.6.4", - "@nx/nx-linux-arm64-musl": "22.6.4", - "@nx/nx-linux-x64-gnu": "22.6.4", - "@nx/nx-linux-x64-musl": "22.6.4", - "@nx/nx-win32-arm64-msvc": "22.6.4", - "@nx/nx-win32-x64-msvc": "22.6.4" + "@nx/nx-darwin-arm64": "22.6.5", + "@nx/nx-darwin-x64": "22.6.5", + "@nx/nx-freebsd-x64": "22.6.5", + "@nx/nx-linux-arm-gnueabihf": "22.6.5", + "@nx/nx-linux-arm64-gnu": "22.6.5", + "@nx/nx-linux-arm64-musl": "22.6.5", + "@nx/nx-linux-x64-gnu": "22.6.5", + "@nx/nx-linux-x64-musl": "22.6.5", + "@nx/nx-win32-arm64-msvc": "22.6.5", + "@nx/nx-win32-x64-msvc": "22.6.5" }, "peerDependencies": { "@swc-node/register": "^1.11.1", @@ -30730,40 +30772,6 @@ } } }, - "node_modules/nx/node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.4.tgz", - "integrity": "sha512-9zESzOO5aDByvhIAsOy9TbpZ0Ur2AJbUI7UT73kcUTS2mxAMHOBaa1st/jAymNoCtvrit99kkzT1FZuXVcgfIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@emnapi/core": "^1.1.0", - "@emnapi/runtime": "^1.1.0", - "@tybys/wasm-util": "^0.9.0" - } - }, - "node_modules/nx/node_modules/@tybys/wasm-util": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", - "integrity": "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/nx/node_modules/axios": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz", - "integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==", - "dev": true, - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", - "proxy-from-env": "^1.1.0" - } - }, "node_modules/nx/node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -30787,23 +30795,6 @@ "node": "18 || 20 || >=22" } }, - "node_modules/nx/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/nx/node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -30856,6 +30847,19 @@ "url": "https://dotenvx.com" } }, + "node_modules/nx/node_modules/ejs": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-5.0.1.tgz", + "integrity": "sha512-COqBPFMxuPTPspXl2DkVYaDS3HtrD1GpzOGkNTJ1IYkifq/r9h8SVEFrjA3D9/VJGOEoMQcrlhpntcSUrM8k6A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.12.18" + } + }, "node_modules/nx/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -32355,97 +32359,6 @@ "postcss": "^8.4.38" } }, - "node_modules/postcss-colormin": { - "version": "7.0.7", - "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-7.0.7.tgz", - "integrity": "sha512-sBQ628lSj3VQpDquQel8Pen5mmjFPsO4pH9lDLaHB1AVkMRHtkl0pRB5DCWznc9upWsxint/kV+AveSj7W1tew==", - "dev": true, - "license": "MIT", - "dependencies": { - "@colordx/core": "^5.0.0", - "browserslist": "^4.28.1", - "caniuse-api": "^3.0.0", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.4.32" - } - }, - "node_modules/postcss-convert-values": { - "version": "7.0.9", - "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-7.0.9.tgz", - "integrity": "sha512-l6uATQATZaCa0bckHV+r6dLXfWtUBKXxO3jK+AtxxJJtgMPD+VhhPCCx51I4/5w8U5uHV67g3w7PXj+V3wlMlg==", - "dev": true, - "license": "MIT", - "dependencies": { - "browserslist": "^4.28.1", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.4.32" - } - }, - "node_modules/postcss-discard-comments": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-7.0.6.tgz", - "integrity": "sha512-Sq+Fzj1Eg5/CPf1ERb0wS1Im5cvE2gDXCE+si4HCn1sf+jpQZxDI4DXEp8t77B/ImzDceWE2ebJQFXdqZ6GRJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^7.1.1" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.4.32" - } - }, - "node_modules/postcss-discard-duplicates": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-7.0.2.tgz", - "integrity": "sha512-eTonaQvPZ/3i1ASDHOKkYwAybiM45zFIc7KXils4mQmHLqIswXD9XNOKEVxtTFnsmwYzF66u4LMgSr0abDlh5w==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.4.32" - } - }, - "node_modules/postcss-discard-empty": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-7.0.1.tgz", - "integrity": "sha512-cFrJKZvcg/uxB6Ijr4l6qmn3pXQBna9zyrPC+sK0zjbkDUZew+6xDltSF7OeB7rAtzaaMVYSdbod+sZOCWnMOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.4.32" - } - }, - "node_modules/postcss-discard-overridden": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-7.0.1.tgz", - "integrity": "sha512-7c3MMjjSZ/qYrx3uc1940GSOzN1Iqjtlqe8uoSg+qdVPYyRb0TILSqqmtlSFuE4mTDECwsm397Ya7iXGzfF7lg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.4.32" - } - }, "node_modules/postcss-import": { "version": "14.1.0", "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-14.1.0.tgz", @@ -32539,111 +32452,6 @@ "integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==", "license": "MIT" }, - "node_modules/postcss-merge-longhand": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-7.0.5.tgz", - "integrity": "sha512-Kpu5v4Ys6QI59FxmxtNB/iHUVDn9Y9sYw66D6+SZoIk4QTz1prC4aYkhIESu+ieG1iylod1f8MILMs1Em3mmIw==", - "dev": true, - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.2.0", - "stylehacks": "^7.0.5" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.4.32" - } - }, - "node_modules/postcss-merge-rules": { - "version": "7.0.8", - "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-7.0.8.tgz", - "integrity": "sha512-BOR1iAM8jnr7zoQSlpeBmCsWV5Uudi/+5j7k05D0O/WP3+OFMPD86c1j/20xiuRtyt45bhxw/7hnhZNhW2mNFA==", - "dev": true, - "license": "MIT", - "dependencies": { - "browserslist": "^4.28.1", - "caniuse-api": "^3.0.0", - "cssnano-utils": "^5.0.1", - "postcss-selector-parser": "^7.1.1" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.4.32" - } - }, - "node_modules/postcss-minify-font-values": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-7.0.1.tgz", - "integrity": "sha512-2m1uiuJeTplll+tq4ENOQSzB8LRnSUChBv7oSyFLsJRtUgAAJGP6LLz0/8lkinTgxrmJSPOEhgY1bMXOQ4ZXhQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.4.32" - } - }, - "node_modules/postcss-minify-gradients": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-7.0.2.tgz", - "integrity": "sha512-fVY3AB8Um7SJR5usHqTY2Ngf9qh8IRN+FFzrBP0ONJy6yYXsP7xyjK2BvSAIrpgs1cST+H91V0TXi3diHLYJtw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@colordx/core": "^5.0.0", - "cssnano-utils": "^5.0.1", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.4.32" - } - }, - "node_modules/postcss-minify-params": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-7.0.6.tgz", - "integrity": "sha512-YOn02gC68JijlaXVuKvFSCvQOhTpblkcfDre2hb/Aaa58r2BIaK4AtE/cyZf2wV7YKAG+UlP9DT+By0ry1E4VQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "browserslist": "^4.28.1", - "cssnano-utils": "^5.0.1", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.4.32" - } - }, - "node_modules/postcss-minify-selectors": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-7.0.6.tgz", - "integrity": "sha512-lIbC0jy3AAwDxEgciZlBullDiMBeBCT+fz5G8RcA9MWqh/hfUkpOI3vNDUNEZHgokaoiv0juB9Y8fGcON7rU/A==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "postcss-selector-parser": "^7.1.1" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.4.32" - } - }, "node_modules/postcss-modules-extract-imports": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", @@ -32707,198 +32515,6 @@ "postcss": "^8.1.0" } }, - "node_modules/postcss-normalize-charset": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-7.0.1.tgz", - "integrity": "sha512-sn413ofhSQHlZFae//m9FTOfkmiZ+YQXsbosqOWRiVQncU2BA3daX3n0VF3cG6rGLSFVc5Di/yns0dFfh8NFgQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.4.32" - } - }, - "node_modules/postcss-normalize-display-values": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-7.0.1.tgz", - "integrity": "sha512-E5nnB26XjSYz/mGITm6JgiDpAbVuAkzXwLzRZtts19jHDUBFxZ0BkXAehy0uimrOjYJbocby4FVswA/5noOxrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.4.32" - } - }, - "node_modules/postcss-normalize-positions": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-7.0.1.tgz", - "integrity": "sha512-pB/SzrIP2l50ZIYu+yQZyMNmnAcwyYb9R1fVWPRxm4zcUFCY2ign7rcntGFuMXDdd9L2pPNUgoODDk91PzRZuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.4.32" - } - }, - "node_modules/postcss-normalize-repeat-style": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-7.0.1.tgz", - "integrity": "sha512-NsSQJ8zj8TIDiF0ig44Byo3Jk9e4gNt9x2VIlJudnQQ5DhWAHJPF4Tr1ITwyHio2BUi/I6Iv0HRO7beHYOloYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.4.32" - } - }, - "node_modules/postcss-normalize-string": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-7.0.1.tgz", - "integrity": "sha512-QByrI7hAhsoze992kpbMlJSbZ8FuCEc1OT9EFbZ6HldXNpsdpZr+YXC5di3UEv0+jeZlHbZcoCADgb7a+lPmmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.4.32" - } - }, - "node_modules/postcss-normalize-timing-functions": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-7.0.1.tgz", - "integrity": "sha512-bHifyuuSNdKKsnNJ0s8fmfLMlvsQwYVxIoUBnowIVl2ZAdrkYQNGVB4RxjfpvkMjipqvbz0u7feBZybkl/6NJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.4.32" - } - }, - "node_modules/postcss-normalize-unicode": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-7.0.6.tgz", - "integrity": "sha512-z6bwTV84YW6ZvvNoaNLuzRW4/uWxDKYI1iIDrzk6D2YTL7hICApy+Q1LP6vBEsljX8FM7YSuV9qI79XESd4ddQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "browserslist": "^4.28.1", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.4.32" - } - }, - "node_modules/postcss-normalize-url": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-7.0.1.tgz", - "integrity": "sha512-sUcD2cWtyK1AOL/82Fwy1aIVm/wwj5SdZkgZ3QiUzSzQQofrbq15jWJ3BA7Z+yVRwamCjJgZJN0I9IS7c6tgeQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.4.32" - } - }, - "node_modules/postcss-normalize-whitespace": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-7.0.1.tgz", - "integrity": "sha512-vsbgFHMFQrJBJKrUFJNZ2pgBeBkC2IvvoHjz1to0/0Xk7sII24T0qFOiJzG6Fu3zJoq/0yI4rKWi7WhApW+EFA==", - "dev": true, - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.4.32" - } - }, - "node_modules/postcss-ordered-values": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-7.0.2.tgz", - "integrity": "sha512-AMJjt1ECBffF7CEON/Y0rekRLS6KsePU6PRP08UqYW4UGFRnTXNrByUzYK1h8AC7UWTZdQ9O3Oq9kFIhm0SFEw==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssnano-utils": "^5.0.1", - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.4.32" - } - }, - "node_modules/postcss-reduce-initial": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-7.0.6.tgz", - "integrity": "sha512-G6ZyK68AmrPdMB6wyeA37ejnnRG2S8xinJrZJnOv+IaRKf6koPAVbQsiC7MfkmXaGmF1UO+QCijb27wfpxuRNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "browserslist": "^4.28.1", - "caniuse-api": "^3.0.0" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.4.32" - } - }, - "node_modules/postcss-reduce-transforms": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-7.0.1.tgz", - "integrity": "sha512-MhyEbfrm+Mlp/36hvZ9mT9DaO7dbncU0CvWI8V93LRkY6IYlu38OPg3FObnuKTUxJ4qA8HpurdQOo5CyqqO76g==", - "dev": true, - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.4.32" - } - }, "node_modules/postcss-safe-parser": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", @@ -32939,39 +32555,6 @@ "node": ">=4" } }, - "node_modules/postcss-svgo": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-7.1.1.tgz", - "integrity": "sha512-zU9H9oEDrUFKa0JB7w+IYL7Qs9ey1mZyjhbf0KLxwJDdDRtoPvCmaEfknzqfHj44QS9VD6c5sJnBAVYTLRg/Sg==", - "dev": true, - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.2.0", - "svgo": "^4.0.1" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >= 18" - }, - "peerDependencies": { - "postcss": "^8.4.32" - } - }, - "node_modules/postcss-unique-selectors": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-7.0.5.tgz", - "integrity": "sha512-3QoYmEt4qg/rUWDn6Tc8+ZVPmbp4G1hXDtCNWDx0st8SjtCbRcxRXDDM1QrEiXGG3A45zscSJFb4QH90LViyxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^7.1.1" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.4.32" - } - }, "node_modules/postcss-value-parser": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", @@ -33170,11 +32753,14 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/prr": { "version": "1.0.1", @@ -35700,6 +35286,19 @@ "npm": ">= 3.0.0" } }, + "node_modules/smol-toml": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", + "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/sockjs": { "version": "0.3.24", "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", @@ -36371,23 +35970,6 @@ "webpack": "^5.0.0" } }, - "node_modules/stylehacks": { - "version": "7.0.8", - "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-7.0.8.tgz", - "integrity": "sha512-I3f053GBLIiS5Fg6OMFhq/c+yW+5Hc2+1fgq7gElDMMSqwlRb3tBf2ef6ucLStYRpId4q//bQO1FjcyNyy4yDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "browserslist": "^4.28.1", - "postcss-selector-parser": "^7.1.1" - }, - "engines": { - "node": "^18.12.0 || ^20.9.0 || >=22.0" - }, - "peerDependencies": { - "postcss": "^8.4.32" - } - }, "node_modules/stylis": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", @@ -37223,6 +36805,303 @@ } } }, + "node_modules/ts-checker-rspack-plugin/node_modules/@jsonjoy.com/base64": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-17.67.0.tgz", + "integrity": "sha512-5SEsJGsm15aP8TQGkDfJvz9axgPwAEm98S5DxOuYe8e1EbfajcDmgeXXzccEjh+mLnjqEKrkBdjHWS5vFNwDdw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/ts-checker-rspack-plugin/node_modules/@jsonjoy.com/codegen": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-17.67.0.tgz", + "integrity": "sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/ts-checker-rspack-plugin/node_modules/@jsonjoy.com/fs-core": { + "version": "4.57.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-core/-/fs-core-4.57.2.tgz", + "integrity": "sha512-SVjwklkpIV5wrynpYtuYnfYH1QF4/nDuLBX7VXdb+3miglcAgBVZb/5y0cOsehRV/9Vb+3UqhkMq3/NR3ztdkQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-node-builtins": "4.57.2", + "@jsonjoy.com/fs-node-utils": "4.57.2", + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/ts-checker-rspack-plugin/node_modules/@jsonjoy.com/fs-fsa": { + "version": "4.57.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-fsa/-/fs-fsa-4.57.2.tgz", + "integrity": "sha512-fhO8+iR2I+OCw668ISDJdn1aArc9zx033sWejIyzQ8RBeXa9bDSaUeA3ix0poYOfrj1KdOzytmYNv2/uLDfV6g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-core": "4.57.2", + "@jsonjoy.com/fs-node-builtins": "4.57.2", + "@jsonjoy.com/fs-node-utils": "4.57.2", + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/ts-checker-rspack-plugin/node_modules/@jsonjoy.com/fs-node": { + "version": "4.57.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node/-/fs-node-4.57.2.tgz", + "integrity": "sha512-nX2AdL6cOFwLdju9G4/nbRnYevmCJbh7N7hvR3gGm97Cs60uEjyd0rpR+YBS7cTg175zzl22pGKXR5USaQMvKg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-core": "4.57.2", + "@jsonjoy.com/fs-node-builtins": "4.57.2", + "@jsonjoy.com/fs-node-utils": "4.57.2", + "@jsonjoy.com/fs-print": "4.57.2", + "@jsonjoy.com/fs-snapshot": "4.57.2", + "glob-to-regex.js": "^1.0.0", + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/ts-checker-rspack-plugin/node_modules/@jsonjoy.com/fs-node-builtins": { + "version": "4.57.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.57.2.tgz", + "integrity": "sha512-xhiegylRmhw43Ki2HO1ZBL7DQ5ja/qpRsL29VtQ2xuUHiuDGbgf2uD4p9Qd8hJI5P6RCtGYD50IXHXVq/Ocjcg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/ts-checker-rspack-plugin/node_modules/@jsonjoy.com/fs-node-to-fsa": { + "version": "4.57.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-to-fsa/-/fs-node-to-fsa-4.57.2.tgz", + "integrity": "sha512-18LmWTSONhoAPW+IWRuf8w/+zRolPFGPeGwMxlAhhfY11EKzX+5XHDBPAw67dBF5dxDErHJbl40U+3IXSDRXSQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-fsa": "4.57.2", + "@jsonjoy.com/fs-node-builtins": "4.57.2", + "@jsonjoy.com/fs-node-utils": "4.57.2" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/ts-checker-rspack-plugin/node_modules/@jsonjoy.com/fs-node-utils": { + "version": "4.57.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.57.2.tgz", + "integrity": "sha512-rsPSJgekz43IlNbLyAM/Ab+ouYLWGp5DDBfYBNNEqDaSpsbXfthBn29Q4muFA9L0F+Z3mKo+CWlgSCXrf+mOyQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-node-builtins": "4.57.2" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/ts-checker-rspack-plugin/node_modules/@jsonjoy.com/fs-print": { + "version": "4.57.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-print/-/fs-print-4.57.2.tgz", + "integrity": "sha512-wK9NSow48i4DbDl9F1CQE5TqnyZOJ04elU3WFG5aJ76p+YxO/ulyBBQvKsessPxdo381Bc2pcEoyPujMOhcRqQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-node-utils": "4.57.2", + "tree-dump": "^1.1.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/ts-checker-rspack-plugin/node_modules/@jsonjoy.com/fs-snapshot": { + "version": "4.57.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.57.2.tgz", + "integrity": "sha512-GdduDZuoP5V/QCgJkx9+BZ6SC0EZ/smXAdTS7PfMqgMTGXLlt/bH/FqMYaqB9JmLf05sJPtO0XRbAwwkEEPbVw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "^17.65.0", + "@jsonjoy.com/fs-node-utils": "4.57.2", + "@jsonjoy.com/json-pack": "^17.65.0", + "@jsonjoy.com/util": "^17.65.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/ts-checker-rspack-plugin/node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/json-pack": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-17.67.0.tgz", + "integrity": "sha512-t0ejURcGaZsn1ClbJ/3kFqSOjlryd92eQY465IYrezsXmPcfHPE/av4twRSxf6WE+TkZgLY+71vCZbiIiFKA/w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/base64": "17.67.0", + "@jsonjoy.com/buffers": "17.67.0", + "@jsonjoy.com/codegen": "17.67.0", + "@jsonjoy.com/json-pointer": "17.67.0", + "@jsonjoy.com/util": "17.67.0", + "hyperdyperid": "^1.2.0", + "thingies": "^2.5.0", + "tree-dump": "^1.1.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/ts-checker-rspack-plugin/node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/util": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-17.67.0.tgz", + "integrity": "sha512-6+8xBaz1rLSohlGh68D1pdw3AwDi9xydm8QNlAFkvnavCJYSze+pxoW2VKP8p308jtlMRLs5NTHfPlZLd4w7ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "17.67.0", + "@jsonjoy.com/codegen": "17.67.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/ts-checker-rspack-plugin/node_modules/@jsonjoy.com/json-pointer": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-17.67.0.tgz", + "integrity": "sha512-+iqOFInH+QZGmSuaybBUNdh7yvNrXvqR+h3wjXm0N/3JK1EyyFAeGJvqnmQL61d1ARLlk/wJdFKSL+LHJ1eaUA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/util": "17.67.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/ts-checker-rspack-plugin/node_modules/@jsonjoy.com/json-pointer/node_modules/@jsonjoy.com/util": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-17.67.0.tgz", + "integrity": "sha512-6+8xBaz1rLSohlGh68D1pdw3AwDi9xydm8QNlAFkvnavCJYSze+pxoW2VKP8p308jtlMRLs5NTHfPlZLd4w7ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "17.67.0", + "@jsonjoy.com/codegen": "17.67.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, "node_modules/ts-checker-rspack-plugin/node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -37262,20 +37141,20 @@ } }, "node_modules/ts-checker-rspack-plugin/node_modules/memfs": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.57.1.tgz", - "integrity": "sha512-WvzrWPwMQT+PtbX2Et64R4qXKK0fj/8pO85MrUCzymX3twwCiJCdvntW3HdhG1teLJcHDDLIKx5+c3HckWYZtQ==", + "version": "4.57.2", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.57.2.tgz", + "integrity": "sha512-2nWzSsJzrukurSDna4Z0WywuScK4Id3tSKejgu74u8KCdW4uNrseKRSIDg75C6Yw5ZRqBe0F0EtMNlTbUq8bAQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-core": "4.57.1", - "@jsonjoy.com/fs-fsa": "4.57.1", - "@jsonjoy.com/fs-node": "4.57.1", - "@jsonjoy.com/fs-node-builtins": "4.57.1", - "@jsonjoy.com/fs-node-to-fsa": "4.57.1", - "@jsonjoy.com/fs-node-utils": "4.57.1", - "@jsonjoy.com/fs-print": "4.57.1", - "@jsonjoy.com/fs-snapshot": "4.57.1", + "@jsonjoy.com/fs-core": "4.57.2", + "@jsonjoy.com/fs-fsa": "4.57.2", + "@jsonjoy.com/fs-node": "4.57.2", + "@jsonjoy.com/fs-node-builtins": "4.57.2", + "@jsonjoy.com/fs-node-to-fsa": "4.57.2", + "@jsonjoy.com/fs-node-utils": "4.57.2", + "@jsonjoy.com/fs-print": "4.57.2", + "@jsonjoy.com/fs-snapshot": "4.57.2", "@jsonjoy.com/json-pack": "^1.11.0", "@jsonjoy.com/util": "^1.9.0", "glob-to-regex.js": "^1.0.1", @@ -37414,23 +37293,6 @@ "webpack": "^5.0.0" } }, - "node_modules/ts-loader/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/ts-node": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", @@ -37504,23 +37366,6 @@ "node": ">=10.13.0" } }, - "node_modules/tsconfig-paths-webpack-plugin/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/tsconfig-paths-webpack-plugin/node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -38336,6 +38181,18 @@ "license": "MIT", "optional": true }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 8" + } + }, "node_modules/webidl-conversions": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", diff --git a/package.json b/package.json index 9b55901c2..55ccfaf70 100644 --- a/package.json +++ b/package.json @@ -157,16 +157,16 @@ "@eslint/js": "9.35.0", "@nestjs/schematics": "11.0.9", "@nestjs/testing": "11.1.14", - "@nx/angular": "22.6.4", - "@nx/eslint-plugin": "22.6.4", - "@nx/jest": "22.6.4", - "@nx/js": "22.6.4", - "@nx/module-federation": "22.6.4", - "@nx/nest": "22.6.4", - "@nx/node": "22.6.4", - "@nx/storybook": "22.6.4", - "@nx/web": "22.6.4", - "@nx/workspace": "22.6.4", + "@nx/angular": "22.6.5", + "@nx/eslint-plugin": "22.6.5", + "@nx/jest": "22.6.5", + "@nx/js": "22.6.5", + "@nx/module-federation": "22.6.5", + "@nx/nest": "22.6.5", + "@nx/node": "22.6.5", + "@nx/storybook": "22.6.5", + "@nx/web": "22.6.5", + "@nx/workspace": "22.6.5", "@schematics/angular": "21.2.6", "@storybook/addon-docs": "10.1.10", "@storybook/angular": "10.1.10", @@ -192,7 +192,7 @@ "jest": "30.2.0", "jest-environment-jsdom": "30.2.0", "jest-preset-angular": "16.0.0", - "nx": "22.6.4", + "nx": "22.6.5", "prettier": "3.8.2", "prettier-plugin-organize-attributes": "1.0.0", "prisma": "6.19.3", From a75a5dd3cdba8a412284d049252c5a1a7aa72bc3 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:52:32 +0200 Subject: [PATCH 007/132] Task/refresh cryptocurrencies list (20260421) (#6758) * Update cryptocurrencies.json * Update changelog --- CHANGELOG.md | 1 + .../cryptocurrencies/cryptocurrencies.json | 109 ++++++++++++++---- 2 files changed, 87 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 792b0cc38..cf874d1ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Refreshed the cryptocurrencies list - Upgraded `countup.js` from version `2.9.0` to `2.10.0` - Upgraded `jsonpath` from version `1.2.1` to `1.3.0` - Upgraded `Nx` from version `22.6.4` to `22.6.5` diff --git a/apps/api/src/assets/cryptocurrencies/cryptocurrencies.json b/apps/api/src/assets/cryptocurrencies/cryptocurrencies.json index 96502ca05..7ca2d568f 100644 --- a/apps/api/src/assets/cryptocurrencies/cryptocurrencies.json +++ b/apps/api/src/assets/cryptocurrencies/cryptocurrencies.json @@ -40,6 +40,7 @@ "0NE": "Stone", "0X0": "0x0.ai", "0X1": "0x1.tools: AI Multi-tool Plaform", + "0X63SPIKE": "Spike", "0XBTC": "0xBitcoin", "0XCOCO": "0xCoco", "0XDEV": "DEVAI", @@ -773,7 +774,7 @@ "ANALY": "Analysoor", "ANARCHISTS": "Anarchists Prime", "ANAT": "Anatolia Token", - "ANB": "Angryb", + "ANB": "Ant BlockChain", "ANC": "Anchor Protocol", "ANCHOR": "AnchorSwap", "ANCIENTKING": "Ancient Kingdom", @@ -798,6 +799,7 @@ "ANGLE": "ANGLE", "ANGO": "Aureus Nummus Gold", "ANGRYSLERF": "ANGRYSLERF", + "ANGRYTOKEN": "Angryb", "ANI": "Ani Grok Companion (anicompanion.net)", "ANIM": "Animalia", "ANIMA": "Realm Anima", @@ -1047,6 +1049,7 @@ "ARTDRAW": "ArtDraw", "ARTE": "Artemine", "ARTEM": "Artem", + "ARTEMIS": "OFFICIAL ARTEMIS", "ARTEON": "Arteon", "ARTEQ": "artèQ", "ARTEX": "Artex", @@ -1120,6 +1123,10 @@ "ASTA": "ASTA", "ASTER": "Aster", "ASTERINU": "Aster INU", + "ASTEROID": "ASTEROID", + "ASTEROIDBOT": "Asteroid Bot", + "ASTEROIDCOIN": "ASTEROID", + "ASTEROIDETH": "Asteroid", "ASTHERUSUSDF": "Astherus USDF", "ASTO": "Altered State Token", "ASTON": "Aston", @@ -1564,6 +1571,7 @@ "BANANO": "Banano", "BANC": "Babes and Nerds", "BANCA": "BANCA", + "BANCORUSD": "USD Bancor", "BAND": "Band Protocol", "BANDEX": "Banana Index", "BANDIT": "Bandit on Base", @@ -1602,11 +1610,12 @@ "BART": "BarterTrade", "BARTKRC": "BART Token", "BARY": "Bary", + "BAS": "BNB Attestation Service", "BASEAI": "BaseAI", "BASEBEAR": "BBQ", "BASECAT": "BASE CAT", "BASECOIN": "BASECOIN", - "BASED": "Based Money", + "BASED": "Based Token", "BASEDAI": "BasedAI", "BASEDALF": "Based Alf", "BASEDB": "Based Bonk", @@ -1614,12 +1623,13 @@ "BASEDCOPE": "COPE", "BASEDFINANCE": "Based", "BASEDHOPPY": "Based Hoppy (basedhoppy.vip)", + "BASEDMONEY": "Based Money", + "BASEDMONEYV1": "Based Money v1", "BASEDP": "Based Pepe", "BASEDR": "Based Rabbit", "BASEDS": "BasedSwap", "BASEDSB": "Based Street Bets", "BASEDTURBO": "Based Turbo", - "BASEDV1": "Based Money v1", "BASEHEROES": "Baseheroes", "BASEPROTOCOL": "Base Protocol", "BASESWAPX": "BaseX", @@ -1637,6 +1647,7 @@ "BASISSHAREV2": "Basis Share", "BASK": "BasketDAO", "BAST": "Bast", + "BASTEROID": "BABY ASTEROID", "BASTET": "Bastet Goddess", "BAT": "Basic Attention Token", "BATCH": "BATCH Token", @@ -2584,7 +2595,7 @@ "BOZO": "BOZO", "BOZOH": "bozo Hybrid", "BOZY": "Book of Crazy", - "BP": "BunnyPark", + "BP": "Backpack", "BPAD": "BlokPad", "BPADA": "Binance-Peg Cardano (Binance Bridge)", "BPAVAX": "Binance-Peg Avalanche (Binance Bridge)", @@ -2650,6 +2661,7 @@ "BREAD": "Breadchain Cooperative", "BREE": "CBDAO", "BREED": "BreederDAO", + "BRENT": "Brent Crude", "BREPE": "BREPE", "BRETARDIO": "Bretardio", "BRETT": "Brett Base", @@ -2965,6 +2977,7 @@ "BULLA": "BULLA", "BULLBEAR": "BullBear AI", "BULLC": "BuySell", + "BULLDOG": "BullDog Coin", "BULLF": "BULL FINANCE", "BULLGOD": "Bull God", "BULLI": "Bullish On Ethereum", @@ -2997,6 +3010,7 @@ "BUNNY": "Pancake Bunny", "BUNNYINU": "Bunny Inu", "BUNNYM": "BUNNY MEV BOT", + "BUNNYP": "BunnyPark", "BUNNYROCKET": "BunnyRocket", "BURG": "Burger", "BURGER": "Burger Swap", @@ -3207,7 +3221,8 @@ "CARTIER": "Cartier", "CARV": "CARV", "CAS": "Cashaa", - "CASH": "CashCoin", + "CASH": "CASH", + "CASHCOIN": "CashCoin", "CASHIO": "Cashio Dollar", "CASHLY": "Cashly", "CASHT": "Cash Tech", @@ -3232,7 +3247,7 @@ "CATCEO": "CATCEO", "CATCH": "SpaceCatch", "CATCO": "CatCoin", - "CATCOIN": "CatCoin", + "CATCOINCASH": "CatCoin", "CATCOINETH": "Catcoin", "CATCOINIO": "Catcoin", "CATCOINOFSOL": "Cat Coin", @@ -3488,7 +3503,7 @@ "CHARTIQ": "ChartIQ", "CHAS": "Chasm", "CHASH": "CleverHash", - "CHAT": "Solchat", + "CHAT": "OpenChat", "CHATAI": "ChatAI Token", "CHATGPT": "AI Dragon", "CHATOSHI": "chAtoshI", @@ -3826,7 +3841,8 @@ "COFIX": "CoFIX", "COFOUNDIT": "Cofound.it", "COG": "Cognitio", - "COGE": "Cogecoin", + "COGE": "Copper Doge", + "COGECOIN": "Cogecoin", "COGEN": "Cogenero", "COGI": "COGI", "COGS": "Cogmento", @@ -4350,6 +4366,7 @@ "CWR": "Cowrium", "CWS": "Crowns", "CWT": "CrossWallet", + "CWU": "Commonwealth", "CWV": "CryptoWave", "CWX": "Crypto-X", "CWXT": "CryptoWorldXToken", @@ -4673,6 +4690,7 @@ "DEFIK": "DeFi Kingdoms JADE", "DEFIL": "DeFIL", "DEFILAB": "Defi", + "DEFINITIVE": "Edge", "DEFISCALE": "DeFiScale", "DEFISSI": "DEFI.ssi", "DEFIT": "Digital Fitness", @@ -5343,7 +5361,8 @@ "DTV": "DraperTV", "DTX": "DataBroker DAO", "DUA": "Brillion", - "DUAL": "Dual Finance", + "DUAL": "DUAL", + "DUALDAOTOKEN": "Dual Finance", "DUB": "DubCoin", "DUBAICAT": "Dubai Cat", "DUBBZ": "Dubbz", @@ -5487,6 +5506,7 @@ "EARTH": "Earth Token", "EARTHCOIN": "EarthCoin", "EARTHM": "Earthmeta", + "EARTHY": "Little Earth Buddy", "EASY": "EASY", "EASYF": "EasyFeedback", "EASYMINE": "EasyMine", @@ -5566,7 +5586,7 @@ "EDEXA": "edeXa Security Token", "EDFI": "EdFi", "EDG": "Edgeless", - "EDGE": "Definitive", + "EDGE": "edgeX", "EDGEACTIVITY": "EDGE Activity Token", "EDGEAI": "EdgeAI", "EDGEN": "LayerEdge", @@ -5650,6 +5670,7 @@ "EIGENP": "Eigenpie", "EIM": "Expert Infra", "EIQT": "IQ Prediction", + "EITHER": "Eitherway", "EJAC": "EJA Coin", "EJS": "Enjinstarter", "EKG": "Ekon Gold", @@ -5765,6 +5786,7 @@ "EML": "EML Protocol", "EMMM": "emmm", "EMN.CUR": "Eastman Chemical", + "EMOGINETWORK": "EMOGI Network", "EMOJI": "MOMOJI", "EMON": "Ethermon", "EMONEYEUR": "e-Money EUR", @@ -6063,10 +6085,11 @@ "EUSX": "eUSX", "EUT": "EarnUp Token", "EUTBL": "Spiko EU T-Bills Money Market Fund", - "EV": "EVAI", + "EV": "Everything", "EVA": "Evadore", "EVAA": "EVAA Protocol", "EVAI": "EVA Intelligence", + "EVAIIO": "EVAI", "EVAL": "Chromia's EVAL by Virtuals", "EVAN": "Evanesco Network", "EVAULT": "EthereumVault", @@ -7101,6 +7124,7 @@ "GENIESWAP": "GenieSwap", "GENIESWAPV1": "GenieSwap v1", "GENIFYART": "Genify ART", + "GENIUS": "Genius", "GENIX": "Genix", "GENO": "GenomeFi", "GENOME": "GenomesDao", @@ -7740,6 +7764,7 @@ "HACHIK": "Hachiko", "HACHIKO": "Hachiko Inu Token", "HACHIKOINU": "Hachiko Inu", + "HACHIKOTOKEN": "Hachiko", "HACHIONB": "Hachi On Base", "HACHITOKEN": "Hachi", "HACK": "HACK", @@ -7747,6 +7772,7 @@ "HAEDAL": "Haedal Protocol", "HAGGIS": "New Born Haggis Pygmy Hippo", "HAHA": "Hasaki", + "HAHAYESRIZO": "Haha Yes Hedgehog", "HAI": "Hacken Token", "HAIO": "HAiO", "HAIR": " HairDAO", @@ -7776,6 +7802,7 @@ "HANACOIN": "Hanacoin", "HANAETH": "Hana", "HANAETHCTO": "HANA", + "HANC": "OddHanc", "HAND": "ShowHand", "HANDY": "Handy", "HANK": "Hank", @@ -7806,6 +7833,7 @@ "HASBIK": "Hasbulla", "HASH": "Provenance Blockchain", "HASHAI": "HashAI", + "HASHCOIN": "HASH Coin", "HASHNET": "HashNet BitEco", "HASHT": "HASH Token", "HASUI": "Haedal", @@ -8411,6 +8439,7 @@ "IG": "IG Token ", "IGCH": "IG-Crypto Holding", "IGG": "IG Gold", + "IGGT": "The Invincible Game Token", "IGI": "Igi", "IGNIS": "Ignis", "IGT": "Infinitar", @@ -8526,7 +8555,8 @@ "INNBC": "Innovative Bioresearch Coin", "INNOU": "Innou", "INNOVAMINEX": "InnovaMinex", - "INO": "Ino Coin", + "INO": "InoAi", + "INOCOIN": "Ino Coin", "INOVAI": "INOVAI", "INP": "Ionic Pocket Token", "INRT": "INRToken", @@ -8709,6 +8739,7 @@ "IVY": "IvyKoin", "IVZ": "InvisibleCoin", "IW": "iWallet", + "IWC": "IWC", "IWFT": "İstanbul Wild Cats", "IWMON": "iShares Russell 2000 ETF (Ondo Tokenized)", "IWT": "IwToken", @@ -9382,6 +9413,7 @@ "KRD": "Krypton DAO", "KREDS": "KREDS", "KREST": "krest Network", + "KRGN": "Kerrigan Network", "KRIDA": "KridaFans", "KRIPTO": "Kripto", "KRL": "Kryll", @@ -9897,7 +9929,7 @@ "LOKA": "League of Kingdoms", "LOKR": "Polkalokr", "LOKY": "Loky by Virtuals", - "LOL": "EMOGI Network", + "LOL": "LOL", "LOLA": "Lola", "LOLATHECAT": "Lola", "LOLC": "LOL Coin", @@ -9907,9 +9939,11 @@ "LOLO": "Lolo", "LOLONBSC": "LOL", "LON": "Tokenlon", - "LONG": "Longdrink Finance", + "LONG": "LONG", + "LONGDRINK": "Longdrink Finance", "LONGEVITY": "longevity", "LONGFU": "LONGFU", + "LONGFUN": "Long", "LONGM": "Long Mao", "LONGSHINE": "LongShine", "LOOBY": "Looby by Stephen Bliss", @@ -10360,6 +10394,7 @@ "MAXL": "Maxi protocol", "MAXR": "Max Revive", "MAXX": "MAXX Finance", + "MAXXING": "Maxxing", "MAY": "Mayflower", "MAYA": "Maya", "MAYACOIN": "MayaCoin", @@ -10667,6 +10702,8 @@ "MEXC": "MEXC Token", "MEXP": "MOJI Experience Points", "MEY": "Mey Network", + "MEZO": "MEZO", + "MEZOUSD": "Mezo USD", "MEZZ": "MEZZ Token", "MF": "Moonwalk Fitness", "MF1": "Meta Finance", @@ -10713,7 +10750,8 @@ "MHT": "Mouse Haunt", "MHUNT": "MetaShooter", "MI": "XiaoMiCoin", - "MIA": "MiamiCoin", + "MIA": "MIA", + "MIAMICOIN": "MiamiCoin", "MIAO": "MIAOCoin", "MIB": "Mobile Integrated Blockchain", "MIBO": "miBoodle", @@ -11034,7 +11072,7 @@ "MOLO": "MOLO CHAIN", "MOLT": "Moltbook", "MOLTID": "MoltID", - "MOM": "Mother of Memes", + "MOM": "MOM", "MOMA": "Mochi Market", "MOMIJI": "MAGA Momiji", "MOMO": "Momo", @@ -11120,6 +11158,7 @@ "MOONEY": "Moon DAO", "MOONI": "MOON INU", "MOONION": "Moonions", + "MOONKIN": "MOONKIN", "MOONKIZE": "MoonKize", "MOONLIGHT": "Moonlight Token", "MOONPIG": "Moonpig", @@ -11156,6 +11195,7 @@ "MOTG": "MetaOctagon", "MOTH": "MOTH", "MOTHER": "Mother Iggy", + "MOTHEROFMEMES": "Mother of Memes", "MOTI": "Motion", "MOTION": "motion", "MOTIONCOIN": "Motion", @@ -12140,7 +12180,7 @@ "OEX": "OEX", "OF": "OFCOIN", "OFBC": "OneFinBank Coin", - "OFC": "$OFC Coin", + "OFC": "OneFootball Club", "OFCR": "CryptoPolice", "OFE": "Ofero", "OFF": "BlastOff", @@ -12278,6 +12318,7 @@ "ONLINE": "Onlinebase", "ONLY": "OnlyCam", "ONLYCUMIES": "OnlyCumies", + "ONLYFANSCOINS": "$OFC Coin", "ONNO": "Onno Vault", "ONOMY": "Onomy Protocol", "ONOT": "ONO", @@ -12506,6 +12547,7 @@ "OXY2": "Cryptoxygen", "OXYC": "Oxycoin", "OYS": "Oyster Platform", + "OYSTERPEARL": "Oyster Pearl", "OZG": "Ozagold", "OZK": "OrdiZK", "OZMPC": "Ozempic", @@ -13021,7 +13063,6 @@ "PINMO": "Pinmo", "PINO": "Pinocchu", "PINS": "PINs Network Token", - "PINU": "Piccolo Inu", "PINU100X": "Pi INU 100x", "PIO": "Pioneershares", "PIP": "Pip", @@ -13412,7 +13453,7 @@ "PRISMA": "Prisma Finance", "PRIVIX": "Privix", "PRIX": "Privatix", - "PRL": "Oyster Pearl", + "PRL": "Perle", "PRM": "PrismChain", "PRMX": "PREMA", "PRNT": "Prime Numbers", @@ -13442,6 +13483,7 @@ "PROTEO": "Proteo DeFi", "PROTO": "Protocon", "PROTOCOLZ": "Protocol Zero", + "PROTOKEN": "Pro Token", "PROTON": "Proton", "PROUD": "PROUD Money", "PROVE": "Succinct", @@ -13540,6 +13582,7 @@ "PUMPB": "Pump", "PUMPBTC": "pumpBTC", "PUMPBTCXYZ": "PumpBTC", + "PUMPCADE": "PUMPCADE", "PUMPFUNBAN": "Pump Fun Ban", "PUMPIT": "BOGDANOFF", "PUMPTRUMP": "PUMP TRUMP", @@ -14058,6 +14101,7 @@ "RETSA": "Retsa Coin", "REU": "REUCOIN", "REUNI": "Reunit Wallet", + "REUR": "Royal Euro", "REUSDC": "Relend USDC", "REV": "Revain", "REV3L": "REV3AL", @@ -14159,6 +14203,7 @@ "RIPTO": "RiptoBuX", "RIS": "Riser", "RISE": "EverRise", + "RISECOIN": "Rise coin", "RISEP": "Rise Protocol", "RISEVISION": "Rise", "RISITA": "Risitas", @@ -14166,6 +14211,7 @@ "RITE": "ritestream", "RITO": "Ritocoin", "RITZ": "Ritz.Game", + "RIV": "RIV Coin", "RIVER": "River", "RIVERPTS": "River Point Reward Token", "RIVUS": "RivusDAO", @@ -14602,7 +14648,7 @@ "SBCH": "Smart Bitcoin Cash", "SBE": "Sombe", "SBEFE": "BEFE", - "SBET": "SBET", + "SBET": "Sports Bet", "SBF": "SBF In Jail", "SBGO": "Bingo Share", "SBIO": "Vector Space Biosciences, Inc.", @@ -15410,6 +15456,7 @@ "SOLCAT": "CatSolHat", "SOLCATMEME": "SOLCAT", "SOLCEX": "SolCex", + "SOLCHAT": "Solchat", "SOLCHICKSSHARDS": "SolChicks Shards", "SOLE": "SoleCoin", "SOLER": "Solerium", @@ -15420,6 +15467,7 @@ "SOLFUN": "SolFun", "SOLGOAT": "SOLGOAT", "SOLGUN": "Solgun", + "SOLIB": "Solitaire Blossom", "SOLIC": "Solice", "SOLID": "Solidified", "SOLIDSEX": "SOLIDsex: Tokenized veSOLID", @@ -15583,7 +15631,11 @@ "SPIDER": "Spider Man", "SPIDERMAN": "SPIDERMAN BITCOIN", "SPIDEY": "Spidey", + "SPIK": "Spike", "SPIKE": "Spiking", + "SPIKE1984": "Spike 1984", + "SPIKECOIN": "SPIKE", + "SPIKEFURIE": "SPIKE", "SPILLWAYS": "SpillWays", "SPIN": "SPIN Protocol", "SPINT": "Spintria", @@ -15591,6 +15643,7 @@ "SPITT": "Hawk Ttuuaahh", "SPIZ": "SPACE-iZ", "SPK": "Spark", + "SPKI": "SPIKE INU", "SPKL": "SpokLottery", "SPKTR": "Ghost Coin", "SPKY": "GhostyCash", @@ -15612,6 +15665,7 @@ "SPOOL": "Spool DAO Token", "SPORE": "Spore", "SPORT": "SportsCoin", + "SPORTBET": "SBET", "SPORTFUN": "Sport.fun", "SPORTS": "ZenSports", "SPORTSFIX": "SportsFix", @@ -16571,6 +16625,7 @@ "TKX": "Tokenize Xchange", "TKY": "THEKEY Token", "TLC": "Trillioner", + "TLF": "Tradeleaf", "TLM": "Alien Worlds", "TLN": "Trustlines Network", "TLOS": "Telos", @@ -17075,6 +17130,7 @@ "UBQ": "Ubiq", "UBT": "UniBright", "UBTC": "UnitedBitcoin", + "UBU": "UBU", "UBX": "UBIX Network", "UBXN": "UpBots Token", "UBXS": "UBXS", @@ -17169,6 +17225,7 @@ "UNBNK": "Unbanked", "UNBREAKABLE": "UnbreakableCoin", "UNC": "UnCoin", + "UNCEROID": "unc asteroid", "UNCL": "UNCL", "UNCN": "Unseen", "UNCOMMONGOODS": "UNCOMMON•GOODS", @@ -17241,6 +17298,7 @@ "UNRC": "UniversalRoyalCoin", "UNS": "UNS TOKEN", "UNSHETH": "unshETH Ether", + "UNT": "Uni Token", "UNW": "UniWorld", "UOP": "Utopia Genesis Foundation", "UOS": "UOS", @@ -17296,7 +17354,7 @@ "USDAI": "USDai", "USDAP": "Bond Appetite USD", "USDAVALON": "USDa", - "USDB": "USD Bancor", + "USDB": "Blynex USD", "USDBC": "Bridged USDC", "USDBLAST": "USDB Blast", "USDC": "USD Coin", @@ -17412,13 +17470,14 @@ "UTNP": "Universa", "UTON": "uTON", "UTOPIA": "Utopia", + "UTOPIAUSD": "Utopia USD", "UTT": "uTrade", "UTU": "UTU Protocol", "UTX": "UTIX", "UTYA": "Utya", "UTYAB": "Utya Black", "UUC": "USA Unity Coin", - "UUSD": "Utopia USD", + "UUSD": "Unity USD", "UUU": "U Network", "UVT": "UvToken", "UW3S": "Utility Web3Shot", @@ -17621,7 +17680,8 @@ "VIKKY": "VikkyToken", "VILADY": "Vitalik Milady", "VIM": "VicMove", - "VIN": "VinChain", + "VIN": "VulgarTycoon", + "VINCHAIN": "VinChain", "VINCI": "VINCI", "VINE": "Vine Coin", "VINU": "Vita Inu", @@ -17716,6 +17776,7 @@ "VOL": "Volume Network", "VOLBOOST": "VolBoost", "VOLLAR": "Vollar", + "VOLM": "VOLM", "VOLR": "Volare Network", "VOLT": "Volt Inu", "VOLTA": "Volta Club", @@ -18488,6 +18549,7 @@ "XCG": "Xchange", "XCH": "Chia", "XCHAT": "XChat", + "XCHATSOL": "XChat", "XCHF": "CryptoFranc", "XCHNG": "Chainge Finance", "XCI": "Cannabis Industry Coin", @@ -18543,6 +18605,7 @@ "XENOVERSE": "Xenoverse", "XEP": "Electra Protocol", "XERA": "XERA", + "XERO": "XERO", "XERS": "X Project", "XES": "Proxeus", "XET": "Xfinite Entertainment Token", From 5a7667adef56855446c14b34255edafb947cf1eb Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:53:10 +0200 Subject: [PATCH 008/132] Task/upgrade nestjs to version 11.1.19 (#6756) * Upgrade nestjs to version 11.1.19 * Update changelog --- CHANGELOG.md | 1 + package-lock.json | 212 +++++++++++++++++++--------------------------- package.json | 16 ++-- 3 files changed, 96 insertions(+), 133 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf874d1ae..51982066f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Refreshed the cryptocurrencies list - Upgraded `countup.js` from version `2.9.0` to `2.10.0` - Upgraded `jsonpath` from version `1.2.1` to `1.3.0` +- Upgraded `nestjs` from version `11.1.14` to `11.1.19` - Upgraded `Nx` from version `22.6.4` to `22.6.5` ## 2.255.0 - 2026-04-20 diff --git a/package-lock.json b/package-lock.json index 15a66dae1..35390f0b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,15 +31,15 @@ "@keyv/redis": "4.4.0", "@nestjs/bull": "11.0.4", "@nestjs/cache-manager": "3.1.0", - "@nestjs/common": "11.1.14", - "@nestjs/config": "4.0.3", - "@nestjs/core": "11.1.14", + "@nestjs/common": "11.1.19", + "@nestjs/config": "4.0.4", + "@nestjs/core": "11.1.19", "@nestjs/event-emitter": "3.0.1", "@nestjs/jwt": "11.0.2", "@nestjs/passport": "11.0.5", - "@nestjs/platform-express": "11.1.14", - "@nestjs/schedule": "6.1.1", - "@nestjs/serve-static": "5.0.4", + "@nestjs/platform-express": "11.1.19", + "@nestjs/schedule": "6.1.3", + "@nestjs/serve-static": "5.0.5", "@openrouter/ai-sdk-provider": "0.7.2", "@prisma/client": "6.19.3", "@simplewebauthn/browser": "13.2.2", @@ -110,8 +110,8 @@ "@angular/pwa": "21.2.6", "@eslint/eslintrc": "3.3.1", "@eslint/js": "9.35.0", - "@nestjs/schematics": "11.0.9", - "@nestjs/testing": "11.1.14", + "@nestjs/schematics": "11.1.0", + "@nestjs/testing": "11.1.19", "@nx/angular": "22.6.5", "@nx/eslint-plugin": "22.6.5", "@nx/jest": "22.6.5", @@ -8194,12 +8194,12 @@ } }, "node_modules/@nestjs/common": { - "version": "11.1.14", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.14.tgz", - "integrity": "sha512-IN/tlqd7Nl9gl6f0jsWEuOrQDaCI9vHzxv0fisHysfBQzfQIkqlv5A7w4Qge02BUQyczXT9HHPgHtWHCxhjRng==", + "version": "11.1.19", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.19.tgz", + "integrity": "sha512-qeiTt2tv+e5QyDKqG8HlVZb2wx64FEaSGFJouqTSRs+kG44iTfl3xlz1XqVped+rihx4hmjWgL5gkhtdK3E6+Q==", "license": "MIT", "dependencies": { - "file-type": "21.3.0", + "file-type": "21.3.4", "iterare": "1.2.1", "load-esm": "1.0.3", "tslib": "2.8.1", @@ -8225,37 +8225,43 @@ } }, "node_modules/@nestjs/config": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-4.0.3.tgz", - "integrity": "sha512-FQ3M3Ohqfl+nHAn5tp7++wUQw0f2nAk+SFKe8EpNRnIifPqvfJP6JQxPKtFLMOHbyer4X646prFG4zSRYEssQQ==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-4.0.4.tgz", + "integrity": "sha512-CJPjNitr0bAufSEnRe2N+JbnVmMmDoo6hvKCPzXgZoGwJSmp/dZPk9f/RMbuD/+Q1ZJPjwsRpq0vxna++Knwow==", "license": "MIT", "dependencies": { - "dotenv": "17.2.3", + "dotenv": "17.4.1", "dotenv-expand": "12.0.3", - "lodash": "4.17.23" + "lodash": "4.18.1" }, "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", "rxjs": "^7.1.0" } }, - "node_modules/@nestjs/config/node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", - "license": "MIT" + "node_modules/@nestjs/config/node_modules/dotenv": { + "version": "17.4.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", + "integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } }, "node_modules/@nestjs/core": { - "version": "11.1.14", - "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.14.tgz", - "integrity": "sha512-7OXPPMoDr6z+5NkoQKu4hOhfjz/YYqM3bNilPqv1WVFWrzSmuNXxvhbX69YMmNmRYascPXiwESqf5jJdjKXEww==", + "version": "11.1.19", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.19.tgz", + "integrity": "sha512-6nJkWa2efrYi+XlU686J9y5L7OvxpLVjT0T/sxRKE7Jvpffiihelup4WSvLvRhdHDjj/5SuoWEwqReXAaaeHmw==", "hasInstallScript": true, "license": "MIT", "dependencies": { "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", "iterare": "1.2.1", - "path-to-regexp": "8.3.0", + "path-to-regexp": "8.4.2", "tslib": "2.8.1", "uid": "2.0.2" }, @@ -8323,15 +8329,15 @@ } }, "node_modules/@nestjs/platform-express": { - "version": "11.1.14", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.14.tgz", - "integrity": "sha512-Fs+/j+mBSBSXErOQJ/YdUn/HqJGSJ4pGfiJyYOyz04l42uNVnqEakvu1kXLbxMabR6vd6/h9d6Bi4tso9p7o4Q==", + "version": "11.1.19", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.19.tgz", + "integrity": "sha512-Vpdv8jyCQdThfoTx+UTn+DRYr6H6X02YUqcpZ3qP6G3ZUwtVp7eS+hoQPGd4UuCnlnFG8Wqr2J9bGEzQdi1rIg==", "license": "MIT", "dependencies": { "cors": "2.8.6", "express": "5.2.1", - "multer": "2.0.2", - "path-to-regexp": "8.3.0", + "multer": "2.1.1", + "path-to-regexp": "8.4.2", "tslib": "2.8.1" }, "funding": { @@ -8344,9 +8350,9 @@ } }, "node_modules/@nestjs/schedule": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.1.1.tgz", - "integrity": "sha512-kQl1RRgi02GJ0uaUGCrXHCcwISsCsJDciCKe38ykJZgnAeeoeVWs8luWtBo4AqAAXm4nS5K8RlV0smHUJ4+2FA==", + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.1.3.tgz", + "integrity": "sha512-RflMFOpR16Dwd1jAUbeB4mfGTCh65fvEdL4mSjQPJChpkRGRjIXjb+6YQcK2faQrVT60c9DmLmoVR7/ONCtuYQ==", "license": "MIT", "dependencies": { "cron": "4.4.0" @@ -8357,33 +8363,39 @@ } }, "node_modules/@nestjs/schematics": { - "version": "11.0.9", - "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.0.9.tgz", - "integrity": "sha512-0NfPbPlEaGwIT8/TCThxLzrlz3yzDNkfRNpbL7FiplKq3w4qXpJg0JYwqgMEJnLQZm3L/L/5XjoyfJHUO3qX9g==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz", + "integrity": "sha512-lVxGZ46tcdItFMoXr6vyKWlnOsm1SZm/GUqAEDvy2RL4Q4O+3bkziAhrO7Y8JLssFUUvNFEGqAizI52WAxhjDw==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "19.2.17", - "@angular-devkit/schematics": "19.2.17", - "comment-json": "4.4.1", + "@angular-devkit/core": "19.2.24", + "@angular-devkit/schematics": "19.2.24", + "comment-json": "5.0.0", "jsonc-parser": "3.3.1", "pluralize": "8.0.0" }, "peerDependencies": { + "prettier": "^3.0.0", "typescript": ">=4.8.2" + }, + "peerDependenciesMeta": { + "prettier": { + "optional": true + } } }, "node_modules/@nestjs/schematics/node_modules/@angular-devkit/core": { - "version": "19.2.17", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.17.tgz", - "integrity": "sha512-Ah008x2RJkd0F+NLKqIpA34/vUGwjlprRCkvddjDopAWRzYn6xCkz1Tqwuhn0nR1Dy47wTLKYD999TYl5ONOAQ==", + "version": "19.2.24", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.24.tgz", + "integrity": "sha512-Kd49warf6U/EyWe5BszF/eebN3zQ3bk7tgfEljAw8q/rX95UUtriJubWvp6pgzHfzBA4jwq8f+QiNZB8eBEXPA==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "8.17.1", + "ajv": "8.18.0", "ajv-formats": "3.0.1", "jsonc-parser": "3.3.1", - "picomatch": "4.0.2", + "picomatch": "4.0.4", "rxjs": "7.8.1", "source-map": "0.7.4" }, @@ -8402,13 +8414,13 @@ } }, "node_modules/@nestjs/schematics/node_modules/@angular-devkit/schematics": { - "version": "19.2.17", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.17.tgz", - "integrity": "sha512-ADfbaBsrG8mBF6Mfs+crKA/2ykB8AJI50Cv9tKmZfwcUcyAdmTr+vVvhsBCfvUAEokigSsgqgpYxfkJVxhJYeg==", + "version": "19.2.24", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.24.tgz", + "integrity": "sha512-lnw+ZM1Io+cJAkReC0NPDjqObL8NtKzKIkdgEEKC8CUmkhurYhedbicN8Y8NYHgG1uLd2GozW3+/QqPRZaN+Lw==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "19.2.17", + "@angular-devkit/core": "19.2.24", "jsonc-parser": "3.3.1", "magic-string": "0.30.17", "ora": "5.4.1", @@ -8420,23 +8432,6 @@ "yarn": ">= 1.13.0" } }, - "node_modules/@nestjs/schematics/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, "node_modules/@nestjs/schematics/node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -8542,19 +8537,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@nestjs/schematics/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/@nestjs/schematics/node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -8603,15 +8585,15 @@ } }, "node_modules/@nestjs/serve-static": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@nestjs/serve-static/-/serve-static-5.0.4.tgz", - "integrity": "sha512-3kO1M9D3vsPyWPFardxIjUYeuolS58PnhCoBTkS7t3BrdZFZCKHnBZ15js+UOzOR2Q6HmD7ssGjLd0DVYVdvOw==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@nestjs/serve-static/-/serve-static-5.0.5.tgz", + "integrity": "sha512-AhYx3N9aMwR2cb0w5Nlb5nHNYiAcF74ea/D/xna+PxlXwjmwGN/PpC/5fuMtOwmPBMgOTxNPOnB8C9LDZBSgyw==", "license": "MIT", "dependencies": { - "path-to-regexp": "8.3.0" + "path-to-regexp": "8.4.2" }, "peerDependencies": { - "@fastify/static": "^8.0.4", + "@fastify/static": "^8.0.4 || ^9.0.0", "@nestjs/common": "^11.0.2", "@nestjs/core": "^11.0.2", "express": "^5.0.1", @@ -8630,9 +8612,9 @@ } }, "node_modules/@nestjs/testing": { - "version": "11.1.14", - "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.1.14.tgz", - "integrity": "sha512-cQxX0ronsTbpfHz8/LYOVWXxoTxv6VoxrnuZoQaVX7QV2PSMqxWE7/9jSQR0GcqAFUEmFP34c6EJqfkjfX/k4Q==", + "version": "11.1.19", + "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.1.19.tgz", + "integrity": "sha512-/UFNWXvPEdu4v4DlC5oWLbGKmD27LehLK06b8oLzs6D6lf4vAQTdST8LRAXBadyMUQnVEQWMuBo3CtAVtlfXtQ==", "dev": true, "license": "MIT", "dependencies": { @@ -18280,14 +18262,13 @@ } }, "node_modules/comment-json": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.4.1.tgz", - "integrity": "sha512-r1To31BQD5060QdkC+Iheai7gHwoSZobzunqkf2/kQ6xIAfJyrKNAFUwdKvkK7Qgu7pVTKQEa7ok7Ed3ycAJgg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-5.0.0.tgz", + "integrity": "sha512-uiqLcOiVDJtBP8WGkZHEP+FZIhTzP1dxvn59EfoYUi9gqupjrBWVQkO2atDrbnKPwLeotFYDsuNb26uBMqB+hw==", "dev": true, "license": "MIT", "dependencies": { "array-timsort": "^1.0.3", - "core-util-is": "^1.0.3", "esprima": "^4.0.1" }, "engines": { @@ -22230,9 +22211,9 @@ } }, "node_modules/file-type": { - "version": "21.3.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.0.tgz", - "integrity": "sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==", + "version": "21.3.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", + "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", "license": "MIT", "dependencies": { "@tokenizer/inflate": "^0.4.1", @@ -29875,6 +29856,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -29999,18 +29981,6 @@ "node": ">= 18" } }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, "node_modules/mlly": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", @@ -30074,21 +30044,22 @@ } }, "node_modules/multer": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", - "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", "busboy": "^1.6.0", "concat-stream": "^2.0.0", - "mkdirp": "^0.5.6", - "object-assign": "^4.1.1", - "type-is": "^1.6.18", - "xtend": "^4.0.2" + "type-is": "^1.6.18" }, "engines": { "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/multer/node_modules/media-typer": { @@ -32030,9 +32001,9 @@ "license": "ISC" }, "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "license": "MIT", "funding": { "type": "opencollective", @@ -39425,15 +39396,6 @@ "sax": "^1.2.4" } }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "license": "MIT", - "engines": { - "node": ">=0.4" - } - }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 55ccfaf70..160cf9408 100644 --- a/package.json +++ b/package.json @@ -76,15 +76,15 @@ "@keyv/redis": "4.4.0", "@nestjs/bull": "11.0.4", "@nestjs/cache-manager": "3.1.0", - "@nestjs/common": "11.1.14", - "@nestjs/config": "4.0.3", - "@nestjs/core": "11.1.14", + "@nestjs/common": "11.1.19", + "@nestjs/config": "4.0.4", + "@nestjs/core": "11.1.19", "@nestjs/event-emitter": "3.0.1", "@nestjs/jwt": "11.0.2", "@nestjs/passport": "11.0.5", - "@nestjs/platform-express": "11.1.14", - "@nestjs/schedule": "6.1.1", - "@nestjs/serve-static": "5.0.4", + "@nestjs/platform-express": "11.1.19", + "@nestjs/schedule": "6.1.3", + "@nestjs/serve-static": "5.0.5", "@openrouter/ai-sdk-provider": "0.7.2", "@prisma/client": "6.19.3", "@simplewebauthn/browser": "13.2.2", @@ -155,8 +155,8 @@ "@angular/pwa": "21.2.6", "@eslint/eslintrc": "3.3.1", "@eslint/js": "9.35.0", - "@nestjs/schematics": "11.0.9", - "@nestjs/testing": "11.1.14", + "@nestjs/schematics": "11.1.0", + "@nestjs/testing": "11.1.19", "@nx/angular": "22.6.5", "@nx/eslint-plugin": "22.6.5", "@nx/jest": "22.6.5", From ee2f0436ae37bf3cbe4b4cc0400f82b9e243c087 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 23 Apr 2026 17:24:28 +0200 Subject: [PATCH 009/132] Task/upgrade ngx-markdown to version 21.2.0 (#6765) * Upgrade ngx-markdown to version 21.2.0 * Update changelog --- CHANGELOG.md | 1 + package-lock.json | 262 +++++++++++++++++++++++++++++++++++----------- package.json | 2 +- 3 files changed, 205 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51982066f..e559395e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Upgraded `countup.js` from version `2.9.0` to `2.10.0` - Upgraded `jsonpath` from version `1.2.1` to `1.3.0` - Upgraded `nestjs` from version `11.1.14` to `11.1.19` +- Upgraded `ngx-markdown` from version `21.1.0` to `21.2.0` - Upgraded `Nx` from version `22.6.4` to `22.6.5` ## 2.255.0 - 2026-04-20 diff --git a/package-lock.json b/package-lock.json index 35390f0b6..6117044e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -78,7 +78,7 @@ "ms": "3.0.0-canary.1", "ng-extract-i18n-merge": "3.3.0", "ngx-device-detector": "11.0.0", - "ngx-markdown": "21.1.0", + "ngx-markdown": "21.2.0", "ngx-skeleton-loader": "12.0.0", "open-color": "1.9.1", "papaparse": "5.3.1", @@ -1511,6 +1511,7 @@ "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" @@ -3519,7 +3520,8 @@ "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@bramus/specificity": { "version": "2.4.2", @@ -3606,6 +3608,7 @@ "integrity": "sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg==", "license": "Apache-2.0", "optional": true, + "peer": true, "dependencies": { "@chevrotain/gast": "12.0.0", "@chevrotain/types": "12.0.0" @@ -3617,6 +3620,7 @@ "integrity": "sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ==", "license": "Apache-2.0", "optional": true, + "peer": true, "dependencies": { "@chevrotain/types": "12.0.0" } @@ -3626,21 +3630,24 @@ "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-12.0.0.tgz", "integrity": "sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA==", "license": "Apache-2.0", - "optional": true + "optional": true, + "peer": true }, "node_modules/@chevrotain/types": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-12.0.0.tgz", "integrity": "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA==", "license": "Apache-2.0", - "optional": true + "optional": true, + "peer": true }, "node_modules/@chevrotain/utils": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-12.0.0.tgz", "integrity": "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==", "license": "Apache-2.0", - "optional": true + "optional": true, + "peer": true }, "node_modules/@codewithdan/observable-store": { "version": "2.2.15", @@ -4595,7 +4602,8 @@ "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@iconify/utils": { "version": "3.1.0", @@ -4603,6 +4611,7 @@ "integrity": "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", @@ -7305,6 +7314,7 @@ "integrity": "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "langium": "^4.0.0" } @@ -13815,6 +13825,7 @@ "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", @@ -13853,7 +13864,8 @@ "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@types/d3-axis": { "version": "3.0.6", @@ -13861,6 +13873,7 @@ "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@types/d3-selection": "*" } @@ -13871,6 +13884,7 @@ "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@types/d3-selection": "*" } @@ -13880,14 +13894,16 @@ "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@types/d3-color": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@types/d3-contour": { "version": "3.0.6", @@ -13895,6 +13911,7 @@ "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@types/d3-array": "*", "@types/geojson": "*" @@ -13905,14 +13922,16 @@ "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@types/d3-dispatch": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@types/d3-drag": { "version": "3.0.7", @@ -13920,6 +13939,7 @@ "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@types/d3-selection": "*" } @@ -13929,14 +13949,16 @@ "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@types/d3-ease": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@types/d3-fetch": { "version": "3.0.7", @@ -13944,6 +13966,7 @@ "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@types/d3-dsv": "*" } @@ -13953,14 +13976,16 @@ "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@types/d3-format": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@types/d3-geo": { "version": "3.1.0", @@ -13968,6 +13993,7 @@ "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@types/geojson": "*" } @@ -13977,7 +14003,8 @@ "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@types/d3-interpolate": { "version": "3.0.4", @@ -13985,6 +14012,7 @@ "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@types/d3-color": "*" } @@ -13994,28 +14022,32 @@ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@types/d3-polygon": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@types/d3-quadtree": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@types/d3-random": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@types/d3-scale": { "version": "4.0.9", @@ -14023,6 +14055,7 @@ "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@types/d3-time": "*" } @@ -14032,14 +14065,16 @@ "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@types/d3-selection": { "version": "3.0.11", "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@types/d3-shape": { "version": "3.1.8", @@ -14047,6 +14082,7 @@ "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@types/d3-path": "*" } @@ -14056,21 +14092,24 @@ "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@types/d3-time-format": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@types/d3-timer": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@types/d3-transition": { "version": "3.0.9", @@ -14078,6 +14117,7 @@ "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@types/d3-selection": "*" } @@ -14088,6 +14128,7 @@ "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" @@ -14212,7 +14253,8 @@ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@types/google-spreadsheet": { "version": "3.1.5", @@ -14600,7 +14642,8 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@types/validator": { "version": "13.15.10", @@ -15612,6 +15655,7 @@ "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", "license": "MIT", "optional": true, + "peer": true, "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" @@ -17768,6 +17812,7 @@ "integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==", "license": "Apache-2.0", "optional": true, + "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "12.0.0", "@chevrotain/gast": "12.0.0", @@ -17785,6 +17830,7 @@ "integrity": "sha512-PvVJm3oGqrveUVW2Vt/eZGeiAIsJszYweUcYwcskg9e+IubNYKKD+rHHem7A6XVO22eDAL+inxNIGAzZ/VIWlA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "lodash-es": "^4.17.21" }, @@ -18002,6 +18048,7 @@ "integrity": "sha512-C+0bbOqkezLIsmWSvlsXS0Q0bmkugu7jcfMIACB+RDEntIzQIkdr148we28AfSloQLRdZlYL/QYyrq05j/3Faw==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "good-listener": "^1.2.2", "select": "^1.1.2", @@ -18358,7 +18405,8 @@ "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/confusing-browser-globals": { "version": "1.0.11", @@ -18533,6 +18581,7 @@ "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "layout-base": "^1.0.0" } @@ -19592,6 +19641,7 @@ "integrity": "sha512-sj4HXd3DokGhzZAdjDejGvTPLqlt84vNFN8m7bGsOzDY5DyVcxIb2ejIXat2Iy7HxWhdT/N1oKyheJ5YdpsGuw==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=0.10" } @@ -19602,6 +19652,7 @@ "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "cose-base": "^1.0.0" }, @@ -19615,6 +19666,7 @@ "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "cose-base": "^2.2.0" }, @@ -19628,6 +19680,7 @@ "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "layout-base": "^2.0.0" } @@ -19637,7 +19690,8 @@ "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/d3": { "version": "7.9.0", @@ -19645,6 +19699,7 @@ "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "d3-array": "3", "d3-axis": "3", @@ -19687,6 +19742,7 @@ "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "internmap": "1 - 2" }, @@ -19700,6 +19756,7 @@ "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", "license": "ISC", "optional": true, + "peer": true, "engines": { "node": ">=12" } @@ -19710,6 +19767,7 @@ "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", @@ -19727,6 +19785,7 @@ "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "d3-path": "1 - 3" }, @@ -19740,6 +19799,7 @@ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", "license": "ISC", "optional": true, + "peer": true, "engines": { "node": ">=12" } @@ -19750,6 +19810,7 @@ "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "d3-array": "^3.2.0" }, @@ -19763,6 +19824,7 @@ "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "delaunator": "5" }, @@ -19776,6 +19838,7 @@ "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", "license": "ISC", "optional": true, + "peer": true, "engines": { "node": ">=12" } @@ -19786,6 +19849,7 @@ "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" @@ -19800,6 +19864,7 @@ "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "commander": "7", "iconv-lite": "0.6", @@ -19826,6 +19891,7 @@ "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 10" } @@ -19836,6 +19902,7 @@ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -19849,6 +19916,7 @@ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", "license": "BSD-3-Clause", "optional": true, + "peer": true, "engines": { "node": ">=12" } @@ -19859,6 +19927,7 @@ "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "d3-dsv": "1 - 3" }, @@ -19872,6 +19941,7 @@ "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", @@ -19887,6 +19957,7 @@ "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", "license": "ISC", "optional": true, + "peer": true, "engines": { "node": ">=12" } @@ -19897,6 +19968,7 @@ "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "d3-array": "2.5.0 - 3" }, @@ -19910,6 +19982,7 @@ "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", "license": "ISC", "optional": true, + "peer": true, "engines": { "node": ">=12" } @@ -19920,6 +19993,7 @@ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "d3-color": "1 - 3" }, @@ -19933,6 +20007,7 @@ "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", "license": "ISC", "optional": true, + "peer": true, "engines": { "node": ">=12" } @@ -19943,6 +20018,7 @@ "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", "license": "ISC", "optional": true, + "peer": true, "engines": { "node": ">=12" } @@ -19953,6 +20029,7 @@ "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", "license": "ISC", "optional": true, + "peer": true, "engines": { "node": ">=12" } @@ -19963,6 +20040,7 @@ "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", "license": "ISC", "optional": true, + "peer": true, "engines": { "node": ">=12" } @@ -19973,6 +20051,7 @@ "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", "license": "BSD-3-Clause", "optional": true, + "peer": true, "dependencies": { "d3-array": "1 - 2", "d3-shape": "^1.2.0" @@ -19984,6 +20063,7 @@ "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", "license": "BSD-3-Clause", "optional": true, + "peer": true, "dependencies": { "internmap": "^1.0.0" } @@ -19993,7 +20073,8 @@ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", "license": "BSD-3-Clause", - "optional": true + "optional": true, + "peer": true }, "node_modules/d3-sankey/node_modules/d3-shape": { "version": "1.3.7", @@ -20001,6 +20082,7 @@ "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", "license": "BSD-3-Clause", "optional": true, + "peer": true, "dependencies": { "d3-path": "1" } @@ -20010,7 +20092,8 @@ "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", "license": "ISC", - "optional": true + "optional": true, + "peer": true }, "node_modules/d3-scale": { "version": "4.0.2", @@ -20018,6 +20101,7 @@ "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", @@ -20035,6 +20119,7 @@ "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" @@ -20049,6 +20134,7 @@ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", "optional": true, + "peer": true, "engines": { "node": ">=12" } @@ -20059,6 +20145,7 @@ "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "d3-path": "^3.1.0" }, @@ -20072,6 +20159,7 @@ "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "d3-array": "2 - 3" }, @@ -20085,6 +20173,7 @@ "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "d3-time": "1 - 3" }, @@ -20098,6 +20187,7 @@ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", "license": "ISC", "optional": true, + "peer": true, "engines": { "node": ">=12" } @@ -20108,6 +20198,7 @@ "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", @@ -20128,6 +20219,7 @@ "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", @@ -20145,6 +20237,7 @@ "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" @@ -20257,7 +20350,8 @@ "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/debounce": { "version": "1.2.1", @@ -20453,6 +20547,7 @@ "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", "license": "ISC", "optional": true, + "peer": true, "dependencies": { "robust-predicates": "^3.0.2" } @@ -20472,7 +20567,8 @@ "resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz", "integrity": "sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/denque": { "version": "2.1.0", @@ -20671,6 +20767,7 @@ "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", "license": "(MPL-2.0 OR Apache-2.0)", "optional": true, + "peer": true, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -20839,7 +20936,8 @@ "resolved": "https://registry.npmjs.org/emoji-toolkit/-/emoji-toolkit-10.0.0.tgz", "integrity": "sha512-GkIAvgutEVbkqcT2HjBzV002SWvpdNaT3aP9q/YjQ6hlgDq8HhE9GcqxWkyYkRRQnLADGpwDoj1heTw9KzO9wQ==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/emojis-list": { "version": "3.0.0", @@ -23395,6 +23493,7 @@ "integrity": "sha512-goW1b+d9q/HIwbVYZzZ6SsTr4IgE+WA44A0GmPIQstuOrgsFcT7VEJ48nmr9GaRtNu0XTKacFLGnBPAM6Afouw==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "delegate": "^3.1.2" } @@ -23530,7 +23629,8 @@ "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/handle-thing": { "version": "2.0.1", @@ -24378,6 +24478,7 @@ "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", "license": "ISC", "optional": true, + "peer": true, "engines": { "node": ">=12" } @@ -28729,6 +28830,7 @@ ], "license": "MIT", "optional": true, + "peer": true, "dependencies": { "commander": "^8.3.0" }, @@ -28742,6 +28844,7 @@ "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 12" } @@ -28760,7 +28863,8 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==", - "optional": true + "optional": true, + "peer": true }, "node_modules/kill-port": { "version": "1.6.1", @@ -28792,6 +28896,7 @@ "integrity": "sha512-JUshTRAfHI4/MF9dH2WupvjSXyn8JBuUEWazB8ZVJUtXutT0doDlAv1XKbZ1Pb5sMexa8FF4CFBc0iiul7gbUQ==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@chevrotain/regexp-to-ast": "~12.0.0", "chevrotain": "~12.0.0", @@ -28821,7 +28926,8 @@ "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/less": { "version": "4.4.2", @@ -29653,6 +29759,7 @@ "integrity": "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.2", @@ -29683,6 +29790,7 @@ "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", "license": "MIT", "optional": true, + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -29700,6 +29808,7 @@ ], "license": "MIT", "optional": true, + "peer": true, "bin": { "uuid": "dist/esm/bin/uuid" } @@ -29987,6 +30096,7 @@ "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", @@ -30249,27 +30359,42 @@ } }, "node_modules/ngx-markdown": { - "version": "21.1.0", - "resolved": "https://registry.npmjs.org/ngx-markdown/-/ngx-markdown-21.1.0.tgz", - "integrity": "sha512-qiyn9Je20F9yS4/q0p1Xhk2b/HW0rHWWlJNRm8DIzJKNck9Rmn/BfFxq0webmQHPPyYkg2AjNq/ZeSqDTQJbsQ==", + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/ngx-markdown/-/ngx-markdown-21.2.0.tgz", + "integrity": "sha512-N2YbawO3lz90b7nVcIhEdwcsCR3Z8bDell3RPvEj+5GcKVxSKb28jekOpg3D7mHtGrDV7vJ7RdpCq/JTGD1LbQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" }, - "optionalDependencies": { - "clipboard": "^2.0.11", - "emoji-toolkit": ">= 8.0.0 < 11.0.0", - "katex": "^0.16.0", - "mermaid": ">= 10.6.0 < 12.0.0", - "prismjs": "^1.30.0" - }, "peerDependencies": { "@angular/common": "^21.0.0", "@angular/core": "^21.0.0", "@angular/platform-browser": "^21.0.0", + "clipboard": "^2.0.11", + "emoji-toolkit": ">= 8.0.0 < 11.0.0", + "katex": "^0.16.0", "marked": "^17.0.0", + "mermaid": ">= 10.6.0 < 12.0.0", + "prismjs": "^1.30.0", "rxjs": "^6.5.3 || ^7.4.0", "zone.js": "~0.15.0 || ~0.16.0" + }, + "peerDependenciesMeta": { + "clipboard": { + "optional": true + }, + "emoji-toolkit": { + "optional": true + }, + "katex": { + "optional": true + }, + "mermaid": { + "optional": true + }, + "prismjs": { + "optional": true + } } }, "node_modules/ngx-skeleton-loader": { @@ -31548,7 +31673,8 @@ "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/pacote": { "version": "21.3.1", @@ -31937,7 +32063,8 @@ "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/path-exists": { "version": "4.0.0", @@ -32184,6 +32311,7 @@ "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", @@ -32236,7 +32364,8 @@ "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/points-on-path": { "version": "0.2.1", @@ -32244,6 +32373,7 @@ "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" @@ -32656,6 +32786,7 @@ "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=6" } @@ -33613,7 +33744,8 @@ "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", "license": "Unlicense", - "optional": true + "optional": true, + "peer": true }, "node_modules/rolldown": { "version": "1.0.0-rc.4", @@ -33800,6 +33932,7 @@ "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", @@ -33872,7 +34005,8 @@ "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", "license": "BSD-3-Clause", - "optional": true + "optional": true, + "peer": true }, "node_modules/rxjs": { "version": "7.8.1", @@ -34589,7 +34723,8 @@ "resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz", "integrity": "sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/select-hose": { "version": "2.0.0", @@ -35946,7 +36081,8 @@ "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/supports-color": { "version": "7.2.0", @@ -36485,7 +36621,8 @@ "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/tiny-invariant": { "version": "1.3.3", @@ -37589,7 +37726,8 @@ "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/uid": { "version": "2.0.2", @@ -38040,6 +38178,7 @@ "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=14.0.0" } @@ -38050,6 +38189,7 @@ "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, @@ -38063,6 +38203,7 @@ "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" @@ -38073,21 +38214,24 @@ "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/vscode-languageserver-types": { "version": "3.17.5", "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/vscode-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", diff --git a/package.json b/package.json index 160cf9408..9203f2e30 100644 --- a/package.json +++ b/package.json @@ -123,7 +123,7 @@ "ms": "3.0.0-canary.1", "ng-extract-i18n-merge": "3.3.0", "ngx-device-detector": "11.0.0", - "ngx-markdown": "21.1.0", + "ngx-markdown": "21.2.0", "ngx-skeleton-loader": "12.0.0", "open-color": "1.9.1", "papaparse": "5.3.1", From 65941eb62e3e66b974de45772abde52466647a76 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:26:05 +0200 Subject: [PATCH 010/132] Release 3.0.0 (#6768) * Feature/migrate from Angular Material design 2 to 3 (#6163) * Migrate from M2 to M3 * Update changelog --------- Co-authored-by: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com> * Task/upgrade to prisma 7 (#6027) * Upgrade Prisma to version 7.3.0 * Update changelog * Feature/restore badge style ("filter indicator") in assistant menu button of header (#6253) * feat(styles): add badge overrides * fix(styles): make the parent display inline-flex * feat(ui): update styles * Task/cleanup m3-theme.scss (#6252) * feat(styles): cleanup m3-theme.scss * Task/decrease horizontal padding of buttons (#6679) Decrease horizontal padding of buttons * Task/decrease border radius of dialogs (#6680) Decrease border radius of dialogs * Task/upgrade prisma to version 7.7.0 (#6749) * Upgrade prisma to version 7.7.0 * Update changelog * Bugfix/prisma client initialization in admin service (#6764) * Fix prisma client initialization * Feature/add blog post: Ghostfolio 3 (#6678) * Add blog post: Ghostfolio 3 * Update changelog * Task/move extended values on analysis page from experimental to general availability (#6766) * Move extended values to general availability * Update changelog * Release 3.0.0 --------- Co-authored-by: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com> --- .config/prisma.ts | 3 + .env.dev | 2 +- .env.example | 2 +- CHANGELOG.md | 13 +- README.md | 40 +- apps/api/src/app/admin/admin.service.ts | 316 +++---- .../app/endpoints/sitemap/sitemap.service.ts | 4 + .../middlewares/html-template.middleware.ts | 4 + .../api/src/services/prisma/prisma.service.ts | 6 + apps/client/src/app/app.component.ts | 2 + .../components/header/header.component.html | 68 +- .../components/header/header.component.scss | 13 +- .../ghostfolio-3-page.component.ts | 21 + .../04/ghostfolio-3/ghostfolio-3-page.html | 281 ++++++ apps/client/src/app/pages/blog/blog-page.html | 26 + .../src/app/pages/blog/blog-page.routes.ts | 9 + .../portfolio/analysis/analysis-page.html | 124 ++- .../src/assets/images/blog/ghostfolio-3.jpg | Bin 0 -> 224143 bytes apps/client/src/styles.scss | 83 +- apps/client/src/styles/theme.scss | 516 +++++++++-- libs/common/jest.config.ts | 1 + libs/common/src/test-setup.ts | 3 + package-lock.json | 867 ++++++++++++++++-- package.json | 7 +- prisma/schema.prisma | 1 - prisma/seed.mts | 7 +- 26 files changed, 1878 insertions(+), 541 deletions(-) create mode 100644 apps/client/src/app/pages/blog/2026/04/ghostfolio-3/ghostfolio-3-page.component.ts create mode 100644 apps/client/src/app/pages/blog/2026/04/ghostfolio-3/ghostfolio-3-page.html create mode 100644 apps/client/src/assets/images/blog/ghostfolio-3.jpg create mode 100644 libs/common/src/test-setup.ts diff --git a/.config/prisma.ts b/.config/prisma.ts index 64691136c..a9e7bbf64 100644 --- a/.config/prisma.ts +++ b/.config/prisma.ts @@ -6,6 +6,9 @@ import { join } from 'node:path'; expand(config({ quiet: true })); export default defineConfig({ + datasource: { + url: process.env.DATABASE_URL ?? '' + }, migrations: { path: join(__dirname, '..', 'prisma', 'migrations'), seed: `node ${join(__dirname, '..', 'prisma', 'seed.mts')}` diff --git a/.env.dev b/.env.dev index d0c9a1576..b7ad632f8 100644 --- a/.env.dev +++ b/.env.dev @@ -12,7 +12,7 @@ POSTGRES_PASSWORD= # VARIOUS ACCESS_TOKEN_SALT= -DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer +DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300 JWT_SECRET_KEY= # DEVELOPMENT diff --git a/.env.example b/.env.example index e4a935626..1f43bfd45 100644 --- a/.env.example +++ b/.env.example @@ -12,5 +12,5 @@ POSTGRES_PASSWORD= # VARIOUS ACCESS_TOKEN_SALT= -DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer +DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?connect_timeout=300 JWT_SECRET_KEY= diff --git a/CHANGELOG.md b/CHANGELOG.md index e559395e4..1fc82ec50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,16 +5,27 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## Unreleased +## 3.0.0 - 2026-04-23 + +### Added + +- Added a blog post: _Announcing Ghostfolio 3.0_ ### Changed +- Migrated from _Material Design_ 2 to _Material Design_ 3 +- Moved the total amount, change and performance with currency effects on the analysis page from experimental to general availability - Refreshed the cryptocurrencies list - Upgraded `countup.js` from version `2.9.0` to `2.10.0` - Upgraded `jsonpath` from version `1.2.1` to `1.3.0` - Upgraded `nestjs` from version `11.1.14` to `11.1.19` - Upgraded `ngx-markdown` from version `21.1.0` to `21.2.0` - Upgraded `Nx` from version `22.6.4` to `22.6.5` +- Upgraded `prisma` from version `6.19.0` to `7.7.0` + +### Todo + +- **Breaking Change**: The `sslmode=prefer` parameter in `DATABASE_URL` is no longer supported. Please update your environment variables (see `.env`) to use `sslmode=require` if _SSL_ is enabled or remove the `sslmode` parameter entirely if _SSL_ is not used. ## 2.255.0 - 2026-04-20 diff --git a/README.md b/README.md index d019721c1..87710d610 100644 --- a/README.md +++ b/README.md @@ -85,26 +85,26 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c ### Supported Environment Variables -| Name | Type | Default Value | Description | -| --------------------------- | --------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens | -| `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key | -| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key | -| `DATABASE_URL` | `string` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` | -| `ENABLE_FEATURE_AUTH_TOKEN` | `boolean` (optional) | `true` | Enables authentication via security token | -| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on | -| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) | -| `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` | -| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on | -| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database | -| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database | -| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database | -| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ | -| `REDIS_HOST` | `string` | | The host where _Redis_ is running | -| `REDIS_PASSWORD` | `string` | | The password of _Redis_ | -| `REDIS_PORT` | `number` | | The port where _Redis_ is running | -| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds | -| `ROOT_URL` | `string` (optional) | `http://0.0.0.0:3333` | The root URL of the Ghostfolio application, used for generating callback URLs and external links. | +| Name | Type | Default Value | Description | +| --------------------------- | --------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens | +| `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key | +| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key | +| `DATABASE_URL` | `string` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}` | +| `ENABLE_FEATURE_AUTH_TOKEN` | `boolean` (optional) | `true` | Enables authentication via security token | +| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on | +| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) | +| `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` | +| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on | +| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database | +| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database | +| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database | +| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ | +| `REDIS_HOST` | `string` | | The host where _Redis_ is running | +| `REDIS_PASSWORD` | `string` | | The password of _Redis_ | +| `REDIS_PORT` | `number` | | The port where _Redis_ is running | +| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds | +| `ROOT_URL` | `string` (optional) | `http://0.0.0.0:3333` | The root URL of the Ghostfolio application, used for generating callback URLs and external links. | #### OpenID Connect OIDC (Experimental) diff --git a/apps/api/src/app/admin/admin.service.ts b/apps/api/src/app/admin/admin.service.ts index 27abb6254..ba338b5b9 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -36,7 +36,6 @@ import { BadRequestException, HttpException, Injectable, - Logger, NotFoundException } from '@nestjs/common'; import { @@ -44,7 +43,6 @@ import { AssetSubClass, DataSource, Prisma, - PrismaClient, Property, SymbolProfile } from '@prisma/client'; @@ -280,186 +278,178 @@ export class AdminService { const extendedPrismaClient = this.getExtendedPrismaClient(); - try { - const symbolProfileResult = await Promise.all([ - extendedPrismaClient.symbolProfile.findMany({ - skip, - take, - where, - orderBy: [...orderBy, { id: sortDirection }], - select: { - _count: { - select: { - activities: true, - watchedBy: true - } - }, - activities: { - orderBy: [{ date: 'asc' }], - select: { date: true }, - take: 1 - }, - assetClass: true, - assetSubClass: true, - comment: true, - countries: true, - currency: true, - dataSource: true, - id: true, - isActive: true, - isUsedByUsersWithSubscription: true, - name: true, - scraperConfiguration: true, - sectors: true, - symbol: true, - SymbolProfileOverrides: true - } - }), - this.prismaService.symbolProfile.count({ where }) - ]); - const assetProfiles = symbolProfileResult[0]; - let count = symbolProfileResult[1]; - - const lastMarketPrices = await this.prismaService.marketData.findMany({ - distinct: ['dataSource', 'symbol'], - orderBy: { date: 'desc' }, + const symbolProfileResult = await Promise.all([ + extendedPrismaClient.symbolProfile.findMany({ + skip, + take, + where, + orderBy: [...orderBy, { id: sortDirection }], select: { + _count: { + select: { + activities: true, + watchedBy: true + } + }, + activities: { + orderBy: [{ date: 'asc' }], + select: { date: true }, + take: 1 + }, + assetClass: true, + assetSubClass: true, + comment: true, + countries: true, + currency: true, dataSource: true, - marketPrice: true, - symbol: true + id: true, + isActive: true, + isUsedByUsersWithSubscription: true, + name: true, + scraperConfiguration: true, + sectors: true, + symbol: true, + SymbolProfileOverrides: true + } + }), + this.prismaService.symbolProfile.count({ where }) + ]); + const assetProfiles = symbolProfileResult[0]; + let count = symbolProfileResult[1]; + + const lastMarketPrices = await this.prismaService.marketData.findMany({ + distinct: ['dataSource', 'symbol'], + orderBy: { date: 'desc' }, + select: { + dataSource: true, + marketPrice: true, + symbol: true + }, + where: { + dataSource: { + in: assetProfiles.map(({ dataSource }) => { + return dataSource; + }) }, - where: { - dataSource: { - in: assetProfiles.map(({ dataSource }) => { - return dataSource; - }) - }, - symbol: { - in: assetProfiles.map(({ symbol }) => { - return symbol; - }) - } + symbol: { + in: assetProfiles.map(({ symbol }) => { + return symbol; + }) } - }); + } + }); - const lastMarketPriceMap = new Map(); + const lastMarketPriceMap = new Map(); - for (const { dataSource, marketPrice, symbol } of lastMarketPrices) { - lastMarketPriceMap.set( - getAssetProfileIdentifier({ dataSource, symbol }), - marketPrice - ); - } + for (const { dataSource, marketPrice, symbol } of lastMarketPrices) { + lastMarketPriceMap.set( + getAssetProfileIdentifier({ dataSource, symbol }), + marketPrice + ); + } + + let marketData: AdminMarketDataItem[] = await Promise.all( + assetProfiles.map( + async ({ + _count, + activities, + assetClass, + assetSubClass, + comment, + countries, + currency, + dataSource, + id, + isActive, + isUsedByUsersWithSubscription, + name, + sectors, + symbol, + SymbolProfileOverrides + }) => { + let countriesCount = countries ? Object.keys(countries).length : 0; + + const lastMarketPrice = lastMarketPriceMap.get( + getAssetProfileIdentifier({ dataSource, symbol }) + ); + + const marketDataItemCount = + marketDataItems.find((marketDataItem) => { + return ( + marketDataItem.dataSource === dataSource && + marketDataItem.symbol === symbol + ); + })?._count ?? 0; + + let sectorsCount = sectors ? Object.keys(sectors).length : 0; + + if (SymbolProfileOverrides) { + assetClass = SymbolProfileOverrides.assetClass ?? assetClass; + assetSubClass = + SymbolProfileOverrides.assetSubClass ?? assetSubClass; + + if ( + (SymbolProfileOverrides.countries as unknown as Prisma.JsonArray) + ?.length > 0 + ) { + countriesCount = ( + SymbolProfileOverrides.countries as unknown as Prisma.JsonArray + ).length; + } - let marketData: AdminMarketDataItem[] = await Promise.all( - assetProfiles.map( - async ({ - _count, - activities, + name = SymbolProfileOverrides.name ?? name; + + if ( + (SymbolProfileOverrides.sectors as unknown as Sector[])?.length > + 0 + ) { + sectorsCount = ( + SymbolProfileOverrides.sectors as unknown as Prisma.JsonArray + ).length; + } + } + + return { assetClass, assetSubClass, comment, - countries, + countriesCount, currency, dataSource, id, isActive, - isUsedByUsersWithSubscription, + lastMarketPrice, + marketDataItemCount, name, - sectors, + sectorsCount, symbol, - SymbolProfileOverrides - }) => { - let countriesCount = countries ? Object.keys(countries).length : 0; - - const lastMarketPrice = lastMarketPriceMap.get( - getAssetProfileIdentifier({ dataSource, symbol }) - ); - - const marketDataItemCount = - marketDataItems.find((marketDataItem) => { - return ( - marketDataItem.dataSource === dataSource && - marketDataItem.symbol === symbol - ); - })?._count ?? 0; - - let sectorsCount = sectors ? Object.keys(sectors).length : 0; - - if (SymbolProfileOverrides) { - assetClass = SymbolProfileOverrides.assetClass ?? assetClass; - assetSubClass = - SymbolProfileOverrides.assetSubClass ?? assetSubClass; - - if ( - ( - SymbolProfileOverrides.countries as unknown as Prisma.JsonArray - )?.length > 0 - ) { - countriesCount = ( - SymbolProfileOverrides.countries as unknown as Prisma.JsonArray - ).length; - } - - name = SymbolProfileOverrides.name ?? name; - - if ( - (SymbolProfileOverrides.sectors as unknown as Sector[]) - ?.length > 0 - ) { - sectorsCount = ( - SymbolProfileOverrides.sectors as unknown as Prisma.JsonArray - ).length; - } - } - - return { - assetClass, - assetSubClass, - comment, - currency, - countriesCount, - dataSource, - id, - isActive, - lastMarketPrice, - name, - symbol, - marketDataItemCount, - sectorsCount, - activitiesCount: _count.activities, - date: activities?.[0]?.date, - isUsedByUsersWithSubscription: - await isUsedByUsersWithSubscription, - watchedByCount: _count.watchedBy - }; - } - ) - ); - - if (presetId) { - if (presetId === 'ETF_WITHOUT_COUNTRIES') { - marketData = marketData.filter(({ countriesCount }) => { - return countriesCount === 0; - }); - } else if (presetId === 'ETF_WITHOUT_SECTORS') { - marketData = marketData.filter(({ sectorsCount }) => { - return sectorsCount === 0; - }); + activitiesCount: _count.activities, + date: activities?.[0]?.date, + isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription, + watchedByCount: _count.watchedBy + }; } + ) + ); - count = marketData.length; + if (presetId) { + if (presetId === 'ETF_WITHOUT_COUNTRIES') { + marketData = marketData.filter(({ countriesCount }) => { + return countriesCount === 0; + }); + } else if (presetId === 'ETF_WITHOUT_SECTORS') { + marketData = marketData.filter(({ sectorsCount }) => { + return sectorsCount === 0; + }); } - return { - count, - marketData - }; - } finally { - await extendedPrismaClient.$disconnect(); - - Logger.debug('Disconnect extended prisma client', 'AdminService'); + count = marketData.length; } + + return { + count, + marketData + }; } public async getMarketDataBySymbol({ @@ -704,8 +694,6 @@ export class AdminService { } private getExtendedPrismaClient() { - Logger.debug('Connect extended prisma client', 'AdminService'); - const symbolProfileExtension = Prisma.defineExtension((client) => { return client.$extends({ result: { @@ -746,7 +734,7 @@ export class AdminService { }); }); - return new PrismaClient().$extends(symbolProfileExtension); + return this.prismaService.$extends(symbolProfileExtension); } private async getMarketDataForCurrencies(): Promise { diff --git a/apps/api/src/app/endpoints/sitemap/sitemap.service.ts b/apps/api/src/app/endpoints/sitemap/sitemap.service.ts index e7e05330f..cf4b5052f 100644 --- a/apps/api/src/app/endpoints/sitemap/sitemap.service.ts +++ b/apps/api/src/app/endpoints/sitemap/sitemap.service.ts @@ -120,6 +120,10 @@ export class SitemapService { { languageCode: 'en', routerLink: ['2025', '11', 'black-weeks-2025'] + }, + { + languageCode: 'en', + routerLink: ['2026', '04', 'ghostfolio-3'] } ] .map(({ languageCode, routerLink }) => { diff --git a/apps/api/src/middlewares/html-template.middleware.ts b/apps/api/src/middlewares/html-template.middleware.ts index c958718f6..2b8820e81 100644 --- a/apps/api/src/middlewares/html-template.middleware.ts +++ b/apps/api/src/middlewares/html-template.middleware.ts @@ -83,6 +83,10 @@ const locales = { '/en/blog/2025/11/black-weeks-2025': { featureGraphicPath: 'assets/images/blog/black-weeks-2025.jpg', title: `Black Weeks 2025 - ${title}` + }, + '/en/blog/2026/04/ghostfolio-3': { + featureGraphicPath: 'assets/images/blog/ghostfolio-3.jpg', + title: `Announcing Ghostfolio 3.0 - ${title}` } }; diff --git a/apps/api/src/services/prisma/prisma.service.ts b/apps/api/src/services/prisma/prisma.service.ts index 4673cbd19..cdbc1cdfd 100644 --- a/apps/api/src/services/prisma/prisma.service.ts +++ b/apps/api/src/services/prisma/prisma.service.ts @@ -6,6 +6,7 @@ import { OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { PrismaPg } from '@prisma/adapter-pg'; import { Prisma, PrismaClient } from '@prisma/client'; @Injectable() @@ -14,6 +15,10 @@ export class PrismaService implements OnModuleInit, OnModuleDestroy { public constructor(configService: ConfigService) { + const adapter = new PrismaPg({ + connectionString: configService.get('DATABASE_URL') + }); + let customLogLevels: LogLevel[]; try { @@ -28,6 +33,7 @@ export class PrismaService : []; super({ + adapter, log, errorFormat: 'colorless' }); diff --git a/apps/client/src/app/app.component.ts b/apps/client/src/app/app.component.ts index 201c2f994..dbe616dac 100644 --- a/apps/client/src/app/app.component.ts +++ b/apps/client/src/app/app.component.ts @@ -336,7 +336,9 @@ export class GfAppComponent implements OnInit { if (isDarkTheme) { this.document.body.classList.add('theme-dark'); + this.document.body.classList.remove('theme-light'); } else { + this.document.body.classList.add('theme-light'); this.document.body.classList.remove('theme-dark'); } diff --git a/apps/client/src/app/components/header/header.component.html b/apps/client/src/app/components/header/header.component.html index 501119b31..94421498c 100644 --- a/apps/client/src/app/components/header/header.component.html +++ b/apps/client/src/app/components/header/header.component.html @@ -2,7 +2,7 @@ @if (user) {
  • +
  • @if (currentRoute !== 'register' && hasPermissionToCreateUser) {
  • +
    +
    +
    +
    +

    Ghostfolio 3.0

    +
    2026-04-23
    + Ghostfolio 3.0 Teaser +
    +
    +

    + Since the + last major version + of Ghostfolio, we have shipped + over 250 releases. The project now counts 275+ contributors from + around the globe and has surpassed 2’300’000 pulls on Docker Hub. + These milestones reflect steady adoption and our focus on + simplifying investment tracking while prioritizing user privacy. +

    +

    + Today’s release marks the next major version in our Open Source + Software (OSS) journey. +

    +
    +
    +

    Introducing Ghostfolio 3.0

    +

    + Ghostfolio 3.0 is the evolution of our + open source wealth management software, with meaningful improvements for both users and developers. We + have refreshed the user interface, expanded analytics, improved + stability, added more languages, and updated the technology stack to + support these changes. Here is a closer look at a selection of what + you can expect from this + release, alongside + many smaller additions and enhancements. +

    +

    Refreshed User Interface

    +

    + Ghostfolio 3.0 comes with a refreshed user interface that modernizes + the visual appearance of the application. The updated design is + cleaner, with refined components and improved consistency across the + platform. +

    +

    Comprehensive Analytics

    +

    + This release provides a broader set of tools to help you understand + your portfolio. Ghostfolio X-ray uses static analysis to highlight + potential issues and risks, with rules that can now be customized to + match your investment strategy. +

    +

    Extended Multilanguage Support

    +

    + Largely driven by contributions from the community, Ghostfolio now + supports more languages than ever. The application is available in a + growing number of languages, making it accessible to a broader + audience of investors around the world. +

    +

    Reliable Stability

    +

    + A wealth management platform needs to be reliable. With Ghostfolio + 3.0, we have further strengthened the robustness of our + architecture, so you can rely on Ghostfolio across different + market conditions. +

    +

    Empowering Self-Hosting

    +

    + This release simplifies and extends the self-hosting experience. A + major addition is that self-hosters can now fully benefit from + Ghostfolio Premium to + make use of a professional data provider. This gives you full + control over your infrastructure while still having access to + high-quality market data for portfolio analytics. +

    +

    Updated Technology Stack

    +

    + Under the hood, Ghostfolio 3.0 has been upgraded to + Angular 21, + Nest.js 11, + Prisma 7, and + Nx 22. Keeping the + technology stack up to date helps us provide a solid foundation for + users and developers. +

    +
    +
    +

    Thriving Ghostfolio Community

    +

    + Ghostfolio is built in public, and its community plays a central + role in shaping the open source project. Here are some highlights of + the community growth: +

    +
      +
    • + Ghostfolio has accumulated 8’000+ stars on + GitHub, reflecting the growing interest and trust in the project. +
    • +
    • + The + Slack + community has grown to over 1’250 members, where + investors exchange ideas and help each other. +
    • +
    • + Over + 700 investors and personal finance enthusiasts + follow Ghostfolio on + X + (formerly Twitter) for updates and discussions. +
    • +
    +

    + There is much more to come. If you are not part of the community + yet, we would love to have you on board. +

    +

    + Join our Slack community: Connect with fellow + investors, share insights, and stay updated by joining our + Slack + community. +

    +

    + Follow us on X: For release updates and market + insights, follow + Ghostfolio on X + to stay informed. +

    +

    + Give us a Star: If Ghostfolio has been useful to + you, please consider giving us a star on + GitHub. Your support helps us continue improving Ghostfolio. +

    +

    + Become a contributor: Interested in getting + involved? We welcome contributions from developers who are willing + to dive into open source and personal finance topics. + Join our developer community + and help shape the future of Ghostfolio. +

    +
    +
    +

    + Ghostfolio 3.0 is the result of countless contributions, feedback, + and shared passion for open source and personal finance. Whether you + have been with us from the start or are just discovering the + project, thank you for being part of this community. +

    +

    Thomas from Ghostfolio

    +
    +
    +
      +
    • + Angular +
    • +
    • + Announcement +
    • +
    • + Collaboration +
    • +
    • + Community +
    • +
    • + Contribution +
    • +
    • + Evolution +
    • +
    • + Fintech +
    • +
    • + Ghostfolio +
    • +
    • + Ghostfolio 3.0 +
    • +
    • + Ghostfolio Premium +
    • +
    • + Internationalization +
    • +
    • + Investment +
    • +
    • + Nest.js +
    • +
    • + Nx +
    • +
    • + Open Source +
    • +
    • + OSS +
    • +
    • + Personal Finance +
    • +
    • + Platform +
    • +
    • + Portfolio +
    • +
    • + Prisma +
    • +
    • + Privacy +
    • +
    • + Release +
    • +
    • + Self-Hosting +
    • +
    • + Software +
    • +
    • + Stack +
    • +
    • + Technology +
    • +
    • + Wealth Management +
    • +
    +
    + +
    +
    +
    +
  • diff --git a/apps/client/src/app/pages/blog/blog-page.html b/apps/client/src/app/pages/blog/blog-page.html index e84cb303d..d60fd1bcf 100644 --- a/apps/client/src/app/pages/blog/blog-page.html +++ b/apps/client/src/app/pages/blog/blog-page.html @@ -8,6 +8,32 @@ finance + + + + + @if (hasPermissionForSubscription) { diff --git a/apps/client/src/app/pages/blog/blog-page.routes.ts b/apps/client/src/app/pages/blog/blog-page.routes.ts index 90d27bdfb..9afb35d46 100644 --- a/apps/client/src/app/pages/blog/blog-page.routes.ts +++ b/apps/client/src/app/pages/blog/blog-page.routes.ts @@ -218,5 +218,14 @@ export const routes: Routes = [ (c) => c.BlackWeeks2025PageComponent ), title: 'Black Weeks 2025' + }, + { + canActivate: [AuthGuard], + path: '2026/04/ghostfolio-3', + loadComponent: () => + import('./2026/04/ghostfolio-3/ghostfolio-3-page.component').then( + (c) => c.Ghostfolio3PageComponent + ), + title: 'Ghostfolio 3.0' } ]; diff --git a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html index c4e8f610b..4c5c61bd8 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html @@ -75,70 +75,68 @@ } - @if (user?.settings?.isExperimentalFeatures) { -
    -
    - - - Total amount - - -
    -
    - - - Change with currency effect - - -
    -
    - - - Performance with currency effect - - -
    +
    +
    + + + Total amount + +
    - } +
    + + + Change with currency effect + + +
    +
    + + + Performance with currency effect + + +
    +
    diff --git a/apps/client/src/assets/images/blog/ghostfolio-3.jpg b/apps/client/src/assets/images/blog/ghostfolio-3.jpg new file mode 100644 index 0000000000000000000000000000000000000000..2a2c787e897a009f16df2a84e2f4849c0cb1fd36 GIT binary patch literal 224143 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

    53?Ti!;=Jliq@PHyf)-9t>F0oyC$dIef{i`^tNk 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 TL~ZYi1xEs+(zw|0V*K9#/src/test-setup.ts'], globals: {}, transform: { '^.+\\.[tj]sx?$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }] diff --git a/libs/common/src/test-setup.ts b/libs/common/src/test-setup.ts new file mode 100644 index 000000000..75cf71ea5 --- /dev/null +++ b/libs/common/src/test-setup.ts @@ -0,0 +1,3 @@ +import { TextDecoder, TextEncoder } from 'node:util'; + +Object.assign(global, { TextDecoder, TextEncoder }); diff --git a/package-lock.json b/package-lock.json index 6117044e0..7b05e00ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ghostfolio", - "version": "2.255.0", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ghostfolio", - "version": "2.255.0", + "version": "3.0.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { @@ -41,7 +41,8 @@ "@nestjs/schedule": "6.1.3", "@nestjs/serve-static": "5.0.5", "@openrouter/ai-sdk-provider": "0.7.2", - "@prisma/client": "6.19.3", + "@prisma/adapter-pg": "7.7.0", + "@prisma/client": "7.7.0", "@simplewebauthn/browser": "13.2.2", "@simplewebauthn/server": "13.2.2", "ai": "4.3.16", @@ -150,7 +151,7 @@ "nx": "22.6.5", "prettier": "3.8.2", "prettier-plugin-organize-attributes": "1.0.0", - "prisma": "6.19.3", + "prisma": "7.7.0", "react": "18.2.0", "react-dom": "18.2.0", "replace-in-file": "8.4.0", @@ -3891,6 +3892,36 @@ "node": ">=14.17.0" } }, + "node_modules/@electric-sql/pglite": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz", + "integrity": "sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@electric-sql/pglite-socket": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.1.tgz", + "integrity": "sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "pglite-server": "dist/scripts/server.js" + }, + "peerDependencies": { + "@electric-sql/pglite": "0.4.1" + } + }, + "node_modules/@electric-sql/pglite-tools": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.3.1.tgz", + "integrity": "sha512-C+T3oivmy9bpQvSxVqXA1UDY8cB9Eb9vZHL9zxWwEUfDixbXv4G3r2LjoTdR33LD8aomR3O9ZXEO3XEwr/cUCA==", + "devOptional": true, + "license": "Apache-2.0", + "peerDependencies": { + "@electric-sql/pglite": "0.4.1" + } + }, "node_modules/@emnapi/core": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", @@ -4533,10 +4564,10 @@ "license": "MIT" }, "node_modules/@hono/node-server": { - "version": "1.19.13", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz", - "integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==", - "dev": true, + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", + "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=18.14.1" @@ -11339,18 +11370,32 @@ "dev": true, "license": "MIT" }, + "node_modules/@prisma/adapter-pg": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.7.0.tgz", + "integrity": "sha512-q33Ta8sKbgzEpAy0lx45tAq//yMv0qcb+8nj+TCA3P4wiAY+OBFEFk/NDkZncAfHaNJeGo5WJpJdpbL+ijYx8g==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/driver-adapter-utils": "7.7.0", + "@types/pg": "^8.16.0", + "pg": "^8.16.3", + "postgres-array": "3.0.4" + } + }, "node_modules/@prisma/client": { - "version": "6.19.3", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz", - "integrity": "sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==", - "hasInstallScript": true, + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.7.0.tgz", + "integrity": "sha512-5Ar4OsZpJ54s21sy5oDNNW9gQtd4NuxCaiM7+JDTOU07D6VvlpLjYzAVCMB1+JzokN+08dAVomlx+b7bhJd3ww==", "license": "Apache-2.0", + "dependencies": { + "@prisma/client-runtime-utils": "7.7.0" + }, "engines": { - "node": ">=18.18" + "node": "^20.19 || ^22.12 || >=24.0" }, "peerDependencies": { "prisma": "*", - "typescript": ">=5.1.0" + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { "prisma": { @@ -11361,67 +11406,338 @@ } } }, + "node_modules/@prisma/client-runtime-utils": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.7.0.tgz", + "integrity": "sha512-BLyd0UpFYOtyJFTHm7jS9vesHW7P83abibodQMiIofqjBKzDHQ1VAsQkdfvXyYDkPlONPfOTz7/rv3x/+CQqvQ==", + "license": "Apache-2.0" + }, "node_modules/@prisma/config": { - "version": "6.19.3", - "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz", - "integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==", + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.7.0.tgz", + "integrity": "sha512-hmPI3tKLO2aP0Y5vugbjcnA9qqlfJndiT6ds4tw28U5hNHLWg+mHJEWAhjsSPgxjtmxhJ/EDIeIlyh+3Us0OPg==", "devOptional": true, "license": "Apache-2.0", "dependencies": { "c12": "3.1.0", "deepmerge-ts": "7.1.5", - "effect": "3.21.0", + "effect": "3.20.0", "empathic": "2.0.0" } }, "node_modules/@prisma/debug": { - "version": "6.19.3", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz", - "integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==", - "devOptional": true, + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.7.0.tgz", + "integrity": "sha512-12J62XdqCmpiwJHhHdQxZeY3ckVCWIFmcJP8hg5dPTceeiQ0wiojXGFYTluKqFQfu46fRLgb/rLALZMAx3+dTA==", "license": "Apache-2.0" }, + "node_modules/@prisma/dev": { + "version": "0.24.3", + "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.3.tgz", + "integrity": "sha512-ffHlQuKXZiaDt9Go0OnCTdJZrHxK0k7omJKNV86/VjpsXu5EIHZLK0T7JSWgvNlJwh56kW9JFu9v0qJciFzepg==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "@electric-sql/pglite": "0.4.1", + "@electric-sql/pglite-socket": "0.1.1", + "@electric-sql/pglite-tools": "0.3.1", + "@hono/node-server": "1.19.11", + "@prisma/get-platform": "7.2.0", + "@prisma/query-plan-executor": "7.2.0", + "@prisma/streams-local": "0.1.2", + "foreground-child": "3.3.1", + "get-port-please": "3.2.0", + "hono": "^4.12.8", + "http-status-codes": "2.3.0", + "pathe": "2.0.3", + "proper-lockfile": "4.1.2", + "remeda": "2.33.4", + "std-env": "3.10.0", + "valibot": "1.2.0", + "zeptomatch": "2.1.0" + } + }, + "node_modules/@prisma/driver-adapter-utils": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.7.0.tgz", + "integrity": "sha512-gZXREeu6mOk7zXfGFJgh86p7Vhj0sXNKp+4Cg1tWYo7V2dfncP2qxS2BiTmbIIha8xPqItkl0WSw38RuSq1HoQ==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.7.0" + } + }, "node_modules/@prisma/engines": { - "version": "6.19.3", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz", - "integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==", + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.7.0.tgz", + "integrity": "sha512-7fmcbT7HHXBq/b+3h/dO1JI3fd8l8q7erf7xP7pRprh58hmSSnG8mg9K3yjW3h9WaHWUwngVFpSxxxivaitQ2w==", "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "6.19.3", - "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", - "@prisma/fetch-engine": "6.19.3", - "@prisma/get-platform": "6.19.3" + "@prisma/debug": "7.7.0", + "@prisma/engines-version": "7.6.0-1.75cbdc1eb7150937890ad5465d861175c6624711", + "@prisma/fetch-engine": "7.7.0", + "@prisma/get-platform": "7.7.0" } }, "node_modules/@prisma/engines-version": { - "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", - "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", + "version": "7.6.0-1.75cbdc1eb7150937890ad5465d861175c6624711", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.6.0-1.75cbdc1eb7150937890ad5465d861175c6624711.tgz", + "integrity": "sha512-r51DLcJ8bDRSrBEJF3J4cinoWyGA7rfP2mG6lD90VqIbGNOkbfcLcXalSVjq5Y6brQS3vcjrq4GbyUb1Cb7vkw==", "devOptional": true, "license": "Apache-2.0" }, + "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.7.0.tgz", + "integrity": "sha512-MEUNzvKxvYnJ7kgvd6oNRnMmmiGNS9TYLB2weMeIXplnHdL/UWEGnvavYGnN7KLJ2n0iI4dDAyzSkHI3c7AscQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.7.0" + } + }, "node_modules/@prisma/fetch-engine": { - "version": "6.19.3", - "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz", - "integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==", + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.7.0.tgz", + "integrity": "sha512-TfyzveBQoK4xALzsTpVhB/0KG1N8zOK0ap+RnBMkzGUu3f98fnQ4QtXa2wlKPhsO2X8a3N5ugFQgcKNoHGmDfw==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "6.19.3", - "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", - "@prisma/get-platform": "6.19.3" + "@prisma/debug": "7.7.0", + "@prisma/engines-version": "7.6.0-1.75cbdc1eb7150937890ad5465d861175c6624711", + "@prisma/get-platform": "7.7.0" + } + }, + "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.7.0.tgz", + "integrity": "sha512-MEUNzvKxvYnJ7kgvd6oNRnMmmiGNS9TYLB2weMeIXplnHdL/UWEGnvavYGnN7KLJ2n0iI4dDAyzSkHI3c7AscQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.7.0" } }, "node_modules/@prisma/get-platform": { - "version": "6.19.3", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz", - "integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz", + "integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.2.0" + } + }, + "node_modules/@prisma/get-platform/node_modules/@prisma/debug": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz", + "integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/query-plan-executor": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz", + "integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/streams-local": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@prisma/streams-local/-/streams-local-0.1.2.tgz", + "integrity": "sha512-l49yTxKKF2odFxaAXTmwmkBKL3+bVQ1tFOooGifu4xkdb9NMNLxHj27XAhTylWZod8I+ISGM5erU1xcl/oBCtg==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "6.19.3" + "ajv": "^8.12.0", + "better-result": "^2.7.0", + "env-paths": "^3.0.0", + "proper-lockfile": "^4.1.2" + }, + "engines": { + "bun": ">=1.3.6", + "node": ">=22.0.0" + } + }, + "node_modules/@prisma/streams-local/node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@prisma/studio-core": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.27.3.tgz", + "integrity": "sha512-AADjNFPdsrglxHQVTmHFqv6DuKQZ5WY4p5/gVFY017twvNrSwpLJ9lqUbYYxEu2W7nbvVxTZA8deJ8LseNALsw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@radix-ui/react-toggle": "1.1.10", + "chart.js": "4.5.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0", + "pnpm": "8" + }, + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, "node_modules/@redis/client": { @@ -14531,6 +14847,17 @@ "@types/passport": "*" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/qs": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", @@ -14546,10 +14873,10 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, + "version": "19.1.12", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz", + "integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==", + "devOptional": true, "license": "MIT", "peer": true, "dependencies": { @@ -16640,6 +16967,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/axios": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", @@ -16965,6 +17302,13 @@ "node": ">=18.0.0" } }, + "node_modules/better-result": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/better-result/-/better-result-2.8.2.tgz", + "integrity": "sha512-YOf0VSj5nUPI27doTtXF+BBnsiRq3qY7avHqfIWnppxTLGyvkLq1QV2RTxkwoZwJ60ywLfZ0raFF4J/G886i7A==", + "devOptional": true, + "license": "MIT" + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -18726,7 +19070,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -19631,7 +19975,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true }, @@ -20880,9 +21224,9 @@ "license": "MIT" }, "node_modules/effect": { - "version": "3.21.0", - "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz", - "integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==", + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz", + "integrity": "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==", "devOptional": true, "license": "MIT", "dependencies": { @@ -22677,7 +23021,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", @@ -23157,6 +23501,16 @@ "node": ">=10" } }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, "node_modules/generator-function": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", @@ -23252,6 +23606,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-port-please": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz", + "integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==", + "devOptional": true, + "license": "MIT" + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -23587,6 +23948,13 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/grammex": { + "version": "3.1.12", + "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz", + "integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -23594,6 +23962,13 @@ "dev": true, "license": "MIT" }, + "node_modules/graphmatch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.1.tgz", + "integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/gtoken": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-5.3.2.tgz", @@ -23784,10 +24159,10 @@ } }, "node_modules/hono": { - "version": "4.12.12", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz", - "integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==", - "dev": true, + "version": "4.12.14", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", + "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=16.9.0" @@ -24957,6 +25332,13 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "devOptional": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -25192,7 +25574,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/isobject": { @@ -29527,6 +29909,13 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "devOptional": true, + "license": "Apache-2.0" + }, "node_modules/long-timeout": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/long-timeout/-/long-timeout-0.1.1.tgz", @@ -29578,6 +29967,22 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "license": "ISC" }, + "node_modules/lru.min": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.3.tgz", + "integrity": "sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==", + "devOptional": true, + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, "node_modules/luxon": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", @@ -30238,6 +30643,57 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/mysql2": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", + "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.1", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.0", + "long": "^5.2.1", + "lru.min": "^1.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/mysql2/node_modules/iconv-lite": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz", + "integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -32090,7 +32546,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -32176,6 +32632,104 @@ "devOptional": true, "license": "MIT" }, + "node_modules/pg": { + "version": "8.16.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", + "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.9.1", + "pg-pool": "^3.10.1", + "pg-protocol": "^1.10.3", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.2.7" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz", + "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz", + "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz", + "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", + "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pg-types/node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -32663,6 +33217,59 @@ "dev": true, "license": "MIT" }, + "node_modules/postgres": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", + "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", + "devOptional": true, + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, + "node_modules/postgres-array": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", + "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/powershell-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", @@ -32755,26 +33362,34 @@ } }, "node_modules/prisma": { - "version": "6.19.3", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz", - "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.7.0.tgz", + "integrity": "sha512-HlgwRBt1uEFB9LStHL4HLYDvoi4BNu1rYA0hPG0zCAEyK9SaZBqp7E5Rjpc3Qh8Lex/ye/svoHZ0OWoFNhWxuQ==", "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@prisma/config": "6.19.3", - "@prisma/engines": "6.19.3" + "@prisma/config": "7.7.0", + "@prisma/dev": "0.24.3", + "@prisma/engines": "7.7.0", + "@prisma/studio-core": "0.27.3", + "mysql2": "3.15.3", + "postgres": "3.4.7" }, "bin": { "prisma": "build/index.js" }, "engines": { - "node": ">=18.18" + "node": "^20.19 || ^22.12 || >=24.0" }, "peerDependencies": { - "typescript": ">=5.1.0" + "better-sqlite3": ">=9.0.0", + "typescript": ">=5.4.0" }, "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true + }, "typescript": { "optional": true } @@ -32832,6 +33447,35 @@ "node": ">= 4" } }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/proper-lockfile/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "devOptional": true, + "license": "ISC" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -33031,7 +33675,7 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0", @@ -33304,6 +33948,16 @@ "node": ">= 0.10" } }, + "node_modules/remeda": { + "version": "2.33.4", + "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", + "integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/remeda" + } + }, "node_modules/renderkid": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz", @@ -34661,7 +35315,7 @@ "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" @@ -34791,6 +35445,12 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==", + "devOptional": true + }, "node_modules/serialize-javascript": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz", @@ -35016,7 +35676,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -35029,7 +35689,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -35582,6 +36242,15 @@ "wbuf": "^1.7.3" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -35589,6 +36258,16 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/ssri": { "version": "13.0.1", "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.1.tgz", @@ -35656,6 +36335,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.1.tgz", @@ -36632,9 +37318,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", - "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", "devOptional": true, "license": "MIT", "engines": { @@ -38062,6 +38748,21 @@ "dev": true, "license": "MIT" }, + "node_modules/valibot": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", + "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/validate-npm-package-name": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", @@ -39234,7 +39935,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -39540,6 +40241,15 @@ "sax": "^1.2.4" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -39796,6 +40506,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zeptomatch": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz", + "integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "grammex": "^3.1.11", + "graphmatch": "^1.1.0" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/package.json b/package.json index 9203f2e30..bd0824c30 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostfolio", - "version": "2.255.0", + "version": "3.0.0", "homepage": "https://ghostfol.io", "license": "AGPL-3.0", "repository": "https://github.com/ghostfolio/ghostfolio", @@ -86,7 +86,8 @@ "@nestjs/schedule": "6.1.3", "@nestjs/serve-static": "5.0.5", "@openrouter/ai-sdk-provider": "0.7.2", - "@prisma/client": "6.19.3", + "@prisma/adapter-pg": "7.7.0", + "@prisma/client": "7.7.0", "@simplewebauthn/browser": "13.2.2", "@simplewebauthn/server": "13.2.2", "ai": "4.3.16", @@ -195,7 +196,7 @@ "nx": "22.6.5", "prettier": "3.8.2", "prettier-plugin-organize-attributes": "1.0.0", - "prisma": "6.19.3", + "prisma": "7.7.0", "react": "18.2.0", "react-dom": "18.2.0", "replace-in-file": "8.4.0", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 069ed6279..a739ce8ff 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -6,7 +6,6 @@ generator client { datasource db { provider = "postgresql" - url = env("DATABASE_URL") } model Access { diff --git a/prisma/seed.mts b/prisma/seed.mts index 18389aab1..4819e241e 100644 --- a/prisma/seed.mts +++ b/prisma/seed.mts @@ -1,6 +1,11 @@ +import { PrismaPg } from '@prisma/adapter-pg'; import { PrismaClient } from '@prisma/client'; -const prisma = new PrismaClient(); +const adapter = new PrismaPg({ + connectionString: process.env.DATABASE_URL +}); + +const prisma = new PrismaClient({ adapter }); async function main() { await prisma.tag.createMany({ From 53dd3039c7524a9c20b738586c9411f121b57d9d Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 24 Apr 2026 17:00:38 +0200 Subject: [PATCH 011/132] Task/upgrade prettier to version 3.8.3 (#6769) * Upgrade prettier to version 3.8.3 * Update changelog --- CHANGELOG.md | 6 ++++++ package-lock.json | 8 ++++---- package.json | 2 +- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fc82ec50..cbccc5eb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Changed + +- Upgraded `prettier` from version `3.8.2` to `3.8.3` + ## 3.0.0 - 2026-04-23 ### Added diff --git a/package-lock.json b/package-lock.json index 7b05e00ff..6e0556cea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -149,7 +149,7 @@ "jest-environment-jsdom": "30.2.0", "jest-preset-angular": "16.0.0", "nx": "22.6.5", - "prettier": "3.8.2", + "prettier": "3.8.3", "prettier-plugin-organize-attributes": "1.0.0", "prisma": "7.7.0", "react": "18.2.0", @@ -33294,9 +33294,9 @@ } }, "node_modules/prettier": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.2.tgz", - "integrity": "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==", + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true, "license": "MIT", "bin": { diff --git a/package.json b/package.json index bd0824c30..af30fac17 100644 --- a/package.json +++ b/package.json @@ -194,7 +194,7 @@ "jest-environment-jsdom": "30.2.0", "jest-preset-angular": "16.0.0", "nx": "22.6.5", - "prettier": "3.8.2", + "prettier": "3.8.3", "prettier-plugin-organize-attributes": "1.0.0", "prisma": "7.7.0", "react": "18.2.0", From 75e2b855c0880ed443f39b490c4842a8eba83b1c Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 25 Apr 2026 11:00:02 +0200 Subject: [PATCH 012/132] Task/various style improvements (#6780) * Various style improvements * Update changelog --- CHANGELOG.md | 1 + .../admin-market-data.component.ts | 2 +- .../asset-profile-dialog.html | 2 +- .../create-or-update-platform-dialog.html | 2 +- .../login-with-access-token-dialog.html | 8 +++++--- .../user-account-access/user-account-access.html | 4 +++- .../user-account-settings.html | 4 +++- .../src/app/pages/register/register-page.html | 4 ++-- .../activities-filter.component.html | 15 ++++----------- 9 files changed, 21 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cbccc5eb1..7f861d45d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Improved the styling of buttons and input fields across various components - Upgraded `prettier` from version `3.8.2` to `3.8.3` ## 3.0.0 - 2026-04-23 diff --git a/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts b/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts index d3265946f..fd96c94fc 100644 --- a/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts +++ b/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts @@ -157,7 +157,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { public ghostfolioScraperApiSymbolPrefix = ghostfolioScraperApiSymbolPrefix; public hasPermissionForSubscription: boolean; public info: InfoItem; - public isLoading = false; + public isLoading = true; public isUUID = isUUID; public placeholder = ''; public pageSize = DEFAULT_PAGE_SIZE; diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html index cfae840a8..32ae24eb2 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html @@ -421,7 +421,7 @@ @if (assetProfileForm.controls.url.value) { diff --git a/apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-platform-dialog.html b/apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-platform-dialog.html index 0e39d7653..50193c2cc 100644 --- a/apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-platform-dialog.html +++ b/apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-platform-dialog.html @@ -30,7 +30,7 @@ /> @if (platformForm.get('url')?.value) { diff --git a/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html b/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html index 78604456b..4f4bb1867 100644 --- a/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html +++ b/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html @@ -13,12 +13,14 @@ (keydown.enter)="onLoginWithAccessToken(); $event.preventDefault()" /> @@ -34,7 +36,7 @@ @if (data.hasPermissionToUseAuthGoogle) {

    Sign in with OpenID Connect diff --git a/apps/client/src/app/components/user-account-settings/user-account-settings.html b/apps/client/src/app/components/user-account-settings/user-account-settings.html index 93ed614cc..f646ef0fd 100644 --- a/apps/client/src/app/components/user-account-settings/user-account-settings.html +++ b/apps/client/src/app/components/user-account-settings/user-account-settings.html @@ -292,12 +292,14 @@ [type]="isAccessTokenHidden ? 'password' : 'text'" /> + @for (filter of selectedFilters; track filter) { - + @if (isLoading()) { + + } From b455e9278e358e611ffbe03d9d94a4624eee809f Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 26 Apr 2026 08:21:21 +0200 Subject: [PATCH 013/132] Task/move copy-to-clipboard button for ISIN and symbol in holding detail dialog to general availability (#6781) * Move copy-to-clipboard buttons from experimental to general availability * Update changelog --- CHANGELOG.md | 2 ++ .../holding-detail-dialog/holding-detail-dialog.html | 8 ++------ 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f861d45d..53d484f9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Moved the copy-to-clipboard button for the ISIN number in the holding detail dialog from experimental to general availability +- Moved the copy-to-clipboard button for the symbol in the holding detail dialog from experimental to general availability - Improved the styling of buttons and input fields across various components - Upgraded `prettier` from version `3.8.2` to `3.8.3` diff --git a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html index 11771dee2..19d938235 100644 --- a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html +++ b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html @@ -310,9 +310,7 @@ Symbol ISIN Date: Sun, 26 Apr 2026 08:58:12 +0200 Subject: [PATCH 014/132] Bugfix/fix cash label in holdings table (#6783) * Fix cash label (assetSubClassLabel) * Update changelog --- CHANGELOG.md | 5 +++++ .../holdings-table/holdings-table.component.html | 2 +- libs/ui/src/lib/services/data.service.ts | 16 ++++++++++------ 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53d484f9c..e41fa319c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improved the styling of buttons and input fields across various components - Upgraded `prettier` from version `3.8.2` to `3.8.3` +### Fixed + +- Fixed the cash label in the holdings table of the portfolio holdings page +- Fixed the cash label in the holdings table of the public page + ## 3.0.0 - 2026-04-23 ### Added diff --git a/libs/ui/src/lib/holdings-table/holdings-table.component.html b/libs/ui/src/lib/holdings-table/holdings-table.component.html index 3bb387ae4..dfb39ec8a 100644 --- a/libs/ui/src/lib/holdings-table/holdings-table.component.html +++ b/libs/ui/src/lib/holdings-table/holdings-table.component.html @@ -26,7 +26,7 @@
    {{ element.name }} @if (element.name === element.symbol) { - ({{ element.assetSubClassLabel }}) + ({{ element.assetProfile.assetSubClassLabel }}) }
    diff --git a/libs/ui/src/lib/services/data.service.ts b/libs/ui/src/lib/services/data.service.ts index 7f2dac0b1..44cef1aed 100644 --- a/libs/ui/src/lib/services/data.service.ts +++ b/libs/ui/src/lib/services/data.service.ts @@ -610,13 +610,11 @@ export class DataService { map((response) => { if (response.holdings) { for (const symbol of Object.keys(response.holdings)) { - response.holdings[symbol].assetClassLabel = translate( - response.holdings[symbol].assetClass - ); + response.holdings[symbol].assetProfile.assetClassLabel = + translate(response.holdings[symbol].assetProfile.assetClass); - response.holdings[symbol].assetSubClassLabel = translate( - response.holdings[symbol].assetSubClass - ); + response.holdings[symbol].assetProfile.assetSubClassLabel = + translate(response.holdings[symbol].assetProfile.assetSubClass); response.holdings[symbol].dateOfFirstActivity = response.holdings[ symbol @@ -699,6 +697,12 @@ export class DataService { map((response) => { if (response.holdings) { for (const symbol of Object.keys(response.holdings)) { + response.holdings[symbol].assetProfile.assetClassLabel = + translate(response.holdings[symbol].assetProfile.assetClass); + + response.holdings[symbol].assetProfile.assetSubClassLabel = + translate(response.holdings[symbol].assetProfile.assetSubClass); + response.holdings[symbol].valueInBaseCurrency = isNumber( response.holdings[symbol].valueInBaseCurrency ) From 0a726644b04dc9e954711461a9613f0942b9e194 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 26 Apr 2026 09:04:56 +0200 Subject: [PATCH 015/132] Release 3.0.1 (#6785) --- CHANGELOG.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e41fa319c..27e3dcb1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## Unreleased +## 3.0.1 - 2026-04-26 ### Changed diff --git a/package-lock.json b/package-lock.json index 6e0556cea..8372e22d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ghostfolio", - "version": "3.0.0", + "version": "3.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ghostfolio", - "version": "3.0.0", + "version": "3.0.1", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/package.json b/package.json index af30fac17..169d8ae18 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostfolio", - "version": "3.0.0", + "version": "3.0.1", "homepage": "https://ghostfol.io", "license": "AGPL-3.0", "repository": "https://github.com/ghostfolio/ghostfolio", From cca34124a954220fe36dee4da42ad22edfcbde5b Mon Sep 17 00:00:00 2001 From: lechtidu56 Date: Sun, 26 Apr 2026 18:12:37 +0200 Subject: [PATCH 016/132] Task/various improvements in Storybook (#6782) * Resolve missing @angular/material styles * Add themes selector * Update changelog --------- Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> --- CHANGELOG.md | 10 ++++++++++ libs/ui/.storybook/main.mjs | 5 ++++- libs/ui/.storybook/preview.js | 20 ++++++++++++++++++++ package-lock.json | 18 ++++++++++++++++++ package.json | 1 + 5 files changed, 53 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27e3dcb1f..8f1071e84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- Integrated a theme switcher into _Storybook_ to support toggling between the light and dark mode + +### Fixed + +- Fixed the _Storybook_ setup by resolving missing `@angular/material` styles + ## 3.0.1 - 2026-04-26 ### Changed diff --git a/libs/ui/.storybook/main.mjs b/libs/ui/.storybook/main.mjs index e7d1378c7..28a7854e3 100644 --- a/libs/ui/.storybook/main.mjs +++ b/libs/ui/.storybook/main.mjs @@ -5,7 +5,10 @@ const require = createRequire(import.meta.url); /** @type {import('@storybook/angular').StorybookConfig} */ const config = { - addons: [getAbsolutePath('@storybook/addon-docs')], + addons: [ + getAbsolutePath('@storybook/addon-docs'), + getAbsolutePath('@storybook/addon-themes') + ], framework: { name: getAbsolutePath('@storybook/angular'), options: {} diff --git a/libs/ui/.storybook/preview.js b/libs/ui/.storybook/preview.js index e69de29bb..e8e2fe282 100644 --- a/libs/ui/.storybook/preview.js +++ b/libs/ui/.storybook/preview.js @@ -0,0 +1,20 @@ +import { withThemeByClassName } from '@storybook/addon-themes'; + +const preview = { + decorators: [ + withThemeByClassName({ + defaultTheme: 'Light', + parentSelector: 'body', + themes: { + Dark: 'theme-dark', + Light: 'theme-light' + } + }) + ] +}; + +if (typeof document !== 'undefined') { + document.body.classList.add('mat-typography'); +} + +export default preview; diff --git a/package-lock.json b/package-lock.json index 8372e22d0..f58dc8e7a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -125,6 +125,7 @@ "@nx/workspace": "22.6.5", "@schematics/angular": "21.2.6", "@storybook/addon-docs": "10.1.10", + "@storybook/addon-themes": "10.1.10", "@storybook/angular": "10.1.10", "@trivago/prettier-plugin-sort-imports": "6.0.2", "@types/big.js": "6.2.2", @@ -13462,6 +13463,23 @@ "storybook": "^10.1.10" } }, + "node_modules/@storybook/addon-themes": { + "version": "10.1.10", + "resolved": "https://registry.npmjs.org/@storybook/addon-themes/-/addon-themes-10.1.10.tgz", + "integrity": "sha512-YlTzREQnUFZ6wepo4MppiobkFrsF1EuObh+vaEhjEj5Cs1oH+kqP5Db+rXi8rbrxnVXaWKmDgqZMtB7kVN4Dnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^10.1.10" + } + }, "node_modules/@storybook/angular": { "version": "10.1.10", "resolved": "https://registry.npmjs.org/@storybook/angular/-/angular-10.1.10.tgz", diff --git a/package.json b/package.json index 169d8ae18..9b440f394 100644 --- a/package.json +++ b/package.json @@ -170,6 +170,7 @@ "@nx/workspace": "22.6.5", "@schematics/angular": "21.2.6", "@storybook/addon-docs": "10.1.10", + "@storybook/addon-themes": "10.1.10", "@storybook/angular": "10.1.10", "@trivago/prettier-plugin-sort-imports": "6.0.2", "@types/big.js": "6.2.2", From 8f5dbcee56a38d982952ace20ac6b8c649d52a86 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 26 Apr 2026 21:37:24 +0200 Subject: [PATCH 017/132] Task/optimize logo spacing in header (#6788) * Optimize spacing * Update changelog --- CHANGELOG.md | 1 + .../client/src/app/components/header/header.component.html | 7 +++---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f1071e84..c4c16ad76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Optimized the spacing of the logo in the header - Fixed the _Storybook_ setup by resolving missing `@angular/material` styles ## 3.0.1 - 2026-04-26 diff --git a/apps/client/src/app/components/header/header.component.html b/apps/client/src/app/components/header/header.component.html index 94421498c..ab5820e2d 100644 --- a/apps/client/src/app/components/header/header.component.html +++ b/apps/client/src/app/components/header/header.component.html @@ -2,13 +2,13 @@ @if (user) { @@ -334,13 +334,12 @@ @if (user === null) {
    From e399702992a3fed3377f62a74a2b26c90e4f59a8 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:30:36 +0200 Subject: [PATCH 018/132] Task/modernize layout of overview tab in admin control panel (#6793) * Modernize layout * Update changelog --- CHANGELOG.md | 4 + .../admin-overview.component.ts | 15 ++++ .../admin-overview/admin-overview.html | 73 +++++++++++-------- 3 files changed, 62 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4c16ad76..1664e56d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Integrated a theme switcher into _Storybook_ to support toggling between the light and dark mode +### Changed + +- Modernized the layout of the overview tab in the admin control panel + ### Fixed - Optimized the spacing of the logo in the header diff --git a/apps/client/src/app/components/admin-overview/admin-overview.component.ts b/apps/client/src/app/components/admin-overview/admin-overview.component.ts index ff55bb2bc..9b92c5b11 100644 --- a/apps/client/src/app/components/admin-overview/admin-overview.component.ts +++ b/apps/client/src/app/components/admin-overview/admin-overview.component.ts @@ -144,6 +144,21 @@ export class GfAdminOverviewComponent implements OnInit { }); } + public get activitiesCountPerUser() { + if (!this.activitiesCount || !this.userCount) { + return undefined; + } + + const formattedActivitiesCountPerUser = ( + this.activitiesCount / this.userCount + ).toLocaleString(this.user?.settings?.locale, { + maximumFractionDigits: 2, + minimumFractionDigits: 2 + }); + + return `(${formattedActivitiesCountPerUser} ${$localize`per User`})`; + } + public ngOnInit() { this.fetchAdminData(); } diff --git a/apps/client/src/app/components/admin-overview/admin-overview.html b/apps/client/src/app/components/admin-overview/admin-overview.html index 3a8a81024..5514c57d0 100644 --- a/apps/client/src/app/components/admin-overview/admin-overview.html +++ b/apps/client/src/app/components/admin-overview/admin-overview.html @@ -1,38 +1,51 @@
    +
    +
    + + + Version + + +
    +
    + + + Users + + +
    +
    + + + Activities + + +
    +
    +
    -
    -
    Version
    -
    - -
    -
    -
    -
    User Count
    -
    - -
    -
    -
    -
    Activity Count
    -
    - - @if (activitiesCount && userCount) { -
    - {{ activitiesCount / userCount | number: '1.2-2' }} - per User -
    - } -
    -
    User Signup
    From 827a65711edf8335dc0b067c09869744b401b6c4 Mon Sep 17 00:00:00 2001 From: David Requeno <108202767+DavidReque@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:58:39 -0600 Subject: [PATCH 019/132] Task/integrate EuroAlternative into logo carousel (#6796) * Integrate EuroAlternative logo * Update changelog --- CHANGELOG.md | 1 + apps/client/src/assets/images/logo-euroalternative.svg | 1 + .../ui/src/lib/logo-carousel/logo-carousel.component.scss | 4 ++++ libs/ui/src/lib/logo-carousel/logo-carousel.component.ts | 8 ++++++++ 4 files changed, 14 insertions(+) create mode 100644 apps/client/src/assets/images/logo-euroalternative.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index 1664e56d4..d78c31053 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added the _EuroAlternative_ logo to the logo carousel on the landing page - Integrated a theme switcher into _Storybook_ to support toggling between the light and dark mode ### Changed diff --git a/apps/client/src/assets/images/logo-euroalternative.svg b/apps/client/src/assets/images/logo-euroalternative.svg new file mode 100644 index 000000000..44e950857 --- /dev/null +++ b/apps/client/src/assets/images/logo-euroalternative.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/libs/ui/src/lib/logo-carousel/logo-carousel.component.scss b/libs/ui/src/lib/logo-carousel/logo-carousel.component.scss index 352f52ee8..0d1817959 100644 --- a/libs/ui/src/lib/logo-carousel/logo-carousel.component.scss +++ b/libs/ui/src/lib/logo-carousel/logo-carousel.component.scss @@ -105,6 +105,10 @@ mask-image: url('/assets/images/logo-dev-community.svg'); } + &.logo-euroalternative { + mask-image: url('/assets/images/logo-euroalternative.svg'); + } + &.logo-hacker-news { mask-image: url('/assets/images/logo-hacker-news.svg'); } diff --git a/libs/ui/src/lib/logo-carousel/logo-carousel.component.ts b/libs/ui/src/lib/logo-carousel/logo-carousel.component.ts index cf50017e6..3d6741d44 100644 --- a/libs/ui/src/lib/logo-carousel/logo-carousel.component.ts +++ b/libs/ui/src/lib/logo-carousel/logo-carousel.component.ts @@ -32,6 +32,14 @@ export class GfLogoCarouselComponent { 'DEV Community - A constructive and inclusive social network for software developers', url: 'https://dev.to' }, + { + className: 'logo-euroalternative', + isMask: true, + name: 'EuroAlternative', + title: + 'EuroAlternative - Discover European Alternatives to Big Tech Companies', + url: 'https://euroalternative.eu/ghostfolio' + }, { className: 'logo-hacker-news', isMask: true, From 98d1b9c2e4ec6370aa26c22416b9dc3924742da6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 20:15:31 +0200 Subject: [PATCH 020/132] Task/update locales (#6720) Co-authored-by: github-actions[bot] --- apps/client/src/locales/messages.ca.xlf | 240 ++++++++++++------------ apps/client/src/locales/messages.de.xlf | 240 ++++++++++++------------ apps/client/src/locales/messages.es.xlf | 240 ++++++++++++------------ apps/client/src/locales/messages.fr.xlf | 240 ++++++++++++------------ apps/client/src/locales/messages.it.xlf | 240 ++++++++++++------------ apps/client/src/locales/messages.ko.xlf | 240 ++++++++++++------------ apps/client/src/locales/messages.nl.xlf | 240 ++++++++++++------------ apps/client/src/locales/messages.pl.xlf | 240 ++++++++++++------------ apps/client/src/locales/messages.pt.xlf | 240 ++++++++++++------------ apps/client/src/locales/messages.tr.xlf | 240 ++++++++++++------------ apps/client/src/locales/messages.uk.xlf | 240 ++++++++++++------------ apps/client/src/locales/messages.xlf | 238 ++++++++++++----------- apps/client/src/locales/messages.zh.xlf | 240 ++++++++++++------------ 13 files changed, 1534 insertions(+), 1584 deletions(-) diff --git a/apps/client/src/locales/messages.ca.xlf b/apps/client/src/locales/messages.ca.xlf index 009d561a4..109af93e8 100644 --- a/apps/client/src/locales/messages.ca.xlf +++ b/apps/client/src/locales/messages.ca.xlf @@ -10,7 +10,7 @@ apps/client/src/app/components/header/header.component.html - 361 + 360 apps/client/src/app/pages/features/features-page.html @@ -34,7 +34,7 @@ Iniciar sessió apps/client/src/app/components/header/header.component.html - 422 + 425 apps/client/src/app/components/header/header.component.ts @@ -42,7 +42,7 @@ apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 79 + 81 libs/common/src/lib/routes/routes.ts @@ -371,7 +371,7 @@ Balanç de Caixa apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 45 + 43 apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html @@ -387,7 +387,7 @@ Plataforma apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 90 + 88 apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html @@ -403,7 +403,7 @@ Balanç de Caixa apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 146 + 142 @@ -615,7 +615,7 @@ apps/client/src/app/components/admin-overview/admin-overview.html - 140 + 153 apps/client/src/app/components/admin-platform/admin-platform.component.html @@ -1015,7 +1015,7 @@ El preu de mercat actual és apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 722 + 743 @@ -1167,7 +1167,7 @@ Asset profile has been saved apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 594 + 618 @@ -1235,7 +1235,7 @@ Està segur qeu vol eliminar aquest cupó? apps/client/src/app/components/admin-overview/admin-overview.component.ts - 196 + 214 @@ -1243,7 +1243,7 @@ Està segur que vol eliminar aquest missatge del sistema? apps/client/src/app/components/admin-overview/admin-overview.component.ts - 209 + 227 @@ -1251,7 +1251,7 @@ Està segur que vol depurar el cache? apps/client/src/app/components/admin-overview/admin-overview.component.ts - 233 + 251 @@ -1259,7 +1259,7 @@ Si us plau, afegeixi el seu missatge del sistema: apps/client/src/app/components/admin-overview/admin-overview.component.ts - 253 + 271 @@ -1267,31 +1267,15 @@ Versió apps/client/src/app/components/admin-overview/admin-overview.html - 7 - - - - User Count - Número d’Usuaris - - apps/client/src/app/components/admin-overview/admin-overview.html - 16 - - - - Activity Count - Número d’Activitats - - apps/client/src/app/components/admin-overview/admin-overview.html - 22 + 11 per User per Usuari - apps/client/src/app/components/admin-overview/admin-overview.html - 31 + apps/client/src/app/components/admin-overview/admin-overview.component.ts + 159 @@ -1307,7 +1291,7 @@ Registrar Usuari apps/client/src/app/components/admin-overview/admin-overview.html - 37 + 50 @@ -1315,7 +1299,7 @@ Mode Només Lecutra apps/client/src/app/components/admin-overview/admin-overview.html - 51 + 64 @@ -1327,7 +1311,7 @@ apps/client/src/app/components/admin-overview/admin-overview.html - 63 + 76 @@ -1335,7 +1319,7 @@ Missatge del Sistema apps/client/src/app/components/admin-overview/admin-overview.html - 75 + 88 @@ -1343,7 +1327,7 @@ Estableix el Missatge apps/client/src/app/components/admin-overview/admin-overview.html - 97 + 110 @@ -1351,7 +1335,7 @@ Coupons apps/client/src/app/components/admin-overview/admin-overview.html - 105 + 118 @@ -1359,7 +1343,7 @@ Afegir apps/client/src/app/components/admin-overview/admin-overview.html - 185 + 198 libs/ui/src/lib/account-balances/account-balances.component.html @@ -1371,7 +1355,7 @@ Ordre apps/client/src/app/components/admin-overview/admin-overview.html - 193 + 206 @@ -1379,7 +1363,7 @@ Depurar el Cache apps/client/src/app/components/admin-overview/admin-overview.html - 209 + 222 @@ -1427,7 +1411,7 @@ Current year apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 205 + 209 @@ -1567,11 +1551,11 @@ Could not validate form apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 570 + 594 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 573 + 597 @@ -1783,7 +1767,7 @@ Informar d’un Problema amb les Dades apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 456 + 452 @@ -1955,7 +1939,7 @@ Current week apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 197 + 201 @@ -2023,7 +2007,7 @@ apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 32 + 34 apps/client/src/app/pages/landing/landing-page.html @@ -2063,7 +2047,7 @@ Inicieu la sessió amb Google apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 44 + 46 @@ -2071,7 +2055,7 @@ Manteniu la sessió iniciada apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 66 + 68 @@ -2123,7 +2107,7 @@ apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 259 + 257 @@ -2135,7 +2119,7 @@ apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 278 + 276 @@ -2355,7 +2339,7 @@ libs/ui/src/lib/assistant/assistant.component.ts - 362 + 367 @@ -2363,11 +2347,11 @@ YTD apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 205 + 209 libs/ui/src/lib/assistant/assistant.component.ts - 374 + 379 @@ -2375,11 +2359,11 @@ 1 any apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 209 + 213 libs/ui/src/lib/assistant/assistant.component.ts - 384 + 389 @@ -2387,11 +2371,11 @@ 5 anys apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 213 + 217 libs/ui/src/lib/assistant/assistant.component.ts - 408 + 413 @@ -2399,7 +2383,7 @@ Performance with currency effect apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 135 + 134 @@ -2407,11 +2391,11 @@ Màx apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 217 + 221 libs/ui/src/lib/assistant/assistant.component.ts - 414 + 419 @@ -2475,7 +2459,7 @@ Accés concedit apps/client/src/app/components/user-account-access/user-account-access.html - 57 + 59 @@ -2755,7 +2739,7 @@ Tanca el compte apps/client/src/app/components/user-account-settings/user-account-settings.html - 316 + 318 @@ -2839,7 +2823,7 @@ apps/client/src/app/components/header/header.component.html - 375 + 374 apps/client/src/app/pages/about/overview/about-overview-page.routes.ts @@ -2951,7 +2935,7 @@ apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 381 + 377 apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html @@ -3055,7 +3039,7 @@ Dades de mercat apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 403 + 399 libs/common/src/lib/routes/routes.ts @@ -3081,6 +3065,10 @@ Users Usuaris + + apps/client/src/app/components/admin-overview/admin-overview.html + 24 + libs/common/src/lib/routes/routes.ts 61 @@ -3225,6 +3213,10 @@ apps/client/src/app/pages/blog/2025/11/black-weeks-2025/black-weeks-2025-page.html 147 + + apps/client/src/app/pages/blog/2026/04/ghostfolio-3/ghostfolio-3-page.html + 269 + apps/client/src/app/pages/blog/blog-page.html 5 @@ -3239,11 +3231,11 @@ Could not parse scraper configuration apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 525 + 545 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 528 + 548 @@ -3436,7 +3428,7 @@ Comença apps/client/src/app/components/header/header.component.html - 433 + 436 apps/client/src/app/pages/features/features-page.html @@ -3468,7 +3460,7 @@ Explotacions apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 102 + 100 apps/client/src/app/components/home-holdings/home-holdings.html @@ -3516,7 +3508,7 @@ apps/client/src/app/components/header/header.component.html - 408 + 407 apps/client/src/app/components/home-market/home-market.html @@ -4012,16 +4004,20 @@ Activitats apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 86 + 84 apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 113 + 111 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html 226 + + apps/client/src/app/components/admin-overview/admin-overview.html + 38 + apps/client/src/app/components/admin-tag/admin-tag.component.html 45 @@ -4036,7 +4032,7 @@ apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 348 + 344 apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html @@ -4492,7 +4488,7 @@ Dividend apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 81 + 79 apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html @@ -4572,7 +4568,7 @@ Close Holding apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 447 + 443 @@ -4580,7 +4576,7 @@ Rendiment absolut dels actius apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 168 + 166 @@ -4588,7 +4584,7 @@ Rendiment de l’actiu apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 190 + 188 @@ -4596,7 +4592,7 @@ Rendiment absolut de la moneda apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 211 + 209 @@ -4604,7 +4600,7 @@ Rendiment de la moneda apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 236 + 234 @@ -4612,7 +4608,7 @@ A dalt apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 305 + 303 @@ -4620,7 +4616,7 @@ A baix apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 354 + 352 @@ -4628,7 +4624,7 @@ Evolució de la cartera apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 407 + 405 @@ -4636,7 +4632,7 @@ Cronologia de la inversió apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 436 + 434 @@ -4644,7 +4640,7 @@ Ratxa actual apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 457 + 455 @@ -4652,7 +4648,7 @@ Ratxa més llarga apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 466 + 464 @@ -4660,7 +4656,7 @@ Cronologia de dividends apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 495 + 493 @@ -4696,7 +4692,7 @@ apps/client/src/app/components/header/header.component.html - 389 + 388 apps/client/src/app/pages/pricing/pricing-page.routes.ts @@ -4868,11 +4864,11 @@ Could not save asset profile apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 604 + 628 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 607 + 631 @@ -5445,7 +5441,7 @@ Setmana fins avui libs/ui/src/lib/assistant/assistant.component.ts - 366 + 371 @@ -5453,11 +5449,11 @@ WTD apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 197 + 201 libs/ui/src/lib/assistant/assistant.component.ts - 366 + 371 @@ -5465,7 +5461,7 @@ Mes fins a la data libs/ui/src/lib/assistant/assistant.component.ts - 370 + 375 @@ -5473,11 +5469,11 @@ MTD apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 201 + 205 libs/ui/src/lib/assistant/assistant.component.ts - 370 + 375 @@ -5485,7 +5481,7 @@ Any fins a la data libs/ui/src/lib/assistant/assistant.component.ts - 374 + 379 @@ -5493,7 +5489,7 @@ any apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 209 + 213 apps/client/src/app/pages/resources/personal-finance-tools/product-page.html @@ -5505,7 +5501,7 @@ libs/ui/src/lib/assistant/assistant.component.ts - 384 + 389 @@ -5513,11 +5509,11 @@ anys apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 213 + 217 libs/ui/src/lib/assistant/assistant.component.ts - 408 + 413 @@ -5681,7 +5677,7 @@ Interès apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 69 + 67 apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html @@ -6005,7 +6001,7 @@ apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 318 + 316 libs/ui/src/lib/i18n.ts @@ -6057,7 +6053,7 @@ Sign in with OpenID Connect apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 55 + 57 @@ -6141,7 +6137,7 @@ Patrimoni apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 57 + 55 libs/ui/src/lib/i18n.ts @@ -6701,7 +6697,7 @@ Error apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 713 + 734 @@ -7399,7 +7395,7 @@ Received Access apps/client/src/app/components/user-account-access/user-account-access.html - 53 + 55 @@ -7423,7 +7419,7 @@ Change with currency effect apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 116 + 115 @@ -7447,7 +7443,7 @@ Lazy apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 231 + 235 @@ -7455,7 +7451,7 @@ Instant apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 235 + 239 @@ -7495,7 +7491,7 @@ end of day apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 231 + 235 @@ -7503,7 +7499,7 @@ real-time apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 235 + 239 @@ -7587,7 +7583,7 @@ Total amount apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 95 + 94 @@ -7679,7 +7675,7 @@ Find account, holding or page... libs/ui/src/lib/assistant/assistant.component.ts - 115 + 116 @@ -7744,7 +7740,7 @@ () is already in use. apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 649 + 675 @@ -7752,7 +7748,7 @@ An error occurred while updating to (). apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 657 + 683 @@ -7816,7 +7812,7 @@ someone apps/client/src/app/pages/public/public-page.component.ts - 61 + 64 @@ -7889,7 +7885,7 @@ Demo user account has been synced. apps/client/src/app/components/admin-overview/admin-overview.component.ts - 277 + 295 @@ -7897,7 +7893,7 @@ Sync Demo User Account apps/client/src/app/components/admin-overview/admin-overview.html - 204 + 217 @@ -8103,7 +8099,7 @@ Current month apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 201 + 205 @@ -8276,7 +8272,7 @@ Generate apps/client/src/app/components/user-account-access/user-account-access.html - 43 + 45 @@ -8324,7 +8320,7 @@ Manage Asset Profile apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 471 + 467 diff --git a/apps/client/src/locales/messages.de.xlf b/apps/client/src/locales/messages.de.xlf index e9d54a973..415c73470 100644 --- a/apps/client/src/locales/messages.de.xlf +++ b/apps/client/src/locales/messages.de.xlf @@ -266,7 +266,7 @@ apps/client/src/app/components/admin-overview/admin-overview.html - 140 + 153 apps/client/src/app/components/admin-platform/admin-platform.component.html @@ -493,14 +493,6 @@ 45 - - Activity Count - Anzahl Aktivitäten - - apps/client/src/app/components/admin-overview/admin-overview.html - 22 - - Historical Data Historische Daten @@ -518,7 +510,7 @@ Möchtest du diesen Gutscheincode wirklich löschen? apps/client/src/app/components/admin-overview/admin-overview.component.ts - 196 + 214 @@ -526,7 +518,7 @@ Möchtest du den Cache wirklich leeren? apps/client/src/app/components/admin-overview/admin-overview.component.ts - 233 + 251 @@ -534,23 +526,15 @@ Bitte gebe deine Systemmeldung ein: apps/client/src/app/components/admin-overview/admin-overview.component.ts - 253 - - - - User Count - Anzahl Benutzer - - apps/client/src/app/components/admin-overview/admin-overview.html - 16 + 271 per User pro Benutzer - apps/client/src/app/components/admin-overview/admin-overview.html - 31 + apps/client/src/app/components/admin-overview/admin-overview.component.ts + 159 @@ -594,7 +578,7 @@ Systemmeldung apps/client/src/app/components/admin-overview/admin-overview.html - 75 + 88 @@ -602,7 +586,7 @@ Systemmeldung setzen apps/client/src/app/components/admin-overview/admin-overview.html - 97 + 110 @@ -610,7 +594,7 @@ Lese-Modus apps/client/src/app/components/admin-overview/admin-overview.html - 51 + 64 @@ -618,7 +602,7 @@ Gutscheincodes apps/client/src/app/components/admin-overview/admin-overview.html - 105 + 118 @@ -626,7 +610,7 @@ Hinzufügen apps/client/src/app/components/admin-overview/admin-overview.html - 185 + 198 libs/ui/src/lib/account-balances/account-balances.component.html @@ -638,7 +622,7 @@ Verwaltung apps/client/src/app/components/admin-overview/admin-overview.html - 193 + 206 @@ -646,7 +630,7 @@ Cache leeren apps/client/src/app/components/admin-overview/admin-overview.html - 209 + 222 @@ -726,7 +710,7 @@ Registrieren apps/client/src/app/components/header/header.component.html - 433 + 436 apps/client/src/app/pages/features/features-page.html @@ -758,7 +742,7 @@ Einloggen apps/client/src/app/components/header/header.component.html - 422 + 425 apps/client/src/app/components/header/header.component.ts @@ -766,7 +750,7 @@ apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 79 + 81 libs/common/src/lib/routes/routes.ts @@ -850,7 +834,7 @@ apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 32 + 34 apps/client/src/app/pages/landing/landing-page.html @@ -890,7 +874,7 @@ Einloggen mit Google apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 44 + 46 @@ -898,7 +882,7 @@ Eingeloggt bleiben apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 66 + 68 @@ -926,7 +910,7 @@ apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 259 + 257 @@ -938,7 +922,7 @@ apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 278 + 276 @@ -1038,7 +1022,7 @@ Datenfehler melden apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 456 + 452 @@ -1078,7 +1062,7 @@ libs/ui/src/lib/assistant/assistant.component.ts - 362 + 367 @@ -1086,11 +1070,11 @@ YTD apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 205 + 209 libs/ui/src/lib/assistant/assistant.component.ts - 374 + 379 @@ -1098,11 +1082,11 @@ 1J apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 209 + 213 libs/ui/src/lib/assistant/assistant.component.ts - 384 + 389 @@ -1110,11 +1094,11 @@ 5J apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 213 + 217 libs/ui/src/lib/assistant/assistant.component.ts - 408 + 413 @@ -1122,7 +1106,7 @@ Performance mit Währungseffekt apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 135 + 134 @@ -1130,11 +1114,11 @@ Max apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 217 + 221 libs/ui/src/lib/assistant/assistant.component.ts - 414 + 419 @@ -1166,7 +1150,7 @@ apps/client/src/app/components/header/header.component.html - 375 + 374 apps/client/src/app/pages/about/overview/about-overview-page.routes.ts @@ -1358,7 +1342,7 @@ Gewährte Zugangsberechtigung apps/client/src/app/components/user-account-access/user-account-access.html - 57 + 59 @@ -1398,7 +1382,7 @@ apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 381 + 377 apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html @@ -1470,7 +1454,7 @@ Cash-Bestand apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 45 + 43 apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html @@ -1486,7 +1470,7 @@ Plattform apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 90 + 88 apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html @@ -1620,6 +1604,10 @@ apps/client/src/app/pages/blog/2025/11/black-weeks-2025/black-weeks-2025-page.html 147 + + apps/client/src/app/pages/blog/2026/04/ghostfolio-3/ghostfolio-3-page.html + 269 + apps/client/src/app/pages/blog/blog-page.html 5 @@ -1666,7 +1654,7 @@ apps/client/src/app/components/header/header.component.html - 361 + 360 apps/client/src/app/pages/features/features-page.html @@ -1730,7 +1718,7 @@ apps/client/src/app/components/header/header.component.html - 408 + 407 apps/client/src/app/components/home-market/home-market.html @@ -1866,7 +1854,7 @@ Zeitstrahl der Investitionen apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 436 + 434 @@ -1874,7 +1862,7 @@ Gewinner apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 305 + 303 @@ -1882,7 +1870,7 @@ Verlierer apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 354 + 352 @@ -1906,7 +1894,7 @@ Positionen apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 102 + 100 apps/client/src/app/components/home-holdings/home-holdings.html @@ -1942,7 +1930,7 @@ Aktuelle Woche apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 197 + 201 @@ -2042,16 +2030,20 @@ Aktivitäten apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 86 + 84 apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 113 + 111 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html 226 + + apps/client/src/app/components/admin-overview/admin-overview.html + 38 + apps/client/src/app/components/admin-tag/admin-tag.component.html 45 @@ -2066,7 +2058,7 @@ apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 348 + 344 apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html @@ -2126,7 +2118,7 @@ apps/client/src/app/components/header/header.component.html - 389 + 388 apps/client/src/app/pages/pricing/pricing-page.routes.ts @@ -2566,7 +2558,7 @@ Verzinsung apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 69 + 67 apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html @@ -2678,11 +2670,11 @@ Das Formular konnte nicht validiert werden apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 570 + 594 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 573 + 597 @@ -2770,7 +2762,7 @@ Portfolio Wertentwicklung apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 407 + 405 @@ -2854,7 +2846,7 @@ apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 318 + 316 libs/ui/src/lib/i18n.ts @@ -2898,7 +2890,7 @@ Beteiligungskapital apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 57 + 55 libs/ui/src/lib/i18n.ts @@ -3146,7 +3138,7 @@ Zeitstrahl der Dividenden apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 495 + 493 @@ -3162,7 +3154,7 @@ Dividenden apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 81 + 79 apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html @@ -3222,7 +3214,7 @@ Benutzer Registrierung apps/client/src/app/components/admin-overview/admin-overview.html - 37 + 50 @@ -3254,7 +3246,7 @@ Marktdaten apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 403 + 399 libs/common/src/lib/routes/routes.ts @@ -3264,6 +3256,10 @@ Users Benutzer + + apps/client/src/app/components/admin-overview/admin-overview.html + 24 + libs/common/src/lib/routes/routes.ts 61 @@ -3650,11 +3646,11 @@ Das Anlageprofil konnte nicht gespeichert werden apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 604 + 628 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 607 + 631 @@ -3894,7 +3890,7 @@ Aktuelles Jahr apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 205 + 209 @@ -3930,7 +3926,7 @@ Das Anlageprofil wurde gespeichert apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 594 + 618 @@ -4074,7 +4070,7 @@ Aktueller Streak apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 457 + 455 @@ -4082,7 +4078,7 @@ Längster Streak apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 466 + 464 @@ -4506,7 +4502,7 @@ Einloggen mit OpenID Connect apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 55 + 57 @@ -4830,11 +4826,11 @@ Die Scraper Konfiguration konnte nicht geparsed werden apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 525 + 545 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 528 + 548 @@ -5564,7 +5560,7 @@ Version apps/client/src/app/components/admin-overview/admin-overview.html - 7 + 11 @@ -5760,7 +5756,7 @@ Möchtest du diese Systemmeldung wirklich löschen? apps/client/src/app/components/admin-overview/admin-overview.component.ts - 209 + 227 @@ -5784,7 +5780,7 @@ Cash-Bestände apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 146 + 142 @@ -5820,7 +5816,7 @@ Der aktuelle Marktpreis ist apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 722 + 743 @@ -5940,7 +5936,7 @@ Position abschliessen apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 447 + 443 @@ -5948,7 +5944,7 @@ Absolute Anlage Performance apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 168 + 166 @@ -5956,7 +5952,7 @@ Anlage Performance apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 190 + 188 @@ -5964,7 +5960,7 @@ Absolute Währungsperformance apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 211 + 209 @@ -5972,7 +5968,7 @@ Währungsperformance apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 236 + 234 @@ -5980,7 +5976,7 @@ Seit Wochenbeginn libs/ui/src/lib/assistant/assistant.component.ts - 366 + 371 @@ -5988,11 +5984,11 @@ WTD apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 197 + 201 libs/ui/src/lib/assistant/assistant.component.ts - 366 + 371 @@ -6000,7 +5996,7 @@ Seit Monatsbeginn libs/ui/src/lib/assistant/assistant.component.ts - 370 + 375 @@ -6008,11 +6004,11 @@ MTD apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 201 + 205 libs/ui/src/lib/assistant/assistant.component.ts - 370 + 375 @@ -6020,7 +6016,7 @@ Seit Jahresbeginn libs/ui/src/lib/assistant/assistant.component.ts - 374 + 379 @@ -6056,7 +6052,7 @@ Jahr apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 209 + 213 apps/client/src/app/pages/resources/personal-finance-tools/product-page.html @@ -6068,7 +6064,7 @@ libs/ui/src/lib/assistant/assistant.component.ts - 384 + 389 @@ -6076,11 +6072,11 @@ Jahre apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 213 + 217 libs/ui/src/lib/assistant/assistant.component.ts - 408 + 413 @@ -6100,7 +6096,7 @@ apps/client/src/app/components/admin-overview/admin-overview.html - 63 + 76 @@ -6289,7 +6285,7 @@ Konto schliessen apps/client/src/app/components/user-account-settings/user-account-settings.html - 316 + 318 @@ -6725,7 +6721,7 @@ Fehler apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 713 + 734 @@ -7423,7 +7419,7 @@ Erhaltene Zugangsberechtigung apps/client/src/app/components/user-account-access/user-account-access.html - 53 + 55 @@ -7447,7 +7443,7 @@ Änderung mit Währungseffekt apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 116 + 115 @@ -7471,7 +7467,7 @@ Verzögert apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 231 + 235 @@ -7479,7 +7475,7 @@ Sofort apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 235 + 239 @@ -7519,7 +7515,7 @@ Tagesende apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 231 + 235 @@ -7527,7 +7523,7 @@ in Echtzeit apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 235 + 239 @@ -7611,7 +7607,7 @@ Gesamtbetrag apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 95 + 94 @@ -7703,7 +7699,7 @@ Konto, Position oder Seite finden... libs/ui/src/lib/assistant/assistant.component.ts - 115 + 116 @@ -7768,7 +7764,7 @@ () wird bereits verwendet. apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 649 + 675 @@ -7776,7 +7772,7 @@ Bei der Änderung zu () ist ein Fehler aufgetreten. apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 657 + 683 @@ -7816,7 +7812,7 @@ jemand apps/client/src/app/pages/public/public-page.component.ts - 61 + 64 @@ -7889,7 +7885,7 @@ Demo Benutzerkonto wurde synchronisiert. apps/client/src/app/components/admin-overview/admin-overview.component.ts - 277 + 295 @@ -7897,7 +7893,7 @@ Synchronisiere Demo Benutzerkonto apps/client/src/app/components/admin-overview/admin-overview.html - 204 + 217 @@ -8103,7 +8099,7 @@ Aktueller Monat apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 201 + 205 @@ -8276,7 +8272,7 @@ Generieren apps/client/src/app/components/user-account-access/user-account-access.html - 43 + 45 @@ -8324,7 +8320,7 @@ Anlageprofil verwalten apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 471 + 467 diff --git a/apps/client/src/locales/messages.es.xlf b/apps/client/src/locales/messages.es.xlf index 1df016d5b..8cd107ecc 100644 --- a/apps/client/src/locales/messages.es.xlf +++ b/apps/client/src/locales/messages.es.xlf @@ -267,7 +267,7 @@ apps/client/src/app/components/admin-overview/admin-overview.html - 140 + 153 apps/client/src/app/components/admin-platform/admin-platform.component.html @@ -494,14 +494,6 @@ 45 - - Activity Count - Número de operaciones - - apps/client/src/app/components/admin-overview/admin-overview.html - 22 - - Historical Data Datos históricos @@ -519,7 +511,7 @@ ¿Seguro que quieres eliminar este cupón? apps/client/src/app/components/admin-overview/admin-overview.component.ts - 196 + 214 @@ -527,7 +519,7 @@ ¿Seguro que quieres limpiar la caché? apps/client/src/app/components/admin-overview/admin-overview.component.ts - 233 + 251 @@ -535,23 +527,15 @@ Por favor, establece tu mensaje del sistema: apps/client/src/app/components/admin-overview/admin-overview.component.ts - 253 - - - - User Count - Número de usuarios - - apps/client/src/app/components/admin-overview/admin-overview.html - 16 + 271 per User por usuario - apps/client/src/app/components/admin-overview/admin-overview.html - 31 + apps/client/src/app/components/admin-overview/admin-overview.component.ts + 159 @@ -579,7 +563,7 @@ Mensaje del sistema apps/client/src/app/components/admin-overview/admin-overview.html - 75 + 88 @@ -587,7 +571,7 @@ Establecer mensaje apps/client/src/app/components/admin-overview/admin-overview.html - 97 + 110 @@ -595,7 +579,7 @@ Modo de solo lectura apps/client/src/app/components/admin-overview/admin-overview.html - 51 + 64 @@ -603,7 +587,7 @@ Cupones apps/client/src/app/components/admin-overview/admin-overview.html - 105 + 118 @@ -611,7 +595,7 @@ Añadir apps/client/src/app/components/admin-overview/admin-overview.html - 185 + 198 libs/ui/src/lib/account-balances/account-balances.component.html @@ -623,7 +607,7 @@ Limpieza del sistema apps/client/src/app/components/admin-overview/admin-overview.html - 193 + 206 @@ -631,7 +615,7 @@ Limpiar caché apps/client/src/app/components/admin-overview/admin-overview.html - 209 + 222 @@ -711,7 +695,7 @@ Empezar apps/client/src/app/components/header/header.component.html - 433 + 436 apps/client/src/app/pages/features/features-page.html @@ -743,7 +727,7 @@ Iniciar sesión apps/client/src/app/components/header/header.component.html - 422 + 425 apps/client/src/app/components/header/header.component.ts @@ -751,7 +735,7 @@ apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 79 + 81 libs/common/src/lib/routes/routes.ts @@ -835,7 +819,7 @@ apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 32 + 34 apps/client/src/app/pages/landing/landing-page.html @@ -875,7 +859,7 @@ Iniciar sesión con Google apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 44 + 46 @@ -883,7 +867,7 @@ Seguir conectado apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 66 + 68 @@ -911,7 +895,7 @@ apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 259 + 257 @@ -923,7 +907,7 @@ apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 278 + 276 @@ -1023,7 +1007,7 @@ Reportar anomalía en los datos apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 456 + 452 @@ -1063,7 +1047,7 @@ libs/ui/src/lib/assistant/assistant.component.ts - 362 + 367 @@ -1071,11 +1055,11 @@ YTD apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 205 + 209 libs/ui/src/lib/assistant/assistant.component.ts - 374 + 379 @@ -1083,11 +1067,11 @@ 1 año apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 209 + 213 libs/ui/src/lib/assistant/assistant.component.ts - 384 + 389 @@ -1095,11 +1079,11 @@ 5 años apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 213 + 217 libs/ui/src/lib/assistant/assistant.component.ts - 408 + 413 @@ -1107,7 +1091,7 @@ Rendimiento con el efecto de la divisa apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 135 + 134 @@ -1115,11 +1099,11 @@ Máximo apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 217 + 221 libs/ui/src/lib/assistant/assistant.component.ts - 414 + 419 @@ -1151,7 +1135,7 @@ apps/client/src/app/components/header/header.component.html - 375 + 374 apps/client/src/app/pages/about/overview/about-overview-page.routes.ts @@ -1343,7 +1327,7 @@ Acceso concedido apps/client/src/app/components/user-account-access/user-account-access.html - 57 + 59 @@ -1383,7 +1367,7 @@ apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 381 + 377 apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html @@ -1455,7 +1439,7 @@ Saldo en efectivo apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 45 + 43 apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html @@ -1471,7 +1455,7 @@ Plataforma apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 90 + 88 apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html @@ -1605,6 +1589,10 @@ apps/client/src/app/pages/blog/2025/11/black-weeks-2025/black-weeks-2025-page.html 147 + + apps/client/src/app/pages/blog/2026/04/ghostfolio-3/ghostfolio-3-page.html + 269 + apps/client/src/app/pages/blog/blog-page.html 5 @@ -1651,7 +1639,7 @@ apps/client/src/app/components/header/header.component.html - 361 + 360 apps/client/src/app/pages/features/features-page.html @@ -1715,7 +1703,7 @@ apps/client/src/app/components/header/header.component.html - 408 + 407 apps/client/src/app/components/home-market/home-market.html @@ -1851,7 +1839,7 @@ Cronología de la inversión apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 436 + 434 @@ -1859,7 +1847,7 @@ Mejores apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 305 + 303 @@ -1867,7 +1855,7 @@ Peores apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 354 + 352 @@ -1891,7 +1879,7 @@ Posiciones apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 102 + 100 apps/client/src/app/components/home-holdings/home-holdings.html @@ -1927,7 +1915,7 @@ Semana actual apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 197 + 201 @@ -2027,16 +2015,20 @@ Operaciones apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 86 + 84 apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 113 + 111 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html 226 + + apps/client/src/app/components/admin-overview/admin-overview.html + 38 + apps/client/src/app/components/admin-tag/admin-tag.component.html 45 @@ -2051,7 +2043,7 @@ apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 348 + 344 apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html @@ -2111,7 +2103,7 @@ apps/client/src/app/components/header/header.component.html - 389 + 388 apps/client/src/app/pages/pricing/pricing-page.routes.ts @@ -2535,7 +2527,7 @@ Interés apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 69 + 67 apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html @@ -2675,11 +2667,11 @@ No se pudo validar el formulario apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 570 + 594 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 573 + 597 @@ -2755,7 +2747,7 @@ Evolución de la cartera apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 407 + 405 @@ -2839,7 +2831,7 @@ apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 318 + 316 libs/ui/src/lib/i18n.ts @@ -2883,7 +2875,7 @@ Renta variable apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 57 + 55 libs/ui/src/lib/i18n.ts @@ -3139,7 +3131,7 @@ Dividendo apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 81 + 79 apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html @@ -3171,7 +3163,7 @@ Calendario de dividendos apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 495 + 493 @@ -3207,7 +3199,7 @@ Registro de usuario apps/client/src/app/components/admin-overview/admin-overview.html - 37 + 50 @@ -3239,7 +3231,7 @@ Datos del mercado apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 403 + 399 libs/common/src/lib/routes/routes.ts @@ -3249,6 +3241,10 @@ Users Usuarios + + apps/client/src/app/components/admin-overview/admin-overview.html + 24 + libs/common/src/lib/routes/routes.ts 61 @@ -3635,11 +3631,11 @@ No se pudo guardar el perfil del activo apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 604 + 628 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 607 + 631 @@ -3871,7 +3867,7 @@ Año actual apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 205 + 209 @@ -3907,7 +3903,7 @@ Perfil del activo guardado apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 594 + 618 @@ -4051,7 +4047,7 @@ Racha actual apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 457 + 455 @@ -4059,7 +4055,7 @@ Racha más larga apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 466 + 464 @@ -4483,7 +4479,7 @@ Iniciar sesión con OpenID Connect apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 55 + 57 @@ -4807,11 +4803,11 @@ No se pudo analizar la configuración del scraper apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 525 + 545 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 528 + 548 @@ -5541,7 +5537,7 @@ Versión apps/client/src/app/components/admin-overview/admin-overview.html - 7 + 11 @@ -5737,7 +5733,7 @@ ¿Seguro que quieres eliminar este mensaje del sistema? apps/client/src/app/components/admin-overview/admin-overview.component.ts - 209 + 227 @@ -5761,7 +5757,7 @@ Saldos de efectivo apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 146 + 142 @@ -5797,7 +5793,7 @@ El precio actual de mercado es apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 722 + 743 @@ -5917,7 +5913,7 @@ Cerrar posición apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 447 + 443 @@ -5925,7 +5921,7 @@ Rendimiento absoluto de los activos apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 168 + 166 @@ -5933,7 +5929,7 @@ Rendimiento de los activos apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 190 + 188 @@ -5941,7 +5937,7 @@ Rendimiento absoluto de las divisas apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 211 + 209 @@ -5949,7 +5945,7 @@ Rendimiento de la divisa apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 236 + 234 @@ -5957,7 +5953,7 @@ Semana hasta la fecha libs/ui/src/lib/assistant/assistant.component.ts - 366 + 371 @@ -5965,11 +5961,11 @@ WTD apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 197 + 201 libs/ui/src/lib/assistant/assistant.component.ts - 366 + 371 @@ -5977,7 +5973,7 @@ Mes hasta la fecha libs/ui/src/lib/assistant/assistant.component.ts - 370 + 375 @@ -5985,11 +5981,11 @@ MTD apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 201 + 205 libs/ui/src/lib/assistant/assistant.component.ts - 370 + 375 @@ -5997,7 +5993,7 @@ Año hasta la fecha libs/ui/src/lib/assistant/assistant.component.ts - 374 + 379 @@ -6033,7 +6029,7 @@ año apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 209 + 213 apps/client/src/app/pages/resources/personal-finance-tools/product-page.html @@ -6045,7 +6041,7 @@ libs/ui/src/lib/assistant/assistant.component.ts - 384 + 389 @@ -6053,11 +6049,11 @@ años apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 213 + 217 libs/ui/src/lib/assistant/assistant.component.ts - 408 + 413 @@ -6077,7 +6073,7 @@ apps/client/src/app/components/admin-overview/admin-overview.html - 63 + 76 @@ -6266,7 +6262,7 @@ Eliminar cuenta apps/client/src/app/components/user-account-settings/user-account-settings.html - 316 + 318 @@ -6702,7 +6698,7 @@ Error apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 713 + 734 @@ -7400,7 +7396,7 @@ Acceso recibido apps/client/src/app/components/user-account-access/user-account-access.html - 53 + 55 @@ -7424,7 +7420,7 @@ Cambio con el efecto de la divisa apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 116 + 115 @@ -7448,7 +7444,7 @@ Bajo demanda apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 231 + 235 @@ -7456,7 +7452,7 @@ Instantáneo apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 235 + 239 @@ -7496,7 +7492,7 @@ final del día apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 231 + 235 @@ -7504,7 +7500,7 @@ en tiempo real apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 235 + 239 @@ -7588,7 +7584,7 @@ Importe total apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 95 + 94 @@ -7680,7 +7676,7 @@ Busca una cuenta, posición o página... libs/ui/src/lib/assistant/assistant.component.ts - 115 + 116 @@ -7745,7 +7741,7 @@ () ya está en uso. apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 649 + 675 @@ -7753,7 +7749,7 @@ Ocurrió un error al actualizar a (). apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 657 + 683 @@ -7817,7 +7813,7 @@ alguien apps/client/src/app/pages/public/public-page.component.ts - 61 + 64 @@ -7890,7 +7886,7 @@ La cuenta de usuario de demostración se ha sincronizado. apps/client/src/app/components/admin-overview/admin-overview.component.ts - 277 + 295 @@ -7898,7 +7894,7 @@ Sincronizar cuenta de usuario de demostración apps/client/src/app/components/admin-overview/admin-overview.html - 204 + 217 @@ -8104,7 +8100,7 @@ Mes actual apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 201 + 205 @@ -8277,7 +8273,7 @@ Generar apps/client/src/app/components/user-account-access/user-account-access.html - 43 + 45 @@ -8325,7 +8321,7 @@ Gestionar perfil de activo apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 471 + 467 diff --git a/apps/client/src/locales/messages.fr.xlf b/apps/client/src/locales/messages.fr.xlf index d2de4b979..fa6f86a78 100644 --- a/apps/client/src/locales/messages.fr.xlf +++ b/apps/client/src/locales/messages.fr.xlf @@ -98,7 +98,7 @@ Plateforme apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 90 + 88 apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html @@ -218,7 +218,7 @@ Balance Cash apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 45 + 43 apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html @@ -322,7 +322,7 @@ apps/client/src/app/components/admin-overview/admin-overview.html - 140 + 153 apps/client/src/app/components/admin-platform/admin-platform.component.html @@ -710,7 +710,7 @@ Voulez-vous vraiment supprimer ce code promotionnel ? apps/client/src/app/components/admin-overview/admin-overview.component.ts - 196 + 214 @@ -718,7 +718,7 @@ Voulez-vous vraiment vider le cache ? apps/client/src/app/components/admin-overview/admin-overview.component.ts - 233 + 251 @@ -726,31 +726,15 @@ Veuillez définir votre message système : apps/client/src/app/components/admin-overview/admin-overview.component.ts - 253 - - - - User Count - Nombre d’Utilisateurs - - apps/client/src/app/components/admin-overview/admin-overview.html - 16 - - - - Activity Count - Nombre d’Activités - - apps/client/src/app/components/admin-overview/admin-overview.html - 22 + 271 per User par Utilisateur - apps/client/src/app/components/admin-overview/admin-overview.html - 31 + apps/client/src/app/components/admin-overview/admin-overview.component.ts + 159 @@ -782,7 +766,7 @@ Inscription de Nouveaux Utilisateurs apps/client/src/app/components/admin-overview/admin-overview.html - 37 + 50 @@ -790,7 +774,7 @@ Mode Lecture Seule apps/client/src/app/components/admin-overview/admin-overview.html - 51 + 64 @@ -798,7 +782,7 @@ Message Système apps/client/src/app/components/admin-overview/admin-overview.html - 75 + 88 @@ -806,7 +790,7 @@ Définir Message apps/client/src/app/components/admin-overview/admin-overview.html - 97 + 110 @@ -814,7 +798,7 @@ Codes promotionnels apps/client/src/app/components/admin-overview/admin-overview.html - 105 + 118 @@ -822,7 +806,7 @@ Ajouter apps/client/src/app/components/admin-overview/admin-overview.html - 185 + 198 libs/ui/src/lib/account-balances/account-balances.component.html @@ -834,7 +818,7 @@ Maintenance apps/client/src/app/components/admin-overview/admin-overview.html - 193 + 206 @@ -842,7 +826,7 @@ Vider le Cache apps/client/src/app/components/admin-overview/admin-overview.html - 209 + 222 @@ -902,11 +886,11 @@ Could not validate form apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 570 + 594 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 573 + 597 @@ -978,7 +962,7 @@ Se connecter apps/client/src/app/components/header/header.component.html - 422 + 425 apps/client/src/app/components/header/header.component.ts @@ -986,7 +970,7 @@ apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 79 + 81 libs/common/src/lib/routes/routes.ts @@ -1118,7 +1102,7 @@ apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 32 + 34 apps/client/src/app/pages/landing/landing-page.html @@ -1158,7 +1142,7 @@ Se connecter avec Google apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 44 + 46 @@ -1166,7 +1150,7 @@ Rester connecté apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 66 + 68 @@ -1194,7 +1178,7 @@ apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 259 + 257 @@ -1206,7 +1190,7 @@ apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 278 + 276 @@ -1298,7 +1282,7 @@ Signaler une Erreur de Données apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 456 + 452 @@ -1310,7 +1294,7 @@ libs/ui/src/lib/assistant/assistant.component.ts - 362 + 367 @@ -1318,11 +1302,11 @@ CDA apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 205 + 209 libs/ui/src/lib/assistant/assistant.component.ts - 374 + 379 @@ -1330,11 +1314,11 @@ 1A apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 209 + 213 libs/ui/src/lib/assistant/assistant.component.ts - 384 + 389 @@ -1342,11 +1326,11 @@ 5A apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 213 + 217 libs/ui/src/lib/assistant/assistant.component.ts - 408 + 413 @@ -1354,7 +1338,7 @@ Performance with currency effect apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 135 + 134 @@ -1362,11 +1346,11 @@ Max apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 217 + 221 libs/ui/src/lib/assistant/assistant.component.ts - 414 + 419 @@ -1434,7 +1418,7 @@ apps/client/src/app/components/header/header.component.html - 375 + 374 apps/client/src/app/pages/about/overview/about-overview-page.routes.ts @@ -1678,7 +1662,7 @@ Accès donné apps/client/src/app/components/user-account-access/user-account-access.html - 57 + 59 @@ -1718,7 +1702,7 @@ apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 381 + 377 apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html @@ -1766,7 +1750,7 @@ Données du marché apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 403 + 399 libs/common/src/lib/routes/routes.ts @@ -1792,6 +1776,10 @@ Users Utilisateurs + + apps/client/src/app/components/admin-overview/admin-overview.html + 24 + libs/common/src/lib/routes/routes.ts 61 @@ -1896,6 +1884,10 @@ apps/client/src/app/pages/blog/2025/11/black-weeks-2025/black-weeks-2025-page.html 147 + + apps/client/src/app/pages/blog/2026/04/ghostfolio-3/ghostfolio-3-page.html + 269 + apps/client/src/app/pages/blog/blog-page.html 5 @@ -1942,7 +1934,7 @@ apps/client/src/app/components/header/header.component.html - 361 + 360 apps/client/src/app/pages/features/features-page.html @@ -1958,7 +1950,7 @@ Positions apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 102 + 100 apps/client/src/app/components/home-holdings/home-holdings.html @@ -2006,7 +1998,7 @@ apps/client/src/app/components/header/header.component.html - 408 + 407 apps/client/src/app/components/home-market/home-market.html @@ -2046,16 +2038,20 @@ Activités apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 86 + 84 apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 113 + 111 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html 226 + + apps/client/src/app/components/admin-overview/admin-overview.html + 38 + apps/client/src/app/components/admin-tag/admin-tag.component.html 45 @@ -2070,7 +2066,7 @@ apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 348 + 344 apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html @@ -2102,7 +2098,7 @@ Current week apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 197 + 201 @@ -2402,7 +2398,7 @@ Dividende apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 81 + 79 apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html @@ -2458,7 +2454,7 @@ Haut apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 305 + 303 @@ -2466,7 +2462,7 @@ Bas apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 354 + 352 @@ -2474,7 +2470,7 @@ Évolution du Portefeuille apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 407 + 405 @@ -2482,7 +2478,7 @@ Historique des Investissements apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 436 + 434 @@ -2490,7 +2486,7 @@ Historique des Dividendes apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 495 + 493 @@ -2526,7 +2522,7 @@ apps/client/src/app/components/header/header.component.html - 389 + 388 apps/client/src/app/pages/pricing/pricing-page.routes.ts @@ -2578,7 +2574,7 @@ Démarrer apps/client/src/app/components/header/header.component.html - 433 + 436 apps/client/src/app/pages/features/features-page.html @@ -2862,7 +2858,7 @@ Intérêt apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 69 + 67 apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html @@ -3042,7 +3038,7 @@ apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 318 + 316 libs/ui/src/lib/i18n.ts @@ -3086,7 +3082,7 @@ Capital apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 57 + 55 libs/ui/src/lib/i18n.ts @@ -3634,11 +3630,11 @@ Could not save asset profile apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 604 + 628 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 607 + 631 @@ -3870,7 +3866,7 @@ Current year apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 205 + 209 @@ -3906,7 +3902,7 @@ Asset profile has been saved apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 594 + 618 @@ -4050,7 +4046,7 @@ Série en cours apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 457 + 455 @@ -4058,7 +4054,7 @@ Série la plus longue apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 466 + 464 @@ -4482,7 +4478,7 @@ Sign in with OpenID Connect apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 55 + 57 @@ -4806,11 +4802,11 @@ Could not parse scraper configuration apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 525 + 545 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 528 + 548 @@ -5540,7 +5536,7 @@ Version apps/client/src/app/components/admin-overview/admin-overview.html - 7 + 11 @@ -5736,7 +5732,7 @@ Confirmer la suppresion de ce message système? apps/client/src/app/components/admin-overview/admin-overview.component.ts - 209 + 227 @@ -5760,7 +5756,7 @@ Cash Balances apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 146 + 142 @@ -5796,7 +5792,7 @@ Le prix actuel du marché est apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 722 + 743 @@ -5916,7 +5912,7 @@ Close Holding apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 447 + 443 @@ -5924,7 +5920,7 @@ Performance des Actifs en valeur absolue apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 168 + 166 @@ -5932,7 +5928,7 @@ Performance des Actifs apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 190 + 188 @@ -5940,7 +5936,7 @@ Performance des devises en valeur absolue apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 211 + 209 @@ -5948,7 +5944,7 @@ Performance des devises apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 236 + 234 @@ -5956,7 +5952,7 @@ Week to date libs/ui/src/lib/assistant/assistant.component.ts - 366 + 371 @@ -5964,11 +5960,11 @@ WTD apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 197 + 201 libs/ui/src/lib/assistant/assistant.component.ts - 366 + 371 @@ -5976,7 +5972,7 @@ Month to date libs/ui/src/lib/assistant/assistant.component.ts - 370 + 375 @@ -5984,11 +5980,11 @@ MTD apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 201 + 205 libs/ui/src/lib/assistant/assistant.component.ts - 370 + 375 @@ -5996,7 +5992,7 @@ Year to date libs/ui/src/lib/assistant/assistant.component.ts - 374 + 379 @@ -6032,7 +6028,7 @@ année apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 209 + 213 apps/client/src/app/pages/resources/personal-finance-tools/product-page.html @@ -6044,7 +6040,7 @@ libs/ui/src/lib/assistant/assistant.component.ts - 384 + 389 @@ -6052,11 +6048,11 @@ années apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 213 + 217 libs/ui/src/lib/assistant/assistant.component.ts - 408 + 413 @@ -6076,7 +6072,7 @@ apps/client/src/app/components/admin-overview/admin-overview.html - 63 + 76 @@ -6265,7 +6261,7 @@ Supprimer le compte apps/client/src/app/components/user-account-settings/user-account-settings.html - 316 + 318 @@ -6701,7 +6697,7 @@ Erreur apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 713 + 734 @@ -7399,7 +7395,7 @@ Accès reçu apps/client/src/app/components/user-account-access/user-account-access.html - 53 + 55 @@ -7423,7 +7419,7 @@ Change with currency effect apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 116 + 115 @@ -7447,7 +7443,7 @@ Paresseux apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 231 + 235 @@ -7455,7 +7451,7 @@ Instantané apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 235 + 239 @@ -7495,7 +7491,7 @@ fin de journée apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 231 + 235 @@ -7503,7 +7499,7 @@ temps réel apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 235 + 239 @@ -7587,7 +7583,7 @@ Total amount apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 95 + 94 @@ -7679,7 +7675,7 @@ Find account, holding or page... libs/ui/src/lib/assistant/assistant.component.ts - 115 + 116 @@ -7744,7 +7740,7 @@ () est déjà utilisé. apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 649 + 675 @@ -7752,7 +7748,7 @@ Une erreur s’est produite lors de la mise à jour vers (). apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 657 + 683 @@ -7816,7 +7812,7 @@ quelqu’un apps/client/src/app/pages/public/public-page.component.ts - 61 + 64 @@ -7889,7 +7885,7 @@ Le compte utilisateur de démonstration a été synchronisé. apps/client/src/app/components/admin-overview/admin-overview.component.ts - 277 + 295 @@ -7897,7 +7893,7 @@ Synchroniser le compte utilisateur de démonstration apps/client/src/app/components/admin-overview/admin-overview.html - 204 + 217 @@ -8103,7 +8099,7 @@ Current month apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 201 + 205 @@ -8276,7 +8272,7 @@ Générer apps/client/src/app/components/user-account-access/user-account-access.html - 43 + 45 @@ -8324,7 +8320,7 @@ Gérer le profil d’actif apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 471 + 467 diff --git a/apps/client/src/locales/messages.it.xlf b/apps/client/src/locales/messages.it.xlf index e2f7303e2..6f5186a62 100644 --- a/apps/client/src/locales/messages.it.xlf +++ b/apps/client/src/locales/messages.it.xlf @@ -267,7 +267,7 @@ apps/client/src/app/components/admin-overview/admin-overview.html - 140 + 153 apps/client/src/app/components/admin-platform/admin-platform.component.html @@ -494,14 +494,6 @@ 45 - - Activity Count - Conteggio attività - - apps/client/src/app/components/admin-overview/admin-overview.html - 22 - - Historical Data Dati storici @@ -519,7 +511,7 @@ Vuoi davvero eliminare questo buono? apps/client/src/app/components/admin-overview/admin-overview.component.ts - 196 + 214 @@ -527,7 +519,7 @@ Vuoi davvero svuotare la cache? apps/client/src/app/components/admin-overview/admin-overview.component.ts - 233 + 251 @@ -535,23 +527,15 @@ Imposta il messaggio di sistema: apps/client/src/app/components/admin-overview/admin-overview.component.ts - 253 - - - - User Count - Numero di utenti - - apps/client/src/app/components/admin-overview/admin-overview.html - 16 + 271 per User per utente - apps/client/src/app/components/admin-overview/admin-overview.html - 31 + apps/client/src/app/components/admin-overview/admin-overview.component.ts + 159 @@ -579,7 +563,7 @@ Messaggio di sistema apps/client/src/app/components/admin-overview/admin-overview.html - 75 + 88 @@ -587,7 +571,7 @@ Imposta messaggio apps/client/src/app/components/admin-overview/admin-overview.html - 97 + 110 @@ -595,7 +579,7 @@ Modalità di sola lettura apps/client/src/app/components/admin-overview/admin-overview.html - 51 + 64 @@ -603,7 +587,7 @@ Buoni sconto apps/client/src/app/components/admin-overview/admin-overview.html - 105 + 118 @@ -611,7 +595,7 @@ Aggiungi apps/client/src/app/components/admin-overview/admin-overview.html - 185 + 198 libs/ui/src/lib/account-balances/account-balances.component.html @@ -623,7 +607,7 @@ Bilancio domestico apps/client/src/app/components/admin-overview/admin-overview.html - 193 + 206 @@ -631,7 +615,7 @@ Svuota la cache apps/client/src/app/components/admin-overview/admin-overview.html - 209 + 222 @@ -711,7 +695,7 @@ Inizia apps/client/src/app/components/header/header.component.html - 433 + 436 apps/client/src/app/pages/features/features-page.html @@ -743,7 +727,7 @@ Accedi apps/client/src/app/components/header/header.component.html - 422 + 425 apps/client/src/app/components/header/header.component.ts @@ -751,7 +735,7 @@ apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 79 + 81 libs/common/src/lib/routes/routes.ts @@ -835,7 +819,7 @@ apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 32 + 34 apps/client/src/app/pages/landing/landing-page.html @@ -875,7 +859,7 @@ Accedi con Google apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 44 + 46 @@ -883,7 +867,7 @@ Rimani connesso apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 66 + 68 @@ -911,7 +895,7 @@ apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 259 + 257 @@ -923,7 +907,7 @@ apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 278 + 276 @@ -1023,7 +1007,7 @@ Segnala un’anomalia dei dati apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 456 + 452 @@ -1063,7 +1047,7 @@ libs/ui/src/lib/assistant/assistant.component.ts - 362 + 367 @@ -1071,11 +1055,11 @@ anno corrente apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 205 + 209 libs/ui/src/lib/assistant/assistant.component.ts - 374 + 379 @@ -1083,11 +1067,11 @@ 1 anno apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 209 + 213 libs/ui/src/lib/assistant/assistant.component.ts - 384 + 389 @@ -1095,11 +1079,11 @@ 5 anni apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 213 + 217 libs/ui/src/lib/assistant/assistant.component.ts - 408 + 413 @@ -1107,7 +1091,7 @@ Performance with currency effect apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 135 + 134 @@ -1115,11 +1099,11 @@ Massimo apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 217 + 221 libs/ui/src/lib/assistant/assistant.component.ts - 414 + 419 @@ -1151,7 +1135,7 @@ apps/client/src/app/components/header/header.component.html - 375 + 374 apps/client/src/app/pages/about/overview/about-overview-page.routes.ts @@ -1343,7 +1327,7 @@ Accesso concesso apps/client/src/app/components/user-account-access/user-account-access.html - 57 + 59 @@ -1383,7 +1367,7 @@ apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 381 + 377 apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html @@ -1455,7 +1439,7 @@ Saldo di cassa apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 45 + 43 apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html @@ -1471,7 +1455,7 @@ Piattaforma apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 90 + 88 apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html @@ -1605,6 +1589,10 @@ apps/client/src/app/pages/blog/2025/11/black-weeks-2025/black-weeks-2025-page.html 147 + + apps/client/src/app/pages/blog/2026/04/ghostfolio-3/ghostfolio-3-page.html + 269 + apps/client/src/app/pages/blog/blog-page.html 5 @@ -1651,7 +1639,7 @@ apps/client/src/app/components/header/header.component.html - 361 + 360 apps/client/src/app/pages/features/features-page.html @@ -1715,7 +1703,7 @@ apps/client/src/app/components/header/header.component.html - 408 + 407 apps/client/src/app/components/home-market/home-market.html @@ -1851,7 +1839,7 @@ Cronologia degli investimenti apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 436 + 434 @@ -1859,7 +1847,7 @@ In alto apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 305 + 303 @@ -1867,7 +1855,7 @@ In basso apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 354 + 352 @@ -1891,7 +1879,7 @@ Partecipazioni apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 102 + 100 apps/client/src/app/components/home-holdings/home-holdings.html @@ -1927,7 +1915,7 @@ Current week apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 197 + 201 @@ -2027,16 +2015,20 @@ Attività apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 86 + 84 apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 113 + 111 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html 226 + + apps/client/src/app/components/admin-overview/admin-overview.html + 38 + apps/client/src/app/components/admin-tag/admin-tag.component.html 45 @@ -2051,7 +2043,7 @@ apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 348 + 344 apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html @@ -2111,7 +2103,7 @@ apps/client/src/app/components/header/header.component.html - 389 + 388 apps/client/src/app/pages/pricing/pricing-page.routes.ts @@ -2535,7 +2527,7 @@ Interesse apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 69 + 67 apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html @@ -2675,11 +2667,11 @@ Could not validate form apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 570 + 594 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 573 + 597 @@ -2755,7 +2747,7 @@ Evoluzione del portafoglio apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 407 + 405 @@ -2839,7 +2831,7 @@ apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 318 + 316 libs/ui/src/lib/i18n.ts @@ -2883,7 +2875,7 @@ Azione ordinaria apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 57 + 55 libs/ui/src/lib/i18n.ts @@ -3139,7 +3131,7 @@ Dividendi apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 81 + 79 apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html @@ -3171,7 +3163,7 @@ Cronologia dei dividendi apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 495 + 493 @@ -3207,7 +3199,7 @@ Registrazione utente apps/client/src/app/components/admin-overview/admin-overview.html - 37 + 50 @@ -3239,7 +3231,7 @@ Dati del mercato apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 403 + 399 libs/common/src/lib/routes/routes.ts @@ -3249,6 +3241,10 @@ Users Utenti + + apps/client/src/app/components/admin-overview/admin-overview.html + 24 + libs/common/src/lib/routes/routes.ts 61 @@ -3635,11 +3631,11 @@ Could not save asset profile apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 604 + 628 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 607 + 631 @@ -3871,7 +3867,7 @@ Current year apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 205 + 209 @@ -3907,7 +3903,7 @@ Asset profile has been saved apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 594 + 618 @@ -4051,7 +4047,7 @@ Serie attuale apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 457 + 455 @@ -4059,7 +4055,7 @@ Serie più lunga apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 466 + 464 @@ -4483,7 +4479,7 @@ Sign in with OpenID Connect apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 55 + 57 @@ -4807,11 +4803,11 @@ Could not parse scraper configuration apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 525 + 545 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 528 + 548 @@ -5541,7 +5537,7 @@ Versione apps/client/src/app/components/admin-overview/admin-overview.html - 7 + 11 @@ -5737,7 +5733,7 @@ Confermi di voler cancellare questo messaggio di sistema? apps/client/src/app/components/admin-overview/admin-overview.component.ts - 209 + 227 @@ -5761,7 +5757,7 @@ Saldi di cassa apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 146 + 142 @@ -5797,7 +5793,7 @@ L’attuale prezzo di mercato è apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 722 + 743 @@ -5917,7 +5913,7 @@ Close Holding apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 447 + 443 @@ -5925,7 +5921,7 @@ Rendimento assoluto dell’Asset apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 168 + 166 @@ -5933,7 +5929,7 @@ Rendimento dell’Asset apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 190 + 188 @@ -5941,7 +5937,7 @@ Rendimento assoluto della Valuta apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 211 + 209 @@ -5949,7 +5945,7 @@ Rendimento della Valuta apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 236 + 234 @@ -5957,7 +5953,7 @@ Da inizio settimana libs/ui/src/lib/assistant/assistant.component.ts - 366 + 371 @@ -5965,11 +5961,11 @@ Settimana corrente apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 197 + 201 libs/ui/src/lib/assistant/assistant.component.ts - 366 + 371 @@ -5977,7 +5973,7 @@ Da inizio mese libs/ui/src/lib/assistant/assistant.component.ts - 370 + 375 @@ -5985,11 +5981,11 @@ Mese corrente apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 201 + 205 libs/ui/src/lib/assistant/assistant.component.ts - 370 + 375 @@ -5997,7 +5993,7 @@ Da inizio anno libs/ui/src/lib/assistant/assistant.component.ts - 374 + 379 @@ -6033,7 +6029,7 @@ anno apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 209 + 213 apps/client/src/app/pages/resources/personal-finance-tools/product-page.html @@ -6045,7 +6041,7 @@ libs/ui/src/lib/assistant/assistant.component.ts - 384 + 389 @@ -6053,11 +6049,11 @@ anni apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 213 + 217 libs/ui/src/lib/assistant/assistant.component.ts - 408 + 413 @@ -6077,7 +6073,7 @@ apps/client/src/app/components/admin-overview/admin-overview.html - 63 + 76 @@ -6266,7 +6262,7 @@ Chiudi l’account apps/client/src/app/components/user-account-settings/user-account-settings.html - 316 + 318 @@ -6702,7 +6698,7 @@ Errore apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 713 + 734 @@ -7400,7 +7396,7 @@ Accesso ricevuto apps/client/src/app/components/user-account-access/user-account-access.html - 53 + 55 @@ -7424,7 +7420,7 @@ Change with currency effect apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 116 + 115 @@ -7448,7 +7444,7 @@ Pigro apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 231 + 235 @@ -7456,7 +7452,7 @@ Istantaneo apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 235 + 239 @@ -7496,7 +7492,7 @@ fine giornata apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 231 + 235 @@ -7504,7 +7500,7 @@ in tempo reale apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 235 + 239 @@ -7588,7 +7584,7 @@ Total amount apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 95 + 94 @@ -7680,7 +7676,7 @@ Find account, holding or page... libs/ui/src/lib/assistant/assistant.component.ts - 115 + 116 @@ -7745,7 +7741,7 @@ () e gia in uso. apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 649 + 675 @@ -7753,7 +7749,7 @@ Si è verificato un errore durante l’aggiornamento di (). apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 657 + 683 @@ -7817,7 +7813,7 @@ qualcuno apps/client/src/app/pages/public/public-page.component.ts - 61 + 64 @@ -7890,7 +7886,7 @@ L’account utente demo è stato sincronizzato. apps/client/src/app/components/admin-overview/admin-overview.component.ts - 277 + 295 @@ -7898,7 +7894,7 @@ Sincronizza l’account utente demo apps/client/src/app/components/admin-overview/admin-overview.html - 204 + 217 @@ -8104,7 +8100,7 @@ Current month apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 201 + 205 @@ -8277,7 +8273,7 @@ Generare apps/client/src/app/components/user-account-access/user-account-access.html - 43 + 45 @@ -8325,7 +8321,7 @@ Gestisci profilo risorsa apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 471 + 467 diff --git a/apps/client/src/locales/messages.ko.xlf b/apps/client/src/locales/messages.ko.xlf index 0d35cd867..ba8593336 100644 --- a/apps/client/src/locales/messages.ko.xlf +++ b/apps/client/src/locales/messages.ko.xlf @@ -308,7 +308,7 @@ 현금 잔액 apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 45 + 43 apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html @@ -324,7 +324,7 @@ 플랫폼 apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 90 + 88 apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html @@ -340,7 +340,7 @@ 현금 잔액 apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 146 + 142 @@ -552,7 +552,7 @@ apps/client/src/app/components/admin-overview/admin-overview.html - 140 + 153 apps/client/src/app/components/admin-platform/admin-platform.component.html @@ -1080,7 +1080,7 @@ 이 쿠폰을 정말 삭제하시겠습니까? apps/client/src/app/components/admin-overview/admin-overview.component.ts - 196 + 214 @@ -1088,7 +1088,7 @@ 이 시스템 메시지를 정말 삭제하시겠습니까? apps/client/src/app/components/admin-overview/admin-overview.component.ts - 209 + 227 @@ -1096,7 +1096,7 @@ 정말로 캐시를 플러시하시겠습니까? apps/client/src/app/components/admin-overview/admin-overview.component.ts - 233 + 251 @@ -1104,7 +1104,7 @@ 시스템 메시지를 설정하십시오: apps/client/src/app/components/admin-overview/admin-overview.component.ts - 253 + 271 @@ -1112,31 +1112,15 @@ 버전 apps/client/src/app/components/admin-overview/admin-overview.html - 7 - - - - User Count - 사용자 수 - - apps/client/src/app/components/admin-overview/admin-overview.html - 16 - - - - Activity Count - 활동 수 - - apps/client/src/app/components/admin-overview/admin-overview.html - 22 + 11 per User 사용자당 - apps/client/src/app/components/admin-overview/admin-overview.html - 31 + apps/client/src/app/components/admin-overview/admin-overview.component.ts + 159 @@ -1152,7 +1136,7 @@ 사용자 가입 apps/client/src/app/components/admin-overview/admin-overview.html - 37 + 50 @@ -1160,7 +1144,7 @@ 읽기 전용 모드 apps/client/src/app/components/admin-overview/admin-overview.html - 51 + 64 @@ -1168,7 +1152,7 @@ 시스템 메시지 apps/client/src/app/components/admin-overview/admin-overview.html - 75 + 88 @@ -1176,7 +1160,7 @@ 메시지 설정 apps/client/src/app/components/admin-overview/admin-overview.html - 97 + 110 @@ -1184,7 +1168,7 @@ 쿠폰 apps/client/src/app/components/admin-overview/admin-overview.html - 105 + 118 @@ -1192,7 +1176,7 @@ 추가 apps/client/src/app/components/admin-overview/admin-overview.html - 185 + 198 libs/ui/src/lib/account-balances/account-balances.component.html @@ -1204,7 +1188,7 @@ 유지 관리 apps/client/src/app/components/admin-overview/admin-overview.html - 193 + 206 @@ -1212,7 +1196,7 @@ 캐시 플러시 apps/client/src/app/components/admin-overview/admin-overview.html - 209 + 222 @@ -1248,7 +1232,7 @@ Asset profile has been saved apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 594 + 618 @@ -1288,7 +1272,7 @@ 올해 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 205 + 209 @@ -1428,11 +1412,11 @@ Could not validate form apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 570 + 594 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 573 + 597 @@ -1512,7 +1496,7 @@ 로그인 apps/client/src/app/components/header/header.component.html - 422 + 425 apps/client/src/app/components/header/header.component.ts @@ -1520,7 +1504,7 @@ apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 79 + 81 libs/common/src/lib/routes/routes.ts @@ -1684,7 +1668,7 @@ 이번주 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 197 + 201 @@ -1752,7 +1736,7 @@ apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 32 + 34 apps/client/src/app/pages/landing/landing-page.html @@ -1792,7 +1776,7 @@ 구글로 로그인 apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 44 + 46 @@ -1800,7 +1784,7 @@ 로그인 상태 유지 apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 66 + 68 @@ -1840,7 +1824,7 @@ apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 259 + 257 @@ -1852,7 +1836,7 @@ apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 278 + 276 @@ -1964,7 +1948,7 @@ 데이터 결함 보고 apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 456 + 452 @@ -2144,7 +2128,7 @@ libs/ui/src/lib/assistant/assistant.component.ts - 362 + 367 @@ -2152,11 +2136,11 @@ 연초 대비 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 205 + 209 libs/ui/src/lib/assistant/assistant.component.ts - 374 + 379 @@ -2164,11 +2148,11 @@ 1년 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 209 + 213 libs/ui/src/lib/assistant/assistant.component.ts - 384 + 389 @@ -2176,11 +2160,11 @@ 5년 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 213 + 217 libs/ui/src/lib/assistant/assistant.component.ts - 408 + 413 @@ -2188,7 +2172,7 @@ Performance with currency effect apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 135 + 134 @@ -2196,11 +2180,11 @@ 맥스 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 217 + 221 libs/ui/src/lib/assistant/assistant.component.ts - 414 + 419 @@ -2224,7 +2208,7 @@ 액세스 권한 부여 apps/client/src/app/components/user-account-access/user-account-access.html - 57 + 59 @@ -2540,7 +2524,7 @@ apps/client/src/app/components/header/header.component.html - 375 + 374 apps/client/src/app/pages/about/overview/about-overview-page.routes.ts @@ -2652,7 +2636,7 @@ apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 381 + 377 apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html @@ -2748,7 +2732,7 @@ 시장 데이터 apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 403 + 399 libs/common/src/lib/routes/routes.ts @@ -2774,6 +2758,10 @@ Users 사용자 + + apps/client/src/app/components/admin-overview/admin-overview.html + 24 + libs/common/src/lib/routes/routes.ts 61 @@ -2918,6 +2906,10 @@ apps/client/src/app/pages/blog/2025/11/black-weeks-2025/black-weeks-2025-page.html 147 + + apps/client/src/app/pages/blog/2026/04/ghostfolio-3/ghostfolio-3-page.html + 269 + apps/client/src/app/pages/blog/blog-page.html 5 @@ -2932,11 +2924,11 @@ Could not parse scraper configuration apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 525 + 545 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 528 + 548 @@ -2984,7 +2976,7 @@ apps/client/src/app/components/header/header.component.html - 361 + 360 apps/client/src/app/pages/features/features-page.html @@ -3104,7 +3096,7 @@ 시작하기 apps/client/src/app/components/header/header.component.html - 433 + 436 apps/client/src/app/pages/features/features-page.html @@ -3136,7 +3128,7 @@ 보유 종목 apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 102 + 100 apps/client/src/app/components/home-holdings/home-holdings.html @@ -3184,7 +3176,7 @@ apps/client/src/app/components/header/header.component.html - 408 + 407 apps/client/src/app/components/home-market/home-market.html @@ -3664,16 +3656,20 @@ 활동 apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 86 + 84 apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 113 + 111 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html 226 + + apps/client/src/app/components/admin-overview/admin-overview.html + 38 + apps/client/src/app/components/admin-tag/admin-tag.component.html 45 @@ -3688,7 +3684,7 @@ apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 348 + 344 apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html @@ -4136,7 +4132,7 @@ 배당금 apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 81 + 79 apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html @@ -4200,7 +4196,7 @@ 상위 apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 305 + 303 @@ -4208,7 +4204,7 @@ 하위 apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 354 + 352 @@ -4216,7 +4212,7 @@ 포트폴리오 진화 apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 407 + 405 @@ -4224,7 +4220,7 @@ 투자 일정 apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 436 + 434 @@ -4232,7 +4228,7 @@ 현재 연속 apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 457 + 455 @@ -4240,7 +4236,7 @@ 최장 연속 apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 466 + 464 @@ -4248,7 +4244,7 @@ 배당 일정 apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 495 + 493 @@ -4284,7 +4280,7 @@ apps/client/src/app/components/header/header.component.html - 389 + 388 apps/client/src/app/pages/pricing/pricing-page.routes.ts @@ -4472,11 +4468,11 @@ Could not save asset profile apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 604 + 628 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 607 + 631 @@ -5141,7 +5137,7 @@ 관심 apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 69 + 67 apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html @@ -5457,7 +5453,7 @@ apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 318 + 316 libs/ui/src/lib/i18n.ts @@ -5509,7 +5505,7 @@ OpenID Connect로 로그인 apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 55 + 57 @@ -5593,7 +5589,7 @@ 주식 apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 57 + 55 libs/ui/src/lib/i18n.ts @@ -5829,7 +5825,7 @@ 현재 시장가격은 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 722 + 743 @@ -5909,7 +5905,7 @@ 절대적인 통화 성과 apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 211 + 209 @@ -5917,7 +5913,7 @@ 닫기 보유 apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 447 + 443 @@ -5925,7 +5921,7 @@ 절대자산성과 apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 168 + 166 @@ -5965,7 +5961,7 @@ 자산 성과 apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 190 + 188 @@ -5973,7 +5969,7 @@ 통화 성과 apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 236 + 234 @@ -5981,7 +5977,7 @@ 연초 현재 libs/ui/src/lib/assistant/assistant.component.ts - 374 + 379 @@ -5989,7 +5985,7 @@ 이번주 현재까지 libs/ui/src/lib/assistant/assistant.component.ts - 366 + 371 @@ -5997,7 +5993,7 @@ 월간 누계 libs/ui/src/lib/assistant/assistant.component.ts - 370 + 375 @@ -6005,11 +6001,11 @@ MTD apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 201 + 205 libs/ui/src/lib/assistant/assistant.component.ts - 370 + 375 @@ -6017,11 +6013,11 @@ WTD apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 197 + 201 libs/ui/src/lib/assistant/assistant.component.ts - 366 + 371 @@ -6057,7 +6053,7 @@ 년도 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 209 + 213 apps/client/src/app/pages/resources/personal-finance-tools/product-page.html @@ -6069,7 +6065,7 @@ libs/ui/src/lib/assistant/assistant.component.ts - 384 + 389 @@ -6077,11 +6073,11 @@ 연령 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 213 + 217 libs/ui/src/lib/assistant/assistant.component.ts - 408 + 413 @@ -6126,7 +6122,7 @@ apps/client/src/app/components/admin-overview/admin-overview.html - 63 + 76 @@ -6274,7 +6270,7 @@ 계정 폐쇄 apps/client/src/app/components/user-account-settings/user-account-settings.html - 316 + 318 @@ -6726,7 +6722,7 @@ 오류 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 713 + 734 @@ -7412,7 +7408,7 @@ 수신된 액세스 apps/client/src/app/components/user-account-access/user-account-access.html - 53 + 55 @@ -7448,7 +7444,7 @@ Change with currency effect apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 116 + 115 @@ -7496,7 +7492,7 @@ 즉각적인 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 235 + 239 @@ -7504,7 +7500,7 @@ 게으른 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 231 + 235 @@ -7520,7 +7516,7 @@ 실시간 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 235 + 239 @@ -7528,7 +7524,7 @@ 하루의 끝 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 231 + 235 @@ -7612,7 +7608,7 @@ Total amount apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 95 + 94 @@ -7692,7 +7688,7 @@ 계정, 보유 또는 페이지 찾기... libs/ui/src/lib/assistant/assistant.component.ts - 115 + 116 @@ -7769,7 +7765,7 @@ ()은(는) 이미 사용 중입니다. apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 649 + 675 @@ -7777,7 +7773,7 @@ ()로 업데이트하는 동안 오류가 발생했습니다. apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 657 + 683 @@ -7817,7 +7813,7 @@ 누구 apps/client/src/app/pages/public/public-page.component.ts - 61 + 64 @@ -7890,7 +7886,7 @@ 데모 사용자 계정 동기화 apps/client/src/app/components/admin-overview/admin-overview.html - 204 + 217 @@ -7898,7 +7894,7 @@ 데모 사용자 계정이 동기화되었습니다. apps/client/src/app/components/admin-overview/admin-overview.component.ts - 277 + 295 @@ -8104,7 +8100,7 @@ 이번 달 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 201 + 205 @@ -8269,7 +8265,7 @@ 생성하다 apps/client/src/app/components/user-account-access/user-account-access.html - 43 + 45 @@ -8325,7 +8321,7 @@ 자산 프로필 관리 apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 471 + 467 diff --git a/apps/client/src/locales/messages.nl.xlf b/apps/client/src/locales/messages.nl.xlf index 288628863..ae73be0c0 100644 --- a/apps/client/src/locales/messages.nl.xlf +++ b/apps/client/src/locales/messages.nl.xlf @@ -266,7 +266,7 @@ apps/client/src/app/components/admin-overview/admin-overview.html - 140 + 153 apps/client/src/app/components/admin-platform/admin-platform.component.html @@ -493,14 +493,6 @@ 45 - - Activity Count - Aantal activiteiten - - apps/client/src/app/components/admin-overview/admin-overview.html - 22 - - Historical Data Historische gegevens @@ -518,7 +510,7 @@ Wil je deze coupon echt verwijderen? apps/client/src/app/components/admin-overview/admin-overview.component.ts - 196 + 214 @@ -526,7 +518,7 @@ Wil je echt de cache legen? apps/client/src/app/components/admin-overview/admin-overview.component.ts - 233 + 251 @@ -534,23 +526,15 @@ Stel je systeemboodschap in: apps/client/src/app/components/admin-overview/admin-overview.component.ts - 253 - - - - User Count - Aantal gebruikers - - apps/client/src/app/components/admin-overview/admin-overview.html - 16 + 271 per User per gebruiker - apps/client/src/app/components/admin-overview/admin-overview.html - 31 + apps/client/src/app/components/admin-overview/admin-overview.component.ts + 159 @@ -578,7 +562,7 @@ Systeembericht apps/client/src/app/components/admin-overview/admin-overview.html - 75 + 88 @@ -586,7 +570,7 @@ Bericht instellen apps/client/src/app/components/admin-overview/admin-overview.html - 97 + 110 @@ -594,7 +578,7 @@ Alleen lezen apps/client/src/app/components/admin-overview/admin-overview.html - 51 + 64 @@ -602,7 +586,7 @@ Coupons apps/client/src/app/components/admin-overview/admin-overview.html - 105 + 118 @@ -610,7 +594,7 @@ Toevoegen apps/client/src/app/components/admin-overview/admin-overview.html - 185 + 198 libs/ui/src/lib/account-balances/account-balances.component.html @@ -622,7 +606,7 @@ Huishouding apps/client/src/app/components/admin-overview/admin-overview.html - 193 + 206 @@ -630,7 +614,7 @@ Cache legen apps/client/src/app/components/admin-overview/admin-overview.html - 209 + 222 @@ -710,7 +694,7 @@ Aan de slag apps/client/src/app/components/header/header.component.html - 433 + 436 apps/client/src/app/pages/features/features-page.html @@ -742,7 +726,7 @@ Aanmelden apps/client/src/app/components/header/header.component.html - 422 + 425 apps/client/src/app/components/header/header.component.ts @@ -750,7 +734,7 @@ apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 79 + 81 libs/common/src/lib/routes/routes.ts @@ -834,7 +818,7 @@ apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 32 + 34 apps/client/src/app/pages/landing/landing-page.html @@ -874,7 +858,7 @@ Aanmelden met Google apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 44 + 46 @@ -882,7 +866,7 @@ Aangemeld blijven apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 66 + 68 @@ -910,7 +894,7 @@ apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 259 + 257 @@ -922,7 +906,7 @@ apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 278 + 276 @@ -1022,7 +1006,7 @@ Gegevensstoring melden apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 456 + 452 @@ -1062,7 +1046,7 @@ libs/ui/src/lib/assistant/assistant.component.ts - 362 + 367 @@ -1070,11 +1054,11 @@ YTD apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 205 + 209 libs/ui/src/lib/assistant/assistant.component.ts - 374 + 379 @@ -1082,11 +1066,11 @@ 1J apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 209 + 213 libs/ui/src/lib/assistant/assistant.component.ts - 384 + 389 @@ -1094,11 +1078,11 @@ 5J apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 213 + 217 libs/ui/src/lib/assistant/assistant.component.ts - 408 + 413 @@ -1106,7 +1090,7 @@ Prestaties met valuta-effect apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 135 + 134 @@ -1114,11 +1098,11 @@ Max apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 217 + 221 libs/ui/src/lib/assistant/assistant.component.ts - 414 + 419 @@ -1150,7 +1134,7 @@ apps/client/src/app/components/header/header.component.html - 375 + 374 apps/client/src/app/pages/about/overview/about-overview-page.routes.ts @@ -1342,7 +1326,7 @@ Verleende toegang apps/client/src/app/components/user-account-access/user-account-access.html - 57 + 59 @@ -1382,7 +1366,7 @@ apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 381 + 377 apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html @@ -1454,7 +1438,7 @@ Saldo apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 45 + 43 apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html @@ -1470,7 +1454,7 @@ Platform apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 90 + 88 apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html @@ -1604,6 +1588,10 @@ apps/client/src/app/pages/blog/2025/11/black-weeks-2025/black-weeks-2025-page.html 147 + + apps/client/src/app/pages/blog/2026/04/ghostfolio-3/ghostfolio-3-page.html + 269 + apps/client/src/app/pages/blog/blog-page.html 5 @@ -1650,7 +1638,7 @@ apps/client/src/app/components/header/header.component.html - 361 + 360 apps/client/src/app/pages/features/features-page.html @@ -1714,7 +1702,7 @@ apps/client/src/app/components/header/header.component.html - 408 + 407 apps/client/src/app/components/home-market/home-market.html @@ -1850,7 +1838,7 @@ Tijdlijn investeringen apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 436 + 434 @@ -1858,7 +1846,7 @@ Winnaars apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 305 + 303 @@ -1866,7 +1854,7 @@ Verliezers apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 354 + 352 @@ -1890,7 +1878,7 @@ Posities apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 102 + 100 apps/client/src/app/components/home-holdings/home-holdings.html @@ -1926,7 +1914,7 @@ Huidige week apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 197 + 201 @@ -2026,16 +2014,20 @@ Activiteiten apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 86 + 84 apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 113 + 111 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html 226 + + apps/client/src/app/components/admin-overview/admin-overview.html + 38 + apps/client/src/app/components/admin-tag/admin-tag.component.html 45 @@ -2050,7 +2042,7 @@ apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 348 + 344 apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html @@ -2110,7 +2102,7 @@ apps/client/src/app/components/header/header.component.html - 389 + 388 apps/client/src/app/pages/pricing/pricing-page.routes.ts @@ -2534,7 +2526,7 @@ Rente apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 69 + 67 apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html @@ -2674,11 +2666,11 @@ Het formulier kon niet worden gevalideerd. apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 570 + 594 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 573 + 597 @@ -2754,7 +2746,7 @@ Waardeontwikkeling van portefeuille apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 407 + 405 @@ -2838,7 +2830,7 @@ apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 318 + 316 libs/ui/src/lib/i18n.ts @@ -2882,7 +2874,7 @@ Equity apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 57 + 55 libs/ui/src/lib/i18n.ts @@ -3138,7 +3130,7 @@ Dividend apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 81 + 79 apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html @@ -3170,7 +3162,7 @@ Tijdlijn dividend apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 495 + 493 @@ -3206,7 +3198,7 @@ Account aanmaken apps/client/src/app/components/admin-overview/admin-overview.html - 37 + 50 @@ -3238,7 +3230,7 @@ Marktgegevens apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 403 + 399 libs/common/src/lib/routes/routes.ts @@ -3248,6 +3240,10 @@ Users Gebruikers + + apps/client/src/app/components/admin-overview/admin-overview.html + 24 + libs/common/src/lib/routes/routes.ts 61 @@ -3634,11 +3630,11 @@ Kon het assetprofiel niet opslaan apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 604 + 628 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 607 + 631 @@ -3870,7 +3866,7 @@ Huidig jaar apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 205 + 209 @@ -3906,7 +3902,7 @@ Het activaprofiel is opgeslagen. apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 594 + 618 @@ -4050,7 +4046,7 @@ Huidige reeks apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 457 + 455 @@ -4058,7 +4054,7 @@ Langste reeks apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 466 + 464 @@ -4482,7 +4478,7 @@ Meld je aan met OpenID Connect apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 55 + 57 @@ -4806,11 +4802,11 @@ De scraperconfiguratie kon niet worden geparseerd apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 525 + 545 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 528 + 548 @@ -5540,7 +5536,7 @@ Versie apps/client/src/app/components/admin-overview/admin-overview.html - 7 + 11 @@ -5736,7 +5732,7 @@ Wilt u dit systeembericht echt verwijderen? apps/client/src/app/components/admin-overview/admin-overview.component.ts - 209 + 227 @@ -5760,7 +5756,7 @@ Contant Saldo apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 146 + 142 @@ -5796,7 +5792,7 @@ De huidige markt waarde is apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 722 + 743 @@ -5916,7 +5912,7 @@ Sluit Holding apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 447 + 443 @@ -5924,7 +5920,7 @@ Absolute Activaprestaties apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 168 + 166 @@ -5932,7 +5928,7 @@ Activaprestaties apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 190 + 188 @@ -5940,7 +5936,7 @@ Absolute Valutaprestaties apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 211 + 209 @@ -5948,7 +5944,7 @@ Valutaprestaties apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 236 + 234 @@ -5956,7 +5952,7 @@ Week tot nu toe libs/ui/src/lib/assistant/assistant.component.ts - 366 + 371 @@ -5964,11 +5960,11 @@ Week tot nu toe apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 197 + 201 libs/ui/src/lib/assistant/assistant.component.ts - 366 + 371 @@ -5976,7 +5972,7 @@ Maand tot nu toe libs/ui/src/lib/assistant/assistant.component.ts - 370 + 375 @@ -5984,11 +5980,11 @@ MTD apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 201 + 205 libs/ui/src/lib/assistant/assistant.component.ts - 370 + 375 @@ -5996,7 +5992,7 @@ Jaar tot nu toe libs/ui/src/lib/assistant/assistant.component.ts - 374 + 379 @@ -6032,7 +6028,7 @@ jaar apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 209 + 213 apps/client/src/app/pages/resources/personal-finance-tools/product-page.html @@ -6044,7 +6040,7 @@ libs/ui/src/lib/assistant/assistant.component.ts - 384 + 389 @@ -6052,11 +6048,11 @@ jaren apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 213 + 217 libs/ui/src/lib/assistant/assistant.component.ts - 408 + 413 @@ -6076,7 +6072,7 @@ apps/client/src/app/components/admin-overview/admin-overview.html - 63 + 76 @@ -6265,7 +6261,7 @@ Account Sluiten apps/client/src/app/components/user-account-settings/user-account-settings.html - 316 + 318 @@ -6701,7 +6697,7 @@ Fout apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 713 + 734 @@ -7399,7 +7395,7 @@ Toegang Verkregen apps/client/src/app/components/user-account-access/user-account-access.html - 53 + 55 @@ -7423,7 +7419,7 @@ Verandering met valuta-effect apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 116 + 115 @@ -7447,7 +7443,7 @@ Lui apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 231 + 235 @@ -7455,7 +7451,7 @@ Direct apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 235 + 239 @@ -7495,7 +7491,7 @@ eind van de dag apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 231 + 235 @@ -7503,7 +7499,7 @@ real-time apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 235 + 239 @@ -7587,7 +7583,7 @@ Totaal bedrag apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 95 + 94 @@ -7679,7 +7675,7 @@ Vindt een account, holding of pagina... libs/ui/src/lib/assistant/assistant.component.ts - 115 + 116 @@ -7744,7 +7740,7 @@ () is al in gebruik. apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 649 + 675 @@ -7752,7 +7748,7 @@ Er is een fout opgetreden tijdens het updaten naar (). apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 657 + 683 @@ -7816,7 +7812,7 @@ iemand apps/client/src/app/pages/public/public-page.component.ts - 61 + 64 @@ -7889,7 +7885,7 @@ Demo-gebruikersaccount is gesynchroniseerd. apps/client/src/app/components/admin-overview/admin-overview.component.ts - 277 + 295 @@ -7897,7 +7893,7 @@ Synchroniseer demo-gebruikersaccount apps/client/src/app/components/admin-overview/admin-overview.html - 204 + 217 @@ -8103,7 +8099,7 @@ Huidige maand apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 201 + 205 @@ -8276,7 +8272,7 @@ Genereren apps/client/src/app/components/user-account-access/user-account-access.html - 43 + 45 @@ -8324,7 +8320,7 @@ Beheer activaprofiel apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 471 + 467 diff --git a/apps/client/src/locales/messages.pl.xlf b/apps/client/src/locales/messages.pl.xlf index ff5299356..99a9f2798 100644 --- a/apps/client/src/locales/messages.pl.xlf +++ b/apps/client/src/locales/messages.pl.xlf @@ -307,7 +307,7 @@ Saldo Gotówkowe apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 45 + 43 apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html @@ -323,7 +323,7 @@ Platforma apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 90 + 88 apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html @@ -543,7 +543,7 @@ apps/client/src/app/components/admin-overview/admin-overview.html - 140 + 153 apps/client/src/app/components/admin-platform/admin-platform.component.html @@ -1047,7 +1047,7 @@ Czy naprawdę chcesz usunąć ten kupon? apps/client/src/app/components/admin-overview/admin-overview.component.ts - 196 + 214 @@ -1055,7 +1055,7 @@ Czy naprawdę chcesz usunąć tę wiadomość systemową? apps/client/src/app/components/admin-overview/admin-overview.component.ts - 209 + 227 @@ -1063,7 +1063,7 @@ Czy naprawdę chcesz wyczyścić pamięć podręczną? apps/client/src/app/components/admin-overview/admin-overview.component.ts - 233 + 251 @@ -1071,7 +1071,7 @@ Proszę ustawić swoją wiadomość systemową: apps/client/src/app/components/admin-overview/admin-overview.component.ts - 253 + 271 @@ -1079,31 +1079,15 @@ Wersja apps/client/src/app/components/admin-overview/admin-overview.html - 7 - - - - User Count - Liczba Użytkowników - - apps/client/src/app/components/admin-overview/admin-overview.html - 16 - - - - Activity Count - Liczba Aktywności - - apps/client/src/app/components/admin-overview/admin-overview.html - 22 + 11 per User na Użytkownika - apps/client/src/app/components/admin-overview/admin-overview.html - 31 + apps/client/src/app/components/admin-overview/admin-overview.component.ts + 159 @@ -1119,7 +1103,7 @@ Rejestracja Użytkownika apps/client/src/app/components/admin-overview/admin-overview.html - 37 + 50 @@ -1127,7 +1111,7 @@ Tryb Tylko do Odczytu apps/client/src/app/components/admin-overview/admin-overview.html - 51 + 64 @@ -1135,7 +1119,7 @@ Wiadomość Systemowa apps/client/src/app/components/admin-overview/admin-overview.html - 75 + 88 @@ -1143,7 +1127,7 @@ Ustaw Wiadomość apps/client/src/app/components/admin-overview/admin-overview.html - 97 + 110 @@ -1151,7 +1135,7 @@ Kupony apps/client/src/app/components/admin-overview/admin-overview.html - 105 + 118 @@ -1159,7 +1143,7 @@ Dodaj apps/client/src/app/components/admin-overview/admin-overview.html - 185 + 198 libs/ui/src/lib/account-balances/account-balances.component.html @@ -1171,7 +1155,7 @@ Konserwacja apps/client/src/app/components/admin-overview/admin-overview.html - 193 + 206 @@ -1179,7 +1163,7 @@ Wyczyszczenie pamięci podręcznej apps/client/src/app/components/admin-overview/admin-overview.html - 209 + 222 @@ -1215,7 +1199,7 @@ Profil zasobu został zapisany apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 594 + 618 @@ -1255,7 +1239,7 @@ Obecny rok apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 205 + 209 @@ -1395,11 +1379,11 @@ Could not validate form apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 570 + 594 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 573 + 597 @@ -1479,7 +1463,7 @@ Zaloguj się apps/client/src/app/components/header/header.component.html - 422 + 425 apps/client/src/app/components/header/header.component.ts @@ -1487,7 +1471,7 @@ apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 79 + 81 libs/common/src/lib/routes/routes.ts @@ -1651,7 +1635,7 @@ Obecny tydzień apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 197 + 201 @@ -1719,7 +1703,7 @@ apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 32 + 34 apps/client/src/app/pages/landing/landing-page.html @@ -1759,7 +1743,7 @@ Zaloguj się przez Google apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 44 + 46 @@ -1767,7 +1751,7 @@ Pozostań zalogowany apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 66 + 68 @@ -1807,7 +1791,7 @@ apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 259 + 257 @@ -1819,7 +1803,7 @@ apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 278 + 276 @@ -1931,7 +1915,7 @@ Zgłoś Błąd Danych apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 456 + 452 @@ -2111,7 +2095,7 @@ libs/ui/src/lib/assistant/assistant.component.ts - 362 + 367 @@ -2119,11 +2103,11 @@ Liczony od początku roku (year-to-date) apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 205 + 209 libs/ui/src/lib/assistant/assistant.component.ts - 374 + 379 @@ -2131,11 +2115,11 @@ 1 rok apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 209 + 213 libs/ui/src/lib/assistant/assistant.component.ts - 384 + 389 @@ -2143,11 +2127,11 @@ 5 lat apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 213 + 217 libs/ui/src/lib/assistant/assistant.component.ts - 408 + 413 @@ -2155,7 +2139,7 @@ Wynik z efektem walutowym apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 135 + 134 @@ -2163,11 +2147,11 @@ Maksimum apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 217 + 221 libs/ui/src/lib/assistant/assistant.component.ts - 414 + 419 @@ -2191,7 +2175,7 @@ Przyznano dostęp apps/client/src/app/components/user-account-access/user-account-access.html - 57 + 59 @@ -2507,7 +2491,7 @@ apps/client/src/app/components/header/header.component.html - 375 + 374 apps/client/src/app/pages/about/overview/about-overview-page.routes.ts @@ -2619,7 +2603,7 @@ apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 381 + 377 apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html @@ -2715,7 +2699,7 @@ Dane Rynkowe apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 403 + 399 libs/common/src/lib/routes/routes.ts @@ -2741,6 +2725,10 @@ Users Użytkownicy + + apps/client/src/app/components/admin-overview/admin-overview.html + 24 + libs/common/src/lib/routes/routes.ts 61 @@ -2885,6 +2873,10 @@ apps/client/src/app/pages/blog/2025/11/black-weeks-2025/black-weeks-2025-page.html 147 + + apps/client/src/app/pages/blog/2026/04/ghostfolio-3/ghostfolio-3-page.html + 269 + apps/client/src/app/pages/blog/blog-page.html 5 @@ -2899,11 +2891,11 @@ Nie udało się przetworzyć konfiguracji scrapera apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 525 + 545 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 528 + 548 @@ -2951,7 +2943,7 @@ apps/client/src/app/components/header/header.component.html - 361 + 360 apps/client/src/app/pages/features/features-page.html @@ -3071,7 +3063,7 @@ Rozpocznij apps/client/src/app/components/header/header.component.html - 433 + 436 apps/client/src/app/pages/features/features-page.html @@ -3103,7 +3095,7 @@ Inwestycje apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 102 + 100 apps/client/src/app/components/home-holdings/home-holdings.html @@ -3151,7 +3143,7 @@ apps/client/src/app/components/header/header.component.html - 408 + 407 apps/client/src/app/components/home-market/home-market.html @@ -3631,16 +3623,20 @@ Aktywności apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 86 + 84 apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 113 + 111 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html 226 + + apps/client/src/app/components/admin-overview/admin-overview.html + 38 + apps/client/src/app/components/admin-tag/admin-tag.component.html 45 @@ -3655,7 +3651,7 @@ apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 348 + 344 apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html @@ -4103,7 +4099,7 @@ Dywidenda apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 81 + 79 apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html @@ -4167,7 +4163,7 @@ Największe wzrosty apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 305 + 303 @@ -4175,7 +4171,7 @@ Największy spadek apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 354 + 352 @@ -4183,7 +4179,7 @@ Rozwój portfela apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 407 + 405 @@ -4191,7 +4187,7 @@ Oś czasu inwestycji apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 436 + 434 @@ -4199,7 +4195,7 @@ Obecna passa apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 457 + 455 @@ -4207,7 +4203,7 @@ Najdłuższa passa apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 466 + 464 @@ -4215,7 +4211,7 @@ Oś czasu dywidend apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 495 + 493 @@ -4251,7 +4247,7 @@ apps/client/src/app/components/header/header.component.html - 389 + 388 apps/client/src/app/pages/pricing/pricing-page.routes.ts @@ -4439,11 +4435,11 @@ Could not save asset profile apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 604 + 628 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 607 + 631 @@ -5072,7 +5068,7 @@ Odsetki apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 69 + 67 apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html @@ -5388,7 +5384,7 @@ apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 318 + 316 libs/ui/src/lib/i18n.ts @@ -5440,7 +5436,7 @@ Zaloguj się za pomocą OpenID Connect apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 55 + 57 @@ -5524,7 +5520,7 @@ Kapitał apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 57 + 55 libs/ui/src/lib/i18n.ts @@ -5760,7 +5756,7 @@ Salda Gotówkowe apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 146 + 142 @@ -5796,7 +5792,7 @@ Obecna cena rynkowa wynosi apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 722 + 743 @@ -5916,7 +5912,7 @@ Zamknij pozycję apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 447 + 443 @@ -5924,7 +5920,7 @@ Łączny wynik aktywów apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 168 + 166 @@ -5932,7 +5928,7 @@ Wyniki aktywów apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 190 + 188 @@ -5940,7 +5936,7 @@ Łączny wynik walut apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 211 + 209 @@ -5948,7 +5944,7 @@ Wynik walut apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 236 + 234 @@ -5956,7 +5952,7 @@ Dotychczasowy tydzień libs/ui/src/lib/assistant/assistant.component.ts - 366 + 371 @@ -5964,11 +5960,11 @@ WTD apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 197 + 201 libs/ui/src/lib/assistant/assistant.component.ts - 366 + 371 @@ -5976,7 +5972,7 @@ Od początku miesiąca libs/ui/src/lib/assistant/assistant.component.ts - 370 + 375 @@ -5984,11 +5980,11 @@ MTD apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 201 + 205 libs/ui/src/lib/assistant/assistant.component.ts - 370 + 375 @@ -5996,7 +5992,7 @@ Od początku roku libs/ui/src/lib/assistant/assistant.component.ts - 374 + 379 @@ -6032,7 +6028,7 @@ rok apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 209 + 213 apps/client/src/app/pages/resources/personal-finance-tools/product-page.html @@ -6044,7 +6040,7 @@ libs/ui/src/lib/assistant/assistant.component.ts - 384 + 389 @@ -6052,11 +6048,11 @@ lata apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 213 + 217 libs/ui/src/lib/assistant/assistant.component.ts - 408 + 413 @@ -6076,7 +6072,7 @@ apps/client/src/app/components/admin-overview/admin-overview.html - 63 + 76 @@ -6265,7 +6261,7 @@ Zamknij Konto apps/client/src/app/components/user-account-settings/user-account-settings.html - 316 + 318 @@ -6701,7 +6697,7 @@ Błąd apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 713 + 734 @@ -7399,7 +7395,7 @@ Otrzymany dostęp apps/client/src/app/components/user-account-access/user-account-access.html - 53 + 55 @@ -7423,7 +7419,7 @@ Zmiana z efektem walutowym apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 116 + 115 @@ -7447,7 +7443,7 @@ Leniwy apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 231 + 235 @@ -7455,7 +7451,7 @@ Natychmiastowy apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 235 + 239 @@ -7495,7 +7491,7 @@ koniec dnia apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 231 + 235 @@ -7503,7 +7499,7 @@ w czasie rzeczywistym apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 235 + 239 @@ -7587,7 +7583,7 @@ Wartość portfela apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 95 + 94 @@ -7679,7 +7675,7 @@ Znajdź konto, pozycję lub stronę... libs/ui/src/lib/assistant/assistant.component.ts - 115 + 116 @@ -7744,7 +7740,7 @@ () jest już w użyciu. apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 649 + 675 @@ -7752,7 +7748,7 @@ Wystąpił błąd podczas aktualizacji do (). apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 657 + 683 @@ -7816,7 +7812,7 @@ ktoś apps/client/src/app/pages/public/public-page.component.ts - 61 + 64 @@ -7889,7 +7885,7 @@ Konto użytkownika demonstracyjnego zostało zsynchronizowane. apps/client/src/app/components/admin-overview/admin-overview.component.ts - 277 + 295 @@ -7897,7 +7893,7 @@ Synchronizuj konto użytkownika demonstracyjnego apps/client/src/app/components/admin-overview/admin-overview.html - 204 + 217 @@ -8103,7 +8099,7 @@ Bieżący miesiąc apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 201 + 205 @@ -8276,7 +8272,7 @@ Generuj apps/client/src/app/components/user-account-access/user-account-access.html - 43 + 45 @@ -8324,7 +8320,7 @@ Zarządzaj profilem aktywów apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 471 + 467 diff --git a/apps/client/src/locales/messages.pt.xlf b/apps/client/src/locales/messages.pt.xlf index 0eb436849..276b27c3c 100644 --- a/apps/client/src/locales/messages.pt.xlf +++ b/apps/client/src/locales/messages.pt.xlf @@ -98,7 +98,7 @@ Plataforma apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 90 + 88 apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html @@ -218,7 +218,7 @@ Saldo disponível em dinheiro apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 45 + 43 apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html @@ -322,7 +322,7 @@ apps/client/src/app/components/admin-overview/admin-overview.html - 140 + 153 apps/client/src/app/components/admin-platform/admin-platform.component.html @@ -557,14 +557,6 @@ 45 - - Activity Count - Número de Atividades - - apps/client/src/app/components/admin-overview/admin-overview.html - 22 - - Historical Data Dados Históricos @@ -610,7 +602,7 @@ Deseja realmente eliminar este cupão? apps/client/src/app/components/admin-overview/admin-overview.component.ts - 196 + 214 @@ -618,7 +610,7 @@ Deseja realmente limpar a cache? apps/client/src/app/components/admin-overview/admin-overview.component.ts - 233 + 251 @@ -626,23 +618,15 @@ Por favor, defina a sua mensagem do sistema: apps/client/src/app/components/admin-overview/admin-overview.component.ts - 253 - - - - User Count - Número de Utilizadores - - apps/client/src/app/components/admin-overview/admin-overview.html - 16 + 271 per User por Utilizador - apps/client/src/app/components/admin-overview/admin-overview.html - 31 + apps/client/src/app/components/admin-overview/admin-overview.component.ts + 159 @@ -658,7 +642,7 @@ Mensagem de Sistema apps/client/src/app/components/admin-overview/admin-overview.html - 75 + 88 @@ -666,7 +650,7 @@ Definir Mensagem apps/client/src/app/components/admin-overview/admin-overview.html - 97 + 110 @@ -674,7 +658,7 @@ Modo Somente Leitura apps/client/src/app/components/admin-overview/admin-overview.html - 51 + 64 @@ -682,7 +666,7 @@ Cupões apps/client/src/app/components/admin-overview/admin-overview.html - 105 + 118 @@ -690,7 +674,7 @@ Adicionar apps/client/src/app/components/admin-overview/admin-overview.html - 185 + 198 libs/ui/src/lib/account-balances/account-balances.component.html @@ -702,7 +686,7 @@ Manutenção apps/client/src/app/components/admin-overview/admin-overview.html - 193 + 206 @@ -710,7 +694,7 @@ Limpar Cache apps/client/src/app/components/admin-overview/admin-overview.html - 209 + 222 @@ -770,11 +754,11 @@ Could not validate form apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 570 + 594 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 573 + 597 @@ -846,7 +830,7 @@ Iniciar sessão apps/client/src/app/components/header/header.component.html - 422 + 425 apps/client/src/app/components/header/header.component.ts @@ -854,7 +838,7 @@ apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 79 + 81 libs/common/src/lib/routes/routes.ts @@ -1002,7 +986,7 @@ apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 32 + 34 apps/client/src/app/pages/landing/landing-page.html @@ -1042,7 +1026,7 @@ Iniciar sessão com Google apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 44 + 46 @@ -1050,7 +1034,7 @@ Manter sessão iniciada apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 66 + 68 @@ -1078,7 +1062,7 @@ apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 259 + 257 @@ -1090,7 +1074,7 @@ apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 278 + 276 @@ -1266,7 +1250,7 @@ Dados do Relatório com Problema apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 456 + 452 @@ -1306,7 +1290,7 @@ libs/ui/src/lib/assistant/assistant.component.ts - 362 + 367 @@ -1314,11 +1298,11 @@ AATD apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 205 + 209 libs/ui/src/lib/assistant/assistant.component.ts - 374 + 379 @@ -1326,11 +1310,11 @@ 1A apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 209 + 213 libs/ui/src/lib/assistant/assistant.component.ts - 384 + 389 @@ -1338,11 +1322,11 @@ 5A apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 213 + 217 libs/ui/src/lib/assistant/assistant.component.ts - 408 + 413 @@ -1350,7 +1334,7 @@ Performance with currency effect apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 135 + 134 @@ -1358,11 +1342,11 @@ Máx apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 217 + 221 libs/ui/src/lib/assistant/assistant.component.ts - 414 + 419 @@ -1430,7 +1414,7 @@ apps/client/src/app/components/header/header.component.html - 375 + 374 apps/client/src/app/pages/about/overview/about-overview-page.routes.ts @@ -1584,6 +1568,10 @@ Users Utilizadores + + apps/client/src/app/components/admin-overview/admin-overview.html + 24 + libs/common/src/lib/routes/routes.ts 61 @@ -1682,7 +1670,7 @@ Acesso Concedido apps/client/src/app/components/user-account-access/user-account-access.html - 57 + 59 @@ -1722,7 +1710,7 @@ apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 381 + 377 apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html @@ -1880,6 +1868,10 @@ apps/client/src/app/pages/blog/2025/11/black-weeks-2025/black-weeks-2025-page.html 147 + + apps/client/src/app/pages/blog/2026/04/ghostfolio-3/ghostfolio-3-page.html + 269 + apps/client/src/app/pages/blog/blog-page.html 5 @@ -1926,7 +1918,7 @@ apps/client/src/app/components/header/header.component.html - 361 + 360 apps/client/src/app/pages/features/features-page.html @@ -1990,7 +1982,7 @@ apps/client/src/app/components/header/header.component.html - 408 + 407 apps/client/src/app/components/home-market/home-market.html @@ -2030,16 +2022,20 @@ Atividades apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 86 + 84 apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 113 + 111 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html 226 + + apps/client/src/app/components/admin-overview/admin-overview.html + 38 + apps/client/src/app/components/admin-tag/admin-tag.component.html 45 @@ -2054,7 +2050,7 @@ apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 348 + 344 apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html @@ -2086,7 +2082,7 @@ Current week apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 197 + 201 @@ -2378,7 +2374,7 @@ Topo apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 305 + 303 @@ -2386,7 +2382,7 @@ Fundo apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 354 + 352 @@ -2394,7 +2390,7 @@ Evolução do Portefólio apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 407 + 405 @@ -2402,7 +2398,7 @@ Cronograma de Investimento apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 436 + 434 @@ -2426,7 +2422,7 @@ Posições apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 102 + 100 apps/client/src/app/components/home-holdings/home-holdings.html @@ -2466,7 +2462,7 @@ apps/client/src/app/components/header/header.component.html - 389 + 388 apps/client/src/app/pages/pricing/pricing-page.routes.ts @@ -2518,7 +2514,7 @@ Começar apps/client/src/app/components/header/header.component.html - 433 + 436 apps/client/src/app/pages/features/features-page.html @@ -2762,7 +2758,7 @@ Juros apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 69 + 67 apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html @@ -2886,7 +2882,7 @@ apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 318 + 316 libs/ui/src/lib/i18n.ts @@ -2930,7 +2926,7 @@ Ações apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 57 + 55 libs/ui/src/lib/i18n.ts @@ -3138,7 +3134,7 @@ Registo do Utilizador apps/client/src/app/components/admin-overview/admin-overview.html - 37 + 50 @@ -3146,7 +3142,7 @@ Dados de Mercado apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 403 + 399 libs/common/src/lib/routes/routes.ts @@ -3202,7 +3198,7 @@ Dividendos apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 81 + 79 apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html @@ -3234,7 +3230,7 @@ Cronograma de Dividendos apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 495 + 493 @@ -3634,11 +3630,11 @@ Could not save asset profile apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 604 + 628 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 607 + 631 @@ -3870,7 +3866,7 @@ Current year apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 205 + 209 @@ -3906,7 +3902,7 @@ Asset profile has been saved apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 594 + 618 @@ -4050,7 +4046,7 @@ Série Atual apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 457 + 455 @@ -4058,7 +4054,7 @@ Série mais Longa apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 466 + 464 @@ -4482,7 +4478,7 @@ Sign in with OpenID Connect apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 55 + 57 @@ -4806,11 +4802,11 @@ Could not parse scraper configuration apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 525 + 545 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 528 + 548 @@ -5540,7 +5536,7 @@ Versão apps/client/src/app/components/admin-overview/admin-overview.html - 7 + 11 @@ -5736,7 +5732,7 @@ Você realmente deseja excluir esta mensagem do sistema? apps/client/src/app/components/admin-overview/admin-overview.component.ts - 209 + 227 @@ -5760,7 +5756,7 @@ Saldos de caixa apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 146 + 142 @@ -5796,7 +5792,7 @@ O preço de mercado atual é apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 722 + 743 @@ -5916,7 +5912,7 @@ Close Holding apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 447 + 443 @@ -5924,7 +5920,7 @@ Desempenho absoluto de ativos apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 168 + 166 @@ -5932,7 +5928,7 @@ Desempenho de ativos apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 190 + 188 @@ -5940,7 +5936,7 @@ Desempenho absoluto da moeda apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 211 + 209 @@ -5948,7 +5944,7 @@ Desempenho da moeda apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 236 + 234 @@ -5956,7 +5952,7 @@ Semana até agora libs/ui/src/lib/assistant/assistant.component.ts - 366 + 371 @@ -5964,11 +5960,11 @@ WTD apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 197 + 201 libs/ui/src/lib/assistant/assistant.component.ts - 366 + 371 @@ -5976,7 +5972,7 @@ Do mês até a data libs/ui/src/lib/assistant/assistant.component.ts - 370 + 375 @@ -5984,11 +5980,11 @@ MTD apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 201 + 205 libs/ui/src/lib/assistant/assistant.component.ts - 370 + 375 @@ -5996,7 +5992,7 @@ No acumulado do ano libs/ui/src/lib/assistant/assistant.component.ts - 374 + 379 @@ -6032,7 +6028,7 @@ ano apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 209 + 213 apps/client/src/app/pages/resources/personal-finance-tools/product-page.html @@ -6044,7 +6040,7 @@ libs/ui/src/lib/assistant/assistant.component.ts - 384 + 389 @@ -6052,11 +6048,11 @@ anos apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 213 + 217 libs/ui/src/lib/assistant/assistant.component.ts - 408 + 413 @@ -6076,7 +6072,7 @@ apps/client/src/app/components/admin-overview/admin-overview.html - 63 + 76 @@ -6265,7 +6261,7 @@ Fechar conta apps/client/src/app/components/user-account-settings/user-account-settings.html - 316 + 318 @@ -6701,7 +6697,7 @@ Erro apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 713 + 734 @@ -7399,7 +7395,7 @@ Received Access apps/client/src/app/components/user-account-access/user-account-access.html - 53 + 55 @@ -7423,7 +7419,7 @@ Change with currency effect apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 116 + 115 @@ -7447,7 +7443,7 @@ Lazy apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 231 + 235 @@ -7455,7 +7451,7 @@ Instant apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 235 + 239 @@ -7495,7 +7491,7 @@ end of day apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 231 + 235 @@ -7503,7 +7499,7 @@ real-time apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 235 + 239 @@ -7587,7 +7583,7 @@ Total amount apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 95 + 94 @@ -7679,7 +7675,7 @@ Find account, holding or page... libs/ui/src/lib/assistant/assistant.component.ts - 115 + 116 @@ -7744,7 +7740,7 @@ () is already in use. apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 649 + 675 @@ -7752,7 +7748,7 @@ An error occurred while updating to (). apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 657 + 683 @@ -7816,7 +7812,7 @@ someone apps/client/src/app/pages/public/public-page.component.ts - 61 + 64 @@ -7889,7 +7885,7 @@ Demo user account has been synced. apps/client/src/app/components/admin-overview/admin-overview.component.ts - 277 + 295 @@ -7897,7 +7893,7 @@ Sync Demo User Account apps/client/src/app/components/admin-overview/admin-overview.html - 204 + 217 @@ -8103,7 +8099,7 @@ Current month apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 201 + 205 @@ -8276,7 +8272,7 @@ Generate apps/client/src/app/components/user-account-access/user-account-access.html - 43 + 45 @@ -8324,7 +8320,7 @@ Gerenciar perfil de ativos apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 471 + 467 diff --git a/apps/client/src/locales/messages.tr.xlf b/apps/client/src/locales/messages.tr.xlf index 362932e44..334604df2 100644 --- a/apps/client/src/locales/messages.tr.xlf +++ b/apps/client/src/locales/messages.tr.xlf @@ -279,7 +279,7 @@ Nakit Bakiye apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 45 + 43 apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html @@ -295,7 +295,7 @@ Platform apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 90 + 88 apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html @@ -503,7 +503,7 @@ apps/client/src/app/components/admin-overview/admin-overview.html - 140 + 153 apps/client/src/app/components/admin-platform/admin-platform.component.html @@ -963,7 +963,7 @@ Bu kuponu gerçekten silmek istiyor musunuz? apps/client/src/app/components/admin-overview/admin-overview.component.ts - 196 + 214 @@ -971,7 +971,7 @@ Önbelleği temizlemeyi gerçekten istiyor musunuz? apps/client/src/app/components/admin-overview/admin-overview.component.ts - 233 + 251 @@ -979,31 +979,15 @@ Lütfen sistem mesajınızı belirleyin: apps/client/src/app/components/admin-overview/admin-overview.component.ts - 253 - - - - User Count - Kullanıcı Sayısı - - apps/client/src/app/components/admin-overview/admin-overview.html - 16 - - - - Activity Count - İşlem Sayısı - - apps/client/src/app/components/admin-overview/admin-overview.html - 22 + 271 per User Kullanıcı başına - apps/client/src/app/components/admin-overview/admin-overview.html - 31 + apps/client/src/app/components/admin-overview/admin-overview.component.ts + 159 @@ -1035,7 +1019,7 @@ Kullanıcı Kaydı apps/client/src/app/components/admin-overview/admin-overview.html - 37 + 50 @@ -1043,7 +1027,7 @@ Salt okunur mod apps/client/src/app/components/admin-overview/admin-overview.html - 51 + 64 @@ -1051,7 +1035,7 @@ Sistem Mesajı apps/client/src/app/components/admin-overview/admin-overview.html - 75 + 88 @@ -1059,7 +1043,7 @@ Mesaj Belirle apps/client/src/app/components/admin-overview/admin-overview.html - 97 + 110 @@ -1067,7 +1051,7 @@ Kupon apps/client/src/app/components/admin-overview/admin-overview.html - 105 + 118 @@ -1075,7 +1059,7 @@ Ekle apps/client/src/app/components/admin-overview/admin-overview.html - 185 + 198 libs/ui/src/lib/account-balances/account-balances.component.html @@ -1087,7 +1071,7 @@ Genel Ayarlar apps/client/src/app/components/admin-overview/admin-overview.html - 193 + 206 @@ -1095,7 +1079,7 @@ Önbelleği temizle apps/client/src/app/components/admin-overview/admin-overview.html - 209 + 222 @@ -1131,7 +1115,7 @@ Asset profile has been saved apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 594 + 618 @@ -1171,7 +1155,7 @@ Current year apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 205 + 209 @@ -1263,11 +1247,11 @@ Could not validate form apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 570 + 594 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 573 + 597 @@ -1347,7 +1331,7 @@ Giriş apps/client/src/app/components/header/header.component.html - 422 + 425 apps/client/src/app/components/header/header.component.ts @@ -1355,7 +1339,7 @@ apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 79 + 81 libs/common/src/lib/routes/routes.ts @@ -1519,7 +1503,7 @@ Current week apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 197 + 201 @@ -1587,7 +1571,7 @@ apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 32 + 34 apps/client/src/app/pages/landing/landing-page.html @@ -1627,7 +1611,7 @@ Google ile Oturum Aç apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 44 + 46 @@ -1635,7 +1619,7 @@ Oturumu açık tut apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 66 + 68 @@ -1663,7 +1647,7 @@ apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 259 + 257 @@ -1675,7 +1659,7 @@ apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 278 + 276 @@ -1799,7 +1783,7 @@ Rapor Veri Sorunu apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 456 + 452 @@ -1979,7 +1963,7 @@ libs/ui/src/lib/assistant/assistant.component.ts - 362 + 367 @@ -1987,11 +1971,11 @@ YTD apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 205 + 209 libs/ui/src/lib/assistant/assistant.component.ts - 374 + 379 @@ -1999,11 +1983,11 @@ 1Y apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 209 + 213 libs/ui/src/lib/assistant/assistant.component.ts - 384 + 389 @@ -2011,11 +1995,11 @@ 5Y apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 213 + 217 libs/ui/src/lib/assistant/assistant.component.ts - 408 + 413 @@ -2023,7 +2007,7 @@ Performance with currency effect apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 135 + 134 @@ -2031,11 +2015,11 @@ Maks. apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 217 + 221 libs/ui/src/lib/assistant/assistant.component.ts - 414 + 419 @@ -2103,7 +2087,7 @@ apps/client/src/app/components/header/header.component.html - 375 + 374 apps/client/src/app/pages/about/overview/about-overview-page.routes.ts @@ -2215,7 +2199,7 @@ apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 381 + 377 apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html @@ -2279,7 +2263,7 @@ Piyasa Verileri apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 403 + 399 libs/common/src/lib/routes/routes.ts @@ -2305,6 +2289,10 @@ Users Kullanıcılar + + apps/client/src/app/components/admin-overview/admin-overview.html + 24 + libs/common/src/lib/routes/routes.ts 61 @@ -2449,6 +2437,10 @@ apps/client/src/app/pages/blog/2025/11/black-weeks-2025/black-weeks-2025-page.html 147 + + apps/client/src/app/pages/blog/2026/04/ghostfolio-3/ghostfolio-3-page.html + 269 + apps/client/src/app/pages/blog/blog-page.html 5 @@ -2463,11 +2455,11 @@ Could not parse scraper configuration apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 525 + 545 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 528 + 548 @@ -2515,7 +2507,7 @@ apps/client/src/app/components/header/header.component.html - 361 + 360 apps/client/src/app/pages/features/features-page.html @@ -2647,7 +2639,7 @@ Başla apps/client/src/app/components/header/header.component.html - 433 + 436 apps/client/src/app/pages/features/features-page.html @@ -2679,7 +2671,7 @@ Varlıklar apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 102 + 100 apps/client/src/app/components/home-holdings/home-holdings.html @@ -2727,7 +2719,7 @@ apps/client/src/app/components/header/header.component.html - 408 + 407 apps/client/src/app/components/home-market/home-market.html @@ -3131,16 +3123,20 @@ İşlemler apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 86 + 84 apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 113 + 111 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html 226 + + apps/client/src/app/components/admin-overview/admin-overview.html + 38 + apps/client/src/app/components/admin-tag/admin-tag.component.html 45 @@ -3155,7 +3151,7 @@ apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 348 + 344 apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html @@ -3587,7 +3583,7 @@ Temettü apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 81 + 79 apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html @@ -3651,7 +3647,7 @@ Üst apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 305 + 303 @@ -3659,7 +3655,7 @@ Alt apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 354 + 352 @@ -3667,7 +3663,7 @@ Portföyün Gelişimi apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 407 + 405 @@ -3675,7 +3671,7 @@ Yatırım Zaman Çizelgesi apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 436 + 434 @@ -3683,7 +3679,7 @@ Güncel Seri apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 457 + 455 @@ -3691,7 +3687,7 @@ En Uzun Seri apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 466 + 464 @@ -3699,7 +3695,7 @@ Temettü Zaman Çizelgesi apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 495 + 493 @@ -3735,7 +3731,7 @@ apps/client/src/app/components/header/header.component.html - 389 + 388 apps/client/src/app/pages/pricing/pricing-page.routes.ts @@ -3923,11 +3919,11 @@ Could not save asset profile apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 604 + 628 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 607 + 631 @@ -4608,7 +4604,7 @@ xErişim İzni Verildi apps/client/src/app/components/user-account-access/user-account-access.html - 57 + 59 @@ -4768,7 +4764,7 @@ Faiz apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 69 + 67 apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html @@ -5084,7 +5080,7 @@ apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 318 + 316 libs/ui/src/lib/i18n.ts @@ -5136,7 +5132,7 @@ Sign in with OpenID Connect apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 55 + 57 @@ -5204,7 +5200,7 @@ Menkul Kıymet apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 57 + 55 libs/ui/src/lib/i18n.ts @@ -5540,7 +5536,7 @@ Versiyon apps/client/src/app/components/admin-overview/admin-overview.html - 7 + 11 @@ -5736,7 +5732,7 @@ Bu sistem mesajını silmeyi gerçekten istiyor musunuz? apps/client/src/app/components/admin-overview/admin-overview.component.ts - 209 + 227 @@ -5760,7 +5756,7 @@ Nakit Bakiyeleri apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 146 + 142 @@ -5796,7 +5792,7 @@ Şu anki piyasa fiyatı apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 722 + 743 @@ -5916,7 +5912,7 @@ Close Holding apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 447 + 443 @@ -5924,7 +5920,7 @@ Mutlak Varlık Performansı apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 168 + 166 @@ -5932,7 +5928,7 @@ Varlık Performansı apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 190 + 188 @@ -5940,7 +5936,7 @@ Mutlak Para Performansı apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 211 + 209 @@ -5948,7 +5944,7 @@ Para Performansı apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 236 + 234 @@ -5956,7 +5952,7 @@ Hafta içi libs/ui/src/lib/assistant/assistant.component.ts - 366 + 371 @@ -5964,11 +5960,11 @@ WTD apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 197 + 201 libs/ui/src/lib/assistant/assistant.component.ts - 366 + 371 @@ -5976,7 +5972,7 @@ Ay içi libs/ui/src/lib/assistant/assistant.component.ts - 370 + 375 @@ -5984,11 +5980,11 @@ MTD apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 201 + 205 libs/ui/src/lib/assistant/assistant.component.ts - 370 + 375 @@ -5996,7 +5992,7 @@ Yıl içi libs/ui/src/lib/assistant/assistant.component.ts - 374 + 379 @@ -6032,7 +6028,7 @@ Yıl apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 209 + 213 apps/client/src/app/pages/resources/personal-finance-tools/product-page.html @@ -6044,7 +6040,7 @@ libs/ui/src/lib/assistant/assistant.component.ts - 384 + 389 @@ -6052,11 +6048,11 @@ Yıllar apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 213 + 217 libs/ui/src/lib/assistant/assistant.component.ts - 408 + 413 @@ -6076,7 +6072,7 @@ apps/client/src/app/components/admin-overview/admin-overview.html - 63 + 76 @@ -6265,7 +6261,7 @@ Hesabı Kapat apps/client/src/app/components/user-account-settings/user-account-settings.html - 316 + 318 @@ -6701,7 +6697,7 @@ Hata apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 713 + 734 @@ -7399,7 +7395,7 @@ Alınan Erişim apps/client/src/app/components/user-account-access/user-account-access.html - 53 + 55 @@ -7423,7 +7419,7 @@ Change with currency effect apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 116 + 115 @@ -7447,7 +7443,7 @@ Tembel apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 231 + 235 @@ -7455,7 +7451,7 @@ Anında apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 235 + 239 @@ -7495,7 +7491,7 @@ gün sonu apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 231 + 235 @@ -7503,7 +7499,7 @@ gerçek zamanlı apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 235 + 239 @@ -7587,7 +7583,7 @@ Total amount apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 95 + 94 @@ -7679,7 +7675,7 @@ Find account, holding or page... libs/ui/src/lib/assistant/assistant.component.ts - 115 + 116 @@ -7744,7 +7740,7 @@ () is already in use. apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 649 + 675 @@ -7752,7 +7748,7 @@ Güncelleştirilirken bir hata oluştu (). apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 657 + 683 @@ -7816,7 +7812,7 @@ birisi apps/client/src/app/pages/public/public-page.component.ts - 61 + 64 @@ -7889,7 +7885,7 @@ Demo kullanıcı hesabı senkronize edildi. apps/client/src/app/components/admin-overview/admin-overview.component.ts - 277 + 295 @@ -7897,7 +7893,7 @@ Demo Kullanıcı Hesabını Senkronize Et apps/client/src/app/components/admin-overview/admin-overview.html - 204 + 217 @@ -8103,7 +8099,7 @@ Current month apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 201 + 205 @@ -8276,7 +8272,7 @@ Generate apps/client/src/app/components/user-account-access/user-account-access.html - 43 + 45 @@ -8324,7 +8320,7 @@ Manage Asset Profile apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 471 + 467 diff --git a/apps/client/src/locales/messages.uk.xlf b/apps/client/src/locales/messages.uk.xlf index 0ccbdb1ba..95beb122a 100644 --- a/apps/client/src/locales/messages.uk.xlf +++ b/apps/client/src/locales/messages.uk.xlf @@ -10,7 +10,7 @@ apps/client/src/app/components/header/header.component.html - 361 + 360 apps/client/src/app/pages/features/features-page.html @@ -34,7 +34,7 @@ Увійти apps/client/src/app/components/header/header.component.html - 422 + 425 apps/client/src/app/components/header/header.component.ts @@ -42,7 +42,7 @@ apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 79 + 81 libs/common/src/lib/routes/routes.ts @@ -387,7 +387,7 @@ Баланс готівки apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 45 + 43 apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html @@ -403,7 +403,7 @@ Платформа apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 90 + 88 apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html @@ -419,7 +419,7 @@ Баланс готівки apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 146 + 142 @@ -631,7 +631,7 @@ apps/client/src/app/components/admin-overview/admin-overview.html - 140 + 153 apps/client/src/app/components/admin-platform/admin-platform.component.html @@ -1003,7 +1003,7 @@ Помилка apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 713 + 734 @@ -1011,7 +1011,7 @@ Поточна ринкова ціна apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 722 + 743 @@ -1147,7 +1147,7 @@ Asset profile has been saved apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 594 + 618 @@ -1231,7 +1231,7 @@ Ви дійсно хочете видалити цей купон? apps/client/src/app/components/admin-overview/admin-overview.component.ts - 196 + 214 @@ -1239,7 +1239,7 @@ Ви дійсно хочете видалити це системне повідомлення? apps/client/src/app/components/admin-overview/admin-overview.component.ts - 209 + 227 @@ -1247,7 +1247,7 @@ Ви дійсно хочете очистити кеш? apps/client/src/app/components/admin-overview/admin-overview.component.ts - 233 + 251 @@ -1255,7 +1255,7 @@ Будь ласка, встановіть ваше системне повідомлення: apps/client/src/app/components/admin-overview/admin-overview.component.ts - 253 + 271 @@ -1263,31 +1263,15 @@ Версія apps/client/src/app/components/admin-overview/admin-overview.html - 7 - - - - User Count - Кількість користувачів - - apps/client/src/app/components/admin-overview/admin-overview.html - 16 - - - - Activity Count - Кількість активностей - - apps/client/src/app/components/admin-overview/admin-overview.html - 22 + 11 per User на користувача - apps/client/src/app/components/admin-overview/admin-overview.html - 31 + apps/client/src/app/components/admin-overview/admin-overview.component.ts + 159 @@ -1295,7 +1279,7 @@ Реєстрація користувача apps/client/src/app/components/admin-overview/admin-overview.html - 37 + 50 @@ -1303,7 +1287,7 @@ Режим лише для читання apps/client/src/app/components/admin-overview/admin-overview.html - 51 + 64 @@ -1315,7 +1299,7 @@ apps/client/src/app/components/admin-overview/admin-overview.html - 63 + 76 @@ -1323,7 +1307,7 @@ Системне повідомлення apps/client/src/app/components/admin-overview/admin-overview.html - 75 + 88 @@ -1331,7 +1315,7 @@ Встановити повідомлення apps/client/src/app/components/admin-overview/admin-overview.html - 97 + 110 @@ -1339,7 +1323,7 @@ Купони apps/client/src/app/components/admin-overview/admin-overview.html - 105 + 118 @@ -1347,7 +1331,7 @@ Додати apps/client/src/app/components/admin-overview/admin-overview.html - 185 + 198 libs/ui/src/lib/account-balances/account-balances.component.html @@ -1359,7 +1343,7 @@ Прибирання apps/client/src/app/components/admin-overview/admin-overview.html - 193 + 206 @@ -1367,7 +1351,7 @@ Очистити кеш apps/client/src/app/components/admin-overview/admin-overview.html - 209 + 222 @@ -1415,7 +1399,7 @@ Current year apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 205 + 209 @@ -1527,7 +1511,7 @@ apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 32 + 34 apps/client/src/app/pages/landing/landing-page.html @@ -1679,11 +1663,11 @@ Could not validate form apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 570 + 594 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 573 + 597 @@ -1919,7 +1903,7 @@ Повідомити про збій даних apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 456 + 452 @@ -2091,7 +2075,7 @@ Current week apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 197 + 201 @@ -2155,7 +2139,7 @@ Увійти з Google apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 44 + 46 @@ -2163,7 +2147,7 @@ Залишатися в системі apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 66 + 68 @@ -2215,7 +2199,7 @@ apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 259 + 257 @@ -2227,7 +2211,7 @@ apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 278 + 276 @@ -2567,7 +2551,7 @@ libs/ui/src/lib/assistant/assistant.component.ts - 362 + 367 @@ -2575,11 +2559,11 @@ З початку року apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 205 + 209 libs/ui/src/lib/assistant/assistant.component.ts - 374 + 379 @@ -2587,11 +2571,11 @@ 1 рік apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 209 + 213 libs/ui/src/lib/assistant/assistant.component.ts - 384 + 389 @@ -2599,11 +2583,11 @@ 5 років apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 213 + 217 libs/ui/src/lib/assistant/assistant.component.ts - 408 + 413 @@ -2611,7 +2595,7 @@ Performance with currency effect apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 135 + 134 @@ -2619,11 +2603,11 @@ Максимум apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 217 + 221 libs/ui/src/lib/assistant/assistant.component.ts - 414 + 419 @@ -2699,7 +2683,7 @@ Отриманий доступ apps/client/src/app/components/user-account-access/user-account-access.html - 53 + 55 @@ -2715,7 +2699,7 @@ Наданий доступ apps/client/src/app/components/user-account-access/user-account-access.html - 57 + 59 @@ -3043,7 +3027,7 @@ Закрити обліковий запис apps/client/src/app/components/user-account-settings/user-account-settings.html - 316 + 318 @@ -3111,7 +3095,7 @@ apps/client/src/app/components/header/header.component.html - 375 + 374 apps/client/src/app/pages/about/overview/about-overview-page.routes.ts @@ -3223,7 +3207,7 @@ apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 381 + 377 apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html @@ -3327,7 +3311,7 @@ Ринкові дані apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 403 + 399 libs/common/src/lib/routes/routes.ts @@ -3353,6 +3337,10 @@ Users Користувачі + + apps/client/src/app/components/admin-overview/admin-overview.html + 24 + libs/common/src/lib/routes/routes.ts 61 @@ -3505,6 +3493,10 @@ apps/client/src/app/pages/blog/2025/11/black-weeks-2025/black-weeks-2025-page.html 147 + + apps/client/src/app/pages/blog/2026/04/ghostfolio-3/ghostfolio-3-page.html + 269 + apps/client/src/app/pages/blog/blog-page.html 5 @@ -3519,11 +3511,11 @@ Could not parse scraper configuration apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 525 + 545 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 528 + 548 @@ -3716,7 +3708,7 @@ Почати apps/client/src/app/components/header/header.component.html - 433 + 436 apps/client/src/app/pages/features/features-page.html @@ -3748,7 +3740,7 @@ Активи apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 102 + 100 apps/client/src/app/components/home-holdings/home-holdings.html @@ -3796,7 +3788,7 @@ apps/client/src/app/components/header/header.component.html - 408 + 407 apps/client/src/app/components/home-market/home-market.html @@ -4292,16 +4284,20 @@ Активності apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 86 + 84 apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 113 + 111 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html 226 + + apps/client/src/app/components/admin-overview/admin-overview.html + 38 + apps/client/src/app/components/admin-tag/admin-tag.component.html 45 @@ -4316,7 +4312,7 @@ apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 348 + 344 apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html @@ -4808,7 +4804,7 @@ Дивіденди apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 81 + 79 apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html @@ -4888,7 +4884,7 @@ Close Holding apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 447 + 443 @@ -4896,7 +4892,7 @@ Абсолютна прибутковість активів apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 168 + 166 @@ -4904,7 +4900,7 @@ Прибутковість активів apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 190 + 188 @@ -4912,7 +4908,7 @@ Абсолютна прибутковість валюти apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 211 + 209 @@ -4920,7 +4916,7 @@ Прибутковість валюти apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 236 + 234 @@ -4928,7 +4924,7 @@ Топ apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 305 + 303 @@ -4936,7 +4932,7 @@ Низ apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 354 + 352 @@ -4944,7 +4940,7 @@ Еволюція портфеля apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 407 + 405 @@ -4952,7 +4948,7 @@ Інвестиційний графік apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 436 + 434 @@ -4960,7 +4956,7 @@ Поточна серія apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 457 + 455 @@ -4968,7 +4964,7 @@ Найдовша серія apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 466 + 464 @@ -4976,7 +4972,7 @@ Графік дивідендів apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 495 + 493 @@ -5052,7 +5048,7 @@ apps/client/src/app/components/header/header.component.html - 389 + 388 apps/client/src/app/pages/pricing/pricing-page.routes.ts @@ -5224,11 +5220,11 @@ Could not save asset profile apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 604 + 628 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 607 + 631 @@ -6183,7 +6179,7 @@ Тиждень до дати libs/ui/src/lib/assistant/assistant.component.ts - 366 + 371 @@ -6191,11 +6187,11 @@ WTD apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 197 + 201 libs/ui/src/lib/assistant/assistant.component.ts - 366 + 371 @@ -6203,7 +6199,7 @@ Місяць до дати libs/ui/src/lib/assistant/assistant.component.ts - 370 + 375 @@ -6211,11 +6207,11 @@ MTD apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 201 + 205 libs/ui/src/lib/assistant/assistant.component.ts - 370 + 375 @@ -6223,7 +6219,7 @@ Рік до дати libs/ui/src/lib/assistant/assistant.component.ts - 374 + 379 @@ -6231,7 +6227,7 @@ рік apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 209 + 213 apps/client/src/app/pages/resources/personal-finance-tools/product-page.html @@ -6243,7 +6239,7 @@ libs/ui/src/lib/assistant/assistant.component.ts - 384 + 389 @@ -6251,11 +6247,11 @@ роки apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 213 + 217 libs/ui/src/lib/assistant/assistant.component.ts - 408 + 413 @@ -6419,7 +6415,7 @@ Відсотки apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 69 + 67 apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html @@ -6863,7 +6859,7 @@ apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 318 + 316 libs/ui/src/lib/i18n.ts @@ -6931,7 +6927,7 @@ Sign in with OpenID Connect apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 55 + 57 @@ -7015,7 +7011,7 @@ Капітал apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 57 + 55 libs/ui/src/lib/i18n.ts @@ -7431,7 +7427,7 @@ Change with currency effect apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 116 + 115 @@ -7447,7 +7443,7 @@ Lazy apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 231 + 235 @@ -7455,7 +7451,7 @@ Instant apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 235 + 239 @@ -7495,7 +7491,7 @@ end of day apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 231 + 235 @@ -7503,7 +7499,7 @@ real-time apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 235 + 239 @@ -7587,7 +7583,7 @@ Total amount apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 95 + 94 @@ -7679,7 +7675,7 @@ Find account, holding or page... libs/ui/src/lib/assistant/assistant.component.ts - 115 + 116 @@ -7744,7 +7740,7 @@ () is already in use. apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 649 + 675 @@ -7752,7 +7748,7 @@ An error occurred while updating to (). apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 657 + 683 @@ -7816,7 +7812,7 @@ someone apps/client/src/app/pages/public/public-page.component.ts - 61 + 64 @@ -7889,7 +7885,7 @@ Demo user account has been synced. apps/client/src/app/components/admin-overview/admin-overview.component.ts - 277 + 295 @@ -7897,7 +7893,7 @@ Sync Demo User Account apps/client/src/app/components/admin-overview/admin-overview.html - 204 + 217 @@ -8103,7 +8099,7 @@ Current month apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 201 + 205 @@ -8276,7 +8272,7 @@ Generate apps/client/src/app/components/user-account-access/user-account-access.html - 43 + 45 @@ -8324,7 +8320,7 @@ Manage Asset Profile apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 471 + 467 diff --git a/apps/client/src/locales/messages.xlf b/apps/client/src/locales/messages.xlf index 17a0227a0..02eaf353b 100644 --- a/apps/client/src/locales/messages.xlf +++ b/apps/client/src/locales/messages.xlf @@ -286,7 +286,7 @@ Cash Balance apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 45 + 43 apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html @@ -301,7 +301,7 @@ Platform apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 90 + 88 apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html @@ -316,7 +316,7 @@ Cash Balances apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 146 + 142 @@ -521,7 +521,7 @@ apps/client/src/app/components/admin-overview/admin-overview.html - 140 + 153 apps/client/src/app/components/admin-platform/admin-platform.component.html @@ -1003,56 +1003,42 @@ Do you really want to delete this coupon? apps/client/src/app/components/admin-overview/admin-overview.component.ts - 196 + 214 Do you really want to delete this system message? apps/client/src/app/components/admin-overview/admin-overview.component.ts - 209 + 227 Do you really want to flush the cache? apps/client/src/app/components/admin-overview/admin-overview.component.ts - 233 + 251 Please set your system message: apps/client/src/app/components/admin-overview/admin-overview.component.ts - 253 + 271 Version apps/client/src/app/components/admin-overview/admin-overview.html - 7 - - - - User Count - - apps/client/src/app/components/admin-overview/admin-overview.html - 16 - - - - Activity Count - - apps/client/src/app/components/admin-overview/admin-overview.html - 22 + 11 per User - apps/client/src/app/components/admin-overview/admin-overview.html - 31 + apps/client/src/app/components/admin-overview/admin-overview.component.ts + 159 @@ -1066,42 +1052,42 @@ User Signup apps/client/src/app/components/admin-overview/admin-overview.html - 37 + 50 Read-only Mode apps/client/src/app/components/admin-overview/admin-overview.html - 51 + 64 System Message apps/client/src/app/components/admin-overview/admin-overview.html - 75 + 88 Set Message apps/client/src/app/components/admin-overview/admin-overview.html - 97 + 110 Coupons apps/client/src/app/components/admin-overview/admin-overview.html - 105 + 118 Add apps/client/src/app/components/admin-overview/admin-overview.html - 185 + 198 libs/ui/src/lib/account-balances/account-balances.component.html @@ -1112,14 +1098,14 @@ Housekeeping apps/client/src/app/components/admin-overview/admin-overview.html - 193 + 206 Flush Cache apps/client/src/app/components/admin-overview/admin-overview.html - 209 + 222 @@ -1152,7 +1138,7 @@ Asset profile has been saved apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 594 + 618 @@ -1187,7 +1173,7 @@ Current year apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 205 + 209 @@ -1312,11 +1298,11 @@ Could not validate form apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 570 + 594 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 573 + 597 @@ -1389,7 +1375,7 @@ Sign in apps/client/src/app/components/header/header.component.html - 422 + 425 apps/client/src/app/components/header/header.component.ts @@ -1397,7 +1383,7 @@ apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 79 + 81 libs/common/src/lib/routes/routes.ts @@ -1545,7 +1531,7 @@ Current week apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 197 + 201 @@ -1608,7 +1594,7 @@ apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 32 + 34 apps/client/src/app/pages/landing/landing-page.html @@ -1647,14 +1633,14 @@ Sign in with Google apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 44 + 46 Stay signed in apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 66 + 68 @@ -1690,7 +1676,7 @@ apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 259 + 257 @@ -1701,7 +1687,7 @@ apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 278 + 276 @@ -1801,7 +1787,7 @@ Report Data Glitch apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 456 + 452 @@ -1969,58 +1955,58 @@ libs/ui/src/lib/assistant/assistant.component.ts - 362 + 367 YTD apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 205 + 209 libs/ui/src/lib/assistant/assistant.component.ts - 374 + 379 1Y apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 209 + 213 libs/ui/src/lib/assistant/assistant.component.ts - 384 + 389 5Y apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 213 + 217 libs/ui/src/lib/assistant/assistant.component.ts - 408 + 413 Performance with currency effect apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 135 + 134 Max apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 217 + 221 libs/ui/src/lib/assistant/assistant.component.ts - 414 + 419 @@ -2041,7 +2027,7 @@ Granted Access apps/client/src/app/components/user-account-access/user-account-access.html - 57 + 59 @@ -2325,7 +2311,7 @@ apps/client/src/app/components/header/header.component.html - 375 + 374 apps/client/src/app/pages/about/overview/about-overview-page.routes.ts @@ -2429,7 +2415,7 @@ apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 381 + 377 apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html @@ -2516,7 +2502,7 @@ Market Data apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 403 + 399 libs/common/src/lib/routes/routes.ts @@ -2540,6 +2526,10 @@ Users + + apps/client/src/app/components/admin-overview/admin-overview.html + 24 + libs/common/src/lib/routes/routes.ts 61 @@ -2682,6 +2672,10 @@ apps/client/src/app/pages/blog/2025/11/black-weeks-2025/black-weeks-2025-page.html 147 + + apps/client/src/app/pages/blog/2026/04/ghostfolio-3/ghostfolio-3-page.html + 269 + apps/client/src/app/pages/blog/blog-page.html 5 @@ -2695,11 +2689,11 @@ Could not parse scraper configuration apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 525 + 545 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 528 + 548 @@ -2743,7 +2737,7 @@ apps/client/src/app/components/header/header.component.html - 361 + 360 apps/client/src/app/pages/features/features-page.html @@ -2849,7 +2843,7 @@ Get Started apps/client/src/app/components/header/header.component.html - 433 + 436 apps/client/src/app/pages/features/features-page.html @@ -2880,7 +2874,7 @@ Holdings apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 102 + 100 apps/client/src/app/components/home-holdings/home-holdings.html @@ -2926,7 +2920,7 @@ apps/client/src/app/components/header/header.component.html - 408 + 407 apps/client/src/app/components/home-market/home-market.html @@ -3359,16 +3353,20 @@ Activities apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 86 + 84 apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 113 + 111 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html 226 + + apps/client/src/app/components/admin-overview/admin-overview.html + 38 + apps/client/src/app/components/admin-tag/admin-tag.component.html 45 @@ -3383,7 +3381,7 @@ apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 348 + 344 apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html @@ -3785,7 +3783,7 @@ Dividend apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 81 + 79 apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html @@ -3844,49 +3842,49 @@ Top apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 305 + 303 Bottom apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 354 + 352 Portfolio Evolution apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 407 + 405 Investment Timeline apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 436 + 434 Current Streak apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 457 + 455 Longest Streak apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 466 + 464 Dividend Timeline apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 495 + 493 @@ -3919,7 +3917,7 @@ apps/client/src/app/components/header/header.component.html - 389 + 388 apps/client/src/app/pages/pricing/pricing-page.routes.ts @@ -4089,11 +4087,11 @@ Could not save asset profile apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 604 + 628 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 607 + 631 @@ -4691,7 +4689,7 @@ Interest apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 69 + 67 apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html @@ -4974,7 +4972,7 @@ apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 318 + 316 libs/ui/src/lib/i18n.ts @@ -5021,7 +5019,7 @@ Sign in with OpenID Connect apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 55 + 57 @@ -5097,7 +5095,7 @@ Equity apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 57 + 55 libs/ui/src/lib/i18n.ts @@ -5307,7 +5305,7 @@ The current market price is apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 722 + 743 @@ -5378,21 +5376,21 @@ Absolute Currency Performance apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 211 + 209 Close Holding apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 447 + 443 Absolute Asset Performance apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 168 + 166 @@ -5429,57 +5427,57 @@ Asset Performance apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 190 + 188 Currency Performance apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 236 + 234 Year to date libs/ui/src/lib/assistant/assistant.component.ts - 374 + 379 Week to date libs/ui/src/lib/assistant/assistant.component.ts - 366 + 371 Month to date libs/ui/src/lib/assistant/assistant.component.ts - 370 + 375 MTD apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 201 + 205 libs/ui/src/lib/assistant/assistant.component.ts - 370 + 375 WTD apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 197 + 201 libs/ui/src/lib/assistant/assistant.component.ts - 366 + 371 @@ -5511,7 +5509,7 @@ year apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 209 + 213 apps/client/src/app/pages/resources/personal-finance-tools/product-page.html @@ -5523,18 +5521,18 @@ libs/ui/src/lib/assistant/assistant.component.ts - 384 + 389 years apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 213 + 217 libs/ui/src/lib/assistant/assistant.component.ts - 408 + 413 @@ -5575,7 +5573,7 @@ apps/client/src/app/components/admin-overview/admin-overview.html - 63 + 76 @@ -5705,7 +5703,7 @@ Close Account apps/client/src/app/components/user-account-settings/user-account-settings.html - 316 + 318 @@ -6108,7 +6106,7 @@ Error apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 713 + 734 @@ -6732,7 +6730,7 @@ Received Access apps/client/src/app/components/user-account-access/user-account-access.html - 53 + 55 @@ -6764,7 +6762,7 @@ Change with currency effect apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 116 + 115 @@ -6806,14 +6804,14 @@ Instant apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 235 + 239 Lazy apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 231 + 235 @@ -6827,14 +6825,14 @@ real-time apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 235 + 239 end of day apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 231 + 235 @@ -6910,7 +6908,7 @@ Total amount apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 95 + 94 @@ -6980,7 +6978,7 @@ Find account, holding or page... libs/ui/src/lib/assistant/assistant.component.ts - 115 + 116 @@ -7049,14 +7047,14 @@ () is already in use. apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 649 + 675 An error occurred while updating to (). apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 657 + 683 @@ -7091,7 +7089,7 @@ someone apps/client/src/app/pages/public/public-page.component.ts - 61 + 64 @@ -7157,14 +7155,14 @@ Sync Demo User Account apps/client/src/app/components/admin-overview/admin-overview.html - 204 + 217 Demo user account has been synced. apps/client/src/app/components/admin-overview/admin-overview.component.ts - 277 + 295 @@ -7348,7 +7346,7 @@ Current month apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 201 + 205 @@ -7493,7 +7491,7 @@ Generate apps/client/src/app/components/user-account-access/user-account-access.html - 43 + 45 @@ -7543,7 +7541,7 @@ Manage Asset Profile apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 471 + 467 diff --git a/apps/client/src/locales/messages.zh.xlf b/apps/client/src/locales/messages.zh.xlf index 64fa63a13..95a73edd8 100644 --- a/apps/client/src/locales/messages.zh.xlf +++ b/apps/client/src/locales/messages.zh.xlf @@ -308,7 +308,7 @@ 现金余额 apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 45 + 43 apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html @@ -324,7 +324,7 @@ 平台 apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 90 + 88 apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html @@ -340,7 +340,7 @@ 现金余额 apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 146 + 142 @@ -552,7 +552,7 @@ apps/client/src/app/components/admin-overview/admin-overview.html - 140 + 153 apps/client/src/app/components/admin-platform/admin-platform.component.html @@ -1056,7 +1056,7 @@ 您确实要删除此优惠券吗? apps/client/src/app/components/admin-overview/admin-overview.component.ts - 196 + 214 @@ -1064,7 +1064,7 @@ 您真的要删除这条系统消息吗? apps/client/src/app/components/admin-overview/admin-overview.component.ts - 209 + 227 @@ -1072,7 +1072,7 @@ 您真的要刷新缓存吗? apps/client/src/app/components/admin-overview/admin-overview.component.ts - 233 + 251 @@ -1080,7 +1080,7 @@ 请设置您的系统消息: apps/client/src/app/components/admin-overview/admin-overview.component.ts - 253 + 271 @@ -1088,31 +1088,15 @@ 版本 apps/client/src/app/components/admin-overview/admin-overview.html - 7 - - - - User Count - 用户数 - - apps/client/src/app/components/admin-overview/admin-overview.html - 16 - - - - Activity Count - 活动计数 - - apps/client/src/app/components/admin-overview/admin-overview.html - 22 + 11 per User 每位用户 - apps/client/src/app/components/admin-overview/admin-overview.html - 31 + apps/client/src/app/components/admin-overview/admin-overview.component.ts + 159 @@ -1128,7 +1112,7 @@ 用户注册 apps/client/src/app/components/admin-overview/admin-overview.html - 37 + 50 @@ -1136,7 +1120,7 @@ 只读模式 apps/client/src/app/components/admin-overview/admin-overview.html - 51 + 64 @@ -1144,7 +1128,7 @@ 系统信息 apps/client/src/app/components/admin-overview/admin-overview.html - 75 + 88 @@ -1152,7 +1136,7 @@ 设置留言 apps/client/src/app/components/admin-overview/admin-overview.html - 97 + 110 @@ -1160,7 +1144,7 @@ 优惠券 apps/client/src/app/components/admin-overview/admin-overview.html - 105 + 118 @@ -1168,7 +1152,7 @@ 添加 apps/client/src/app/components/admin-overview/admin-overview.html - 185 + 198 libs/ui/src/lib/account-balances/account-balances.component.html @@ -1180,7 +1164,7 @@ 维护 apps/client/src/app/components/admin-overview/admin-overview.html - 193 + 206 @@ -1188,7 +1172,7 @@ 刷新缓存 apps/client/src/app/components/admin-overview/admin-overview.html - 209 + 222 @@ -1224,7 +1208,7 @@ 资产概况已保存 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 594 + 618 @@ -1264,7 +1248,7 @@ 当前年份 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 205 + 209 @@ -1404,11 +1388,11 @@ 无法验证表单 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 570 + 594 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 573 + 597 @@ -1488,7 +1472,7 @@ 登入 apps/client/src/app/components/header/header.component.html - 422 + 425 apps/client/src/app/components/header/header.component.ts @@ -1496,7 +1480,7 @@ apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 79 + 81 libs/common/src/lib/routes/routes.ts @@ -1660,7 +1644,7 @@ 当前周 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 197 + 201 @@ -1728,7 +1712,7 @@ apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 32 + 34 apps/client/src/app/pages/landing/landing-page.html @@ -1768,7 +1752,7 @@ 使用 Google 登录 apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 44 + 46 @@ -1776,7 +1760,7 @@ 保持登录 apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 66 + 68 @@ -1816,7 +1800,7 @@ apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 259 + 257 @@ -1828,7 +1812,7 @@ apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 278 + 276 @@ -1940,7 +1924,7 @@ 报告数据故障 apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 456 + 452 @@ -2120,7 +2104,7 @@ libs/ui/src/lib/assistant/assistant.component.ts - 362 + 367 @@ -2128,11 +2112,11 @@ 年初至今 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 205 + 209 libs/ui/src/lib/assistant/assistant.component.ts - 374 + 379 @@ -2140,11 +2124,11 @@ 1年 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 209 + 213 libs/ui/src/lib/assistant/assistant.component.ts - 384 + 389 @@ -2152,11 +2136,11 @@ 5年 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 213 + 217 libs/ui/src/lib/assistant/assistant.component.ts - 408 + 413 @@ -2164,7 +2148,7 @@ 含货币影响的表现 apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 135 + 134 @@ -2172,11 +2156,11 @@ 最大限度 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 217 + 221 libs/ui/src/lib/assistant/assistant.component.ts - 414 + 419 @@ -2200,7 +2184,7 @@ 授予访问权限 apps/client/src/app/components/user-account-access/user-account-access.html - 57 + 59 @@ -2516,7 +2500,7 @@ apps/client/src/app/components/header/header.component.html - 375 + 374 apps/client/src/app/pages/about/overview/about-overview-page.routes.ts @@ -2628,7 +2612,7 @@ apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 381 + 377 apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html @@ -2724,7 +2708,7 @@ 市场数据 apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 403 + 399 libs/common/src/lib/routes/routes.ts @@ -2750,6 +2734,10 @@ Users 用户 + + apps/client/src/app/components/admin-overview/admin-overview.html + 24 + libs/common/src/lib/routes/routes.ts 61 @@ -2894,6 +2882,10 @@ apps/client/src/app/pages/blog/2025/11/black-weeks-2025/black-weeks-2025-page.html 147 + + apps/client/src/app/pages/blog/2026/04/ghostfolio-3/ghostfolio-3-page.html + 269 + apps/client/src/app/pages/blog/blog-page.html 5 @@ -2908,11 +2900,11 @@ 无法解析抓取器配置 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 525 + 545 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 528 + 548 @@ -2960,7 +2952,7 @@ apps/client/src/app/components/header/header.component.html - 361 + 360 apps/client/src/app/pages/features/features-page.html @@ -3080,7 +3072,7 @@ 立即开始 apps/client/src/app/components/header/header.component.html - 433 + 436 apps/client/src/app/pages/features/features-page.html @@ -3112,7 +3104,7 @@ 持仓 apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 102 + 100 apps/client/src/app/components/home-holdings/home-holdings.html @@ -3160,7 +3152,7 @@ apps/client/src/app/components/header/header.component.html - 408 + 407 apps/client/src/app/components/home-market/home-market.html @@ -3648,16 +3640,20 @@ 活动 apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 86 + 84 apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 113 + 111 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html 226 + + apps/client/src/app/components/admin-overview/admin-overview.html + 38 + apps/client/src/app/components/admin-tag/admin-tag.component.html 45 @@ -3672,7 +3668,7 @@ apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 348 + 344 apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html @@ -4120,7 +4116,7 @@ 股息 apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 81 + 79 apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html @@ -4184,7 +4180,7 @@ 顶部 apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 305 + 303 @@ -4192,7 +4188,7 @@ 底部 apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 354 + 352 @@ -4200,7 +4196,7 @@ 投资组合演变 apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 407 + 405 @@ -4208,7 +4204,7 @@ 投资时间表 apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 436 + 434 @@ -4216,7 +4212,7 @@ 当前连胜 apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 457 + 455 @@ -4224,7 +4220,7 @@ 最长连续纪录 apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 466 + 464 @@ -4232,7 +4228,7 @@ 股息时间表 apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 495 + 493 @@ -4268,7 +4264,7 @@ apps/client/src/app/components/header/header.component.html - 389 + 388 apps/client/src/app/pages/pricing/pricing-page.routes.ts @@ -4456,11 +4452,11 @@ 无法保存资产概况 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 604 + 628 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 607 + 631 @@ -5125,7 +5121,7 @@ 利息 apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 69 + 67 apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html @@ -5433,7 +5429,7 @@ apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 318 + 316 libs/ui/src/lib/i18n.ts @@ -5485,7 +5481,7 @@ 使用 OpenID Connect 登录 apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html - 55 + 57 @@ -5569,7 +5565,7 @@ 股权 apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html - 57 + 55 libs/ui/src/lib/i18n.ts @@ -5805,7 +5801,7 @@ 当前市场价格为 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 722 + 743 @@ -5885,7 +5881,7 @@ 绝对货币表现 apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 211 + 209 @@ -5893,7 +5889,7 @@ 关闭持仓 apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 447 + 443 @@ -5901,7 +5897,7 @@ 绝对资产回报 apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 168 + 166 @@ -5941,7 +5937,7 @@ 资产回报 apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 190 + 188 @@ -5949,7 +5945,7 @@ 货币表现 apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 236 + 234 @@ -5957,7 +5953,7 @@ 今年迄今为止 libs/ui/src/lib/assistant/assistant.component.ts - 374 + 379 @@ -5965,7 +5961,7 @@ 本周至今 libs/ui/src/lib/assistant/assistant.component.ts - 366 + 371 @@ -5973,7 +5969,7 @@ 本月至今 libs/ui/src/lib/assistant/assistant.component.ts - 370 + 375 @@ -5981,11 +5977,11 @@ 本月至今 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 201 + 205 libs/ui/src/lib/assistant/assistant.component.ts - 370 + 375 @@ -5993,11 +5989,11 @@ 本周至今 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 197 + 201 libs/ui/src/lib/assistant/assistant.component.ts - 366 + 371 @@ -6033,7 +6029,7 @@ apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 209 + 213 apps/client/src/app/pages/resources/personal-finance-tools/product-page.html @@ -6045,7 +6041,7 @@ libs/ui/src/lib/assistant/assistant.component.ts - 384 + 389 @@ -6053,11 +6049,11 @@ apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 213 + 217 libs/ui/src/lib/assistant/assistant.component.ts - 408 + 413 @@ -6102,7 +6098,7 @@ apps/client/src/app/components/admin-overview/admin-overview.html - 63 + 76 @@ -6266,7 +6262,7 @@ 关闭账户 apps/client/src/app/components/user-account-settings/user-account-settings.html - 316 + 318 @@ -6702,7 +6698,7 @@ 错误 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 713 + 734 @@ -7400,7 +7396,7 @@ 已获得访问权限 apps/client/src/app/components/user-account-access/user-account-access.html - 53 + 55 @@ -7424,7 +7420,7 @@ 含货币影响的涨跌 apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 116 + 115 @@ -7448,7 +7444,7 @@ 延迟 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 231 + 235 @@ -7456,7 +7452,7 @@ 即时 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 235 + 239 @@ -7496,7 +7492,7 @@ 收盘 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 231 + 235 @@ -7504,7 +7500,7 @@ 实时 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 235 + 239 @@ -7588,7 +7584,7 @@ 总金额 apps/client/src/app/pages/portfolio/analysis/analysis-page.html - 95 + 94 @@ -7680,7 +7676,7 @@ 查找账户、持仓或页面... libs/ui/src/lib/assistant/assistant.component.ts - 115 + 116 @@ -7745,7 +7741,7 @@ () 已在使用中。 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 649 + 675 @@ -7753,7 +7749,7 @@ 在更新到 () 时发生错误。 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 657 + 683 @@ -7817,7 +7813,7 @@ 某人 apps/client/src/app/pages/public/public-page.component.ts - 61 + 64 @@ -7890,7 +7886,7 @@ 演示用户账户已同步。 apps/client/src/app/components/admin-overview/admin-overview.component.ts - 277 + 295 @@ -7898,7 +7894,7 @@ 同步演示用户账户 apps/client/src/app/components/admin-overview/admin-overview.html - 204 + 217 @@ -8104,7 +8100,7 @@ 当前月份 apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts - 201 + 205 @@ -8277,7 +8273,7 @@ 生成 apps/client/src/app/components/user-account-access/user-account-access.html - 43 + 45 @@ -8325,7 +8321,7 @@ 管理资产概况 apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html - 471 + 467 From 404ef252c7c1e38baa6c7c8ef085aead26772025 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Wed, 29 Apr 2026 20:17:39 +0200 Subject: [PATCH 021/132] Task/improve coupon management in admin control panel (#6794) * Improve coupon management --- .../access-table/access-table.component.html | 4 +- .../app/components/admin-jobs/admin-jobs.html | 4 +- .../admin-market-data/admin-market-data.html | 4 +- .../admin-overview.component.ts | 66 ++++-- .../admin-overview/admin-overview.html | 220 +++++++++++------- .../components/admin-users/admin-users.html | 4 +- 6 files changed, 192 insertions(+), 110 deletions(-) diff --git a/apps/client/src/app/components/access-table/access-table.component.html b/apps/client/src/app/components/access-table/access-table.component.html index 4283d7860..8ba906e0f 100644 --- a/apps/client/src/app/components/access-table/access-table.component.html +++ b/apps/client/src/app/components/access-table/access-table.component.html @@ -53,9 +53,9 @@ - + - +
    } - @if (hasPermissionForSubscription) { -
    -
    Coupons
    -
    - - @for (coupon of coupons; track coupon) { - - - - - - } -
    - - - {{ formatStringValue(coupon.duration) }} - - - - - -
    -
    -
    - - - {{ - formatStringValue('7 days') - }} - {{ - formatStringValue('14 days') - }} - {{ - formatStringValue('30 days') - }} - {{ - formatStringValue('90 days') - }} - {{ - formatStringValue('180 days') - }} - {{ - formatStringValue('1 year') - }} - - - -
    -
    -
    -
    - }
    Housekeeping
    @@ -228,4 +139,135 @@
    + + @if (hasPermissionForSubscription) { +
    +
    + + + Coupons + + +
    + + + + + + + + + + + + + + + + + + +
    + Code + + + + Duration + + {{ formatStringValue(element.duration) }} + + + + + +
    +
    +
    + +
    + + + {{ + formatStringValue('7 days') + }} + {{ + formatStringValue('14 days') + }} + {{ + formatStringValue('30 days') + }} + {{ + formatStringValue('90 days') + }} + {{ + formatStringValue('180 days') + }} + {{ + formatStringValue('1 year') + }} + + + +
    +
    +
    +
    +
    + }
    diff --git a/apps/client/src/app/components/admin-users/admin-users.html b/apps/client/src/app/components/admin-users/admin-users.html index 0f9789feb..529703d9e 100644 --- a/apps/client/src/app/components/admin-users/admin-users.html +++ b/apps/client/src/app/components/admin-users/admin-users.html @@ -198,12 +198,12 @@
    + @if (isLoading()) {
    - + @if (isLoading) { Date: Wed, 29 Apr 2026 20:26:39 +0200 Subject: [PATCH 024/132] Task/improve language localization for de (20260429) (#6803) * Update translations * Update changelog --- CHANGELOG.md | 1 + apps/client/src/locales/messages.de.xlf | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48e598492..2dcd3ab9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Modernized the layout of the overview tab in the admin control panel - Improved the styling of the paginator across various table components +- Improved the language localization for German (`de`) ### Fixed diff --git a/apps/client/src/locales/messages.de.xlf b/apps/client/src/locales/messages.de.xlf index 39bb937d6..38c7db874 100644 --- a/apps/client/src/locales/messages.de.xlf +++ b/apps/client/src/locales/messages.de.xlf @@ -1627,7 +1627,7 @@ Duration - Duration + Dauer apps/client/src/app/components/admin-overview/admin-overview.html 172 @@ -2775,7 +2775,7 @@ Code - Code + Code apps/client/src/app/components/admin-overview/admin-overview.html 159 @@ -6982,7 +6982,7 @@ has been copied to the clipboard - wurde in die Zwischenablage kopiert + wurde in die Zwischenablage kopiert apps/client/src/app/components/admin-overview/admin-overview.component.ts 377 From 2883481d272f37bc2df0f0ba2bf217ba8d76ae08 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Wed, 29 Apr 2026 20:31:10 +0200 Subject: [PATCH 025/132] Release 3.1.0 (#6804) --- CHANGELOG.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dcd3ab9d..4b5b4c3c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## Unreleased +## 3.1.0 - 2026-04-29 ### Added diff --git a/package-lock.json b/package-lock.json index f58dc8e7a..29b1e137e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ghostfolio", - "version": "3.0.1", + "version": "3.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ghostfolio", - "version": "3.0.1", + "version": "3.1.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/package.json b/package.json index 9b440f394..0148468b8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostfolio", - "version": "3.0.1", + "version": "3.1.0", "homepage": "https://ghostfol.io", "license": "AGPL-3.0", "repository": "https://github.com/ghostfolio/ghostfolio", From 175015ae5403752305486bc650955e1a77f3476c Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 30 Apr 2026 17:40:37 +0200 Subject: [PATCH 026/132] Task/upgrade stripe to version 21.0.1 (#6795) * Upgrade stripe to version 21.0.1 * Update changelog --- CHANGELOG.md | 6 ++++++ .../api/src/app/subscription/subscription.service.ts | 2 +- package-lock.json | 12 ++++++------ package.json | 2 +- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b5b4c3c4..536453dbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Changed + +- Upgraded `stripe` from version `20.4.1` to `21.0.1` + ## 3.1.0 - 2026-04-29 ### Added diff --git a/apps/api/src/app/subscription/subscription.service.ts b/apps/api/src/app/subscription/subscription.service.ts index 877ea0ee4..07795d0d1 100644 --- a/apps/api/src/app/subscription/subscription.service.ts +++ b/apps/api/src/app/subscription/subscription.service.ts @@ -35,7 +35,7 @@ export class SubscriptionService { this.stripe = new Stripe( this.configurationService.get('STRIPE_SECRET_KEY'), { - apiVersion: '2026-02-25.clover' + apiVersion: '2026-03-25.dahlia' } ); } diff --git a/package-lock.json b/package-lock.json index 29b1e137e..3e35d09fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -90,7 +90,7 @@ "passport-openidconnect": "0.1.2", "reflect-metadata": "0.2.2", "rxjs": "7.8.1", - "stripe": "20.4.1", + "stripe": "21.0.1", "svgmap": "2.19.3", "tablemark": "4.1.0", "twitter-api-v2": "1.29.0", @@ -36731,15 +36731,15 @@ } }, "node_modules/stripe": { - "version": "20.4.1", - "resolved": "https://registry.npmjs.org/stripe/-/stripe-20.4.1.tgz", - "integrity": "sha512-axCguHItc8Sxt0HC6aSkdVRPffjYPV7EQqZRb2GkIa8FzWDycE7nHJM19C6xAIynH1Qp1/BHiopSi96jGBxT0w==", + "version": "21.0.1", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-21.0.1.tgz", + "integrity": "sha512-ocv0j7dWttswDWV2XL/kb6+yiLpDXNXL3RQAOB5OB2kr49z0cEatdQc12+zP/j5nrXk6rAsT4N3y/NUvBbK7Pw==", "license": "MIT", "engines": { - "node": ">=16" + "node": ">=18" }, "peerDependencies": { - "@types/node": ">=16" + "@types/node": ">=18" }, "peerDependenciesMeta": { "@types/node": { diff --git a/package.json b/package.json index 0148468b8..99b774bfb 100644 --- a/package.json +++ b/package.json @@ -135,7 +135,7 @@ "passport-openidconnect": "0.1.2", "reflect-metadata": "0.2.2", "rxjs": "7.8.1", - "stripe": "20.4.1", + "stripe": "21.0.1", "svgmap": "2.19.3", "tablemark": "4.1.0", "twitter-api-v2": "1.29.0", From 1daa672f27e2824cabeb33824e48f5adc7b72348 Mon Sep 17 00:00:00 2001 From: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com> Date: Fri, 1 May 2026 14:33:07 +0700 Subject: [PATCH 027/132] Task/improve type safety in pricing page component (#6806) * fix(client): resolve type errors * feat(client): enforce immutability * fix(client): remove unused variables * fix(client): enforce encapsulation * feat(client): replace constructor based DI with inject --- .../pages/pricing/pricing-page.component.ts | 63 +++++++++---------- libs/ui/src/lib/i18n.ts | 1 - 2 files changed, 30 insertions(+), 34 deletions(-) diff --git a/apps/client/src/app/pages/pricing/pricing-page.component.ts b/apps/client/src/app/pages/pricing/pricing-page.component.ts index 08c561fc8..0b76fcd1d 100644 --- a/apps/client/src/app/pages/pricing/pricing-page.component.ts +++ b/apps/client/src/app/pages/pricing/pricing-page.component.ts @@ -13,6 +13,7 @@ import { Component, CUSTOM_ELEMENTS_SCHEMA, DestroyRef, + inject, OnInit } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -48,35 +49,28 @@ import { catchError } from 'rxjs/operators'; templateUrl: './pricing-page.html' }) export class GfPricingPageComponent implements OnInit { - public baseCurrency: string; - public coupon: number; - public couponId: string; - public durationExtension: StringValue; - public hasPermissionToCreateUser: boolean; - public hasPermissionToUpdateUserSettings: boolean; - - public importAndExportTooltipBasic = translate( + protected baseCurrency: string; + protected coupon: number | undefined; + protected durationExtension: StringValue | undefined; + protected hasPermissionToCreateUser: boolean; + protected hasPermissionToUpdateUserSettings: boolean; + + protected readonly importAndExportTooltipBasic = translate( 'DATA_IMPORT_AND_EXPORT_TOOLTIP_BASIC' ); - public importAndExportTooltipOSS = translate( + protected readonly importAndExportTooltipOSS = translate( 'DATA_IMPORT_AND_EXPORT_TOOLTIP_OSS' ); - public importAndExportTooltipPremium = translate( - 'DATA_IMPORT_AND_EXPORT_TOOLTIP_PREMIUM' - ); - - public isLoggedIn: boolean; - public label: string; - public price: number; - public priceId: string; + protected label: string | undefined; + protected price: number | undefined; - public professionalDataProviderTooltipPremium = translate( + protected readonly professionalDataProviderTooltipPremium = translate( 'PROFESSIONAL_DATA_PROVIDER_TOOLTIP_PREMIUM' ); - public referralBrokers = [ + protected readonly referralBrokers = [ 'Alpian', 'DEGIRO', 'finpension', @@ -87,19 +81,22 @@ export class GfPricingPageComponent implements OnInit { 'Swissquote', 'VIAC', 'Zak' - ]; - - public routerLinkFeatures = publicRoutes.features.routerLink; - public routerLinkRegister = publicRoutes.register.routerLink; - public user: User; - - public constructor( - private changeDetectorRef: ChangeDetectorRef, - private dataService: DataService, - private destroyRef: DestroyRef, - private notificationService: NotificationService, - private userService: UserService - ) { + ] as const; + + protected readonly routerLinkFeatures = publicRoutes.features.routerLink; + protected readonly routerLinkRegister = publicRoutes.register.routerLink; + protected user: User; + + private couponId: string | undefined; + private priceId: string; + + private readonly changeDetectorRef = inject(ChangeDetectorRef); + private readonly dataService = inject(DataService); + private readonly destroyRef = inject(DestroyRef); + private readonly notificationService = inject(NotificationService); + private readonly userService = inject(UserService); + + public constructor() { addIcons({ checkmarkCircleOutline, checkmarkOutline, @@ -147,7 +144,7 @@ export class GfPricingPageComponent implements OnInit { }); } - public onCheckout() { + protected onCheckout() { this.dataService .createStripeCheckoutSession({ couponId: this.couponId, diff --git a/libs/ui/src/lib/i18n.ts b/libs/ui/src/lib/i18n.ts index e23b37590..c7d8b7c8b 100644 --- a/libs/ui/src/lib/i18n.ts +++ b/libs/ui/src/lib/i18n.ts @@ -11,7 +11,6 @@ const locales = { CLOSE: $localize`Close`, DATA_IMPORT_AND_EXPORT_TOOLTIP_BASIC: $localize`Switch to Ghostfolio Premium or Ghostfolio Open Source easily`, DATA_IMPORT_AND_EXPORT_TOOLTIP_OSS: $localize`Switch to Ghostfolio Premium easily`, - DATA_IMPORT_AND_EXPORT_TOOLTIP_PREMIUM: $localize`Switch to Ghostfolio Open Source or Ghostfolio Basic easily`, DATA_SOURCE: $localize`Data Source`, EMERGENCY_FUND: $localize`Emergency Fund`, EXCLUDE_FROM_ANALYSIS: $localize`Exclude from Analysis`, From 1f78ba5bbb94093dc325a5a4808f50d4847d9742 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 1 May 2026 10:08:03 +0200 Subject: [PATCH 028/132] Task/rename deviceService to deviceDetectorService for consistency (#6810) * Rename deviceService to deviceDetectorService for consistency --- apps/client/src/app/app.component.ts | 4 ++-- .../admin-market-data/admin-market-data.component.ts | 4 ++-- .../app/components/admin-platform/admin-platform.component.ts | 4 ++-- .../src/app/components/admin-tag/admin-tag.component.ts | 4 ++-- .../src/app/components/admin-users/admin-users.component.ts | 4 ++-- .../app/components/home-holdings/home-holdings.component.ts | 4 ++-- .../src/app/components/home-market/home-market.component.ts | 4 ++-- .../app/components/home-overview/home-overview.component.ts | 4 ++-- .../src/app/components/home-summary/home-summary.component.ts | 4 ++-- apps/client/src/app/components/markets/markets.component.ts | 4 ++-- apps/client/src/app/components/rule/rule.component.ts | 4 ++-- .../user-account-access/user-account-access.component.ts | 4 ++-- apps/client/src/app/core/layout.service.ts | 4 ++-- apps/client/src/app/pages/about/about-page.component.ts | 4 ++-- apps/client/src/app/pages/accounts/accounts-page.component.ts | 4 ++-- apps/client/src/app/pages/admin/admin-page.component.ts | 4 ++-- apps/client/src/app/pages/faq/faq-page.component.ts | 4 ++-- apps/client/src/app/pages/home/home-page.component.ts | 4 ++-- apps/client/src/app/pages/landing/landing-page.component.ts | 4 ++-- .../pages/portfolio/activities/activities-page.component.ts | 4 ++-- .../import-activities-dialog.component.ts | 4 ++-- .../pages/portfolio/allocations/allocations-page.component.ts | 4 ++-- .../app/pages/portfolio/analysis/analysis-page.component.ts | 4 ++-- .../src/app/pages/portfolio/portfolio-page.component.ts | 4 ++-- apps/client/src/app/pages/register/register-page.component.ts | 4 ++-- .../src/app/pages/resources/resources-page.component.ts | 4 ++-- .../src/app/pages/user-account/user-account-page.component.ts | 4 ++-- apps/client/src/app/pages/zen/zen-page.component.ts | 4 ++-- apps/client/src/app/services/user/user.service.ts | 4 ++-- 29 files changed, 58 insertions(+), 58 deletions(-) diff --git a/apps/client/src/app/app.component.ts b/apps/client/src/app/app.component.ts index dbe616dac..0a9be3a75 100644 --- a/apps/client/src/app/app.component.ts +++ b/apps/client/src/app/app.component.ts @@ -67,7 +67,7 @@ export class GfAppComponent implements OnInit { private readonly changeDetectorRef = inject(ChangeDetectorRef); private readonly dataService = inject(DataService); private readonly destroyRef = inject(DestroyRef); - private readonly deviceService = inject(DeviceDetectorService); + private readonly deviceDetectorService = inject(DeviceDetectorService); private readonly dialog = inject(MatDialog); private readonly document = inject(DOCUMENT); private readonly impersonationStorageService = inject( @@ -104,7 +104,7 @@ export class GfAppComponent implements OnInit { } public ngOnInit() { - this.deviceType = this.deviceService.getDeviceInfo().deviceType; + this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; this.info = this.dataService.fetchInfo(); this.impersonationStorageService diff --git a/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts b/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts index fd96c94fc..0abe9ea0f 100644 --- a/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts +++ b/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts @@ -171,7 +171,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { private changeDetectorRef: ChangeDetectorRef, private dataService: DataService, private destroyRef: DestroyRef, - private deviceService: DeviceDetectorService, + private deviceDetectorService: DeviceDetectorService, private dialog: MatDialog, private route: ActivatedRoute, private router: Router, @@ -272,7 +272,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { const { benchmarks } = this.dataService.fetchInfo(); this.benchmarks = benchmarks; - this.deviceType = this.deviceService.getDeviceInfo().deviceType; + this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; this.selection = new SelectionModel(true); } diff --git a/apps/client/src/app/components/admin-platform/admin-platform.component.ts b/apps/client/src/app/components/admin-platform/admin-platform.component.ts index b8f2af789..1c117b983 100644 --- a/apps/client/src/app/components/admin-platform/admin-platform.component.ts +++ b/apps/client/src/app/components/admin-platform/admin-platform.component.ts @@ -68,7 +68,7 @@ export class GfAdminPlatformComponent implements OnInit { private changeDetectorRef: ChangeDetectorRef, private dataService: DataService, private destroyRef: DestroyRef, - private deviceService: DeviceDetectorService, + private deviceDetectorService: DeviceDetectorService, private dialog: MatDialog, private notificationService: NotificationService, private route: ActivatedRoute, @@ -97,7 +97,7 @@ export class GfAdminPlatformComponent implements OnInit { } public ngOnInit() { - this.deviceType = this.deviceService.getDeviceInfo().deviceType; + this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; this.fetchPlatforms(); } diff --git a/apps/client/src/app/components/admin-tag/admin-tag.component.ts b/apps/client/src/app/components/admin-tag/admin-tag.component.ts index 506736156..56544f784 100644 --- a/apps/client/src/app/components/admin-tag/admin-tag.component.ts +++ b/apps/client/src/app/components/admin-tag/admin-tag.component.ts @@ -65,7 +65,7 @@ export class GfAdminTagComponent implements OnInit { private changeDetectorRef: ChangeDetectorRef, private dataService: DataService, private destroyRef: DestroyRef, - private deviceService: DeviceDetectorService, + private deviceDetectorService: DeviceDetectorService, private dialog: MatDialog, private notificationService: NotificationService, private route: ActivatedRoute, @@ -94,7 +94,7 @@ export class GfAdminTagComponent implements OnInit { } public ngOnInit() { - this.deviceType = this.deviceService.getDeviceInfo().deviceType; + this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; this.fetchTags(); } diff --git a/apps/client/src/app/components/admin-users/admin-users.component.ts b/apps/client/src/app/components/admin-users/admin-users.component.ts index 874bbc1db..fa9d58956 100644 --- a/apps/client/src/app/components/admin-users/admin-users.component.ts +++ b/apps/client/src/app/components/admin-users/admin-users.component.ts @@ -98,7 +98,7 @@ export class GfAdminUsersComponent implements OnInit { private changeDetectorRef: ChangeDetectorRef, private dataService: DataService, private destroyRef: DestroyRef, - private deviceService: DeviceDetectorService, + private deviceDetectorService: DeviceDetectorService, private dialog: MatDialog, private impersonationStorageService: ImpersonationStorageService, private notificationService: NotificationService, @@ -106,7 +106,7 @@ export class GfAdminUsersComponent implements OnInit { private router: Router, private userService: UserService ) { - this.deviceType = this.deviceService.getDeviceInfo().deviceType; + this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; this.info = this.dataService.fetchInfo(); this.hasPermissionForSubscription = hasPermission( diff --git a/apps/client/src/app/components/home-holdings/home-holdings.component.ts b/apps/client/src/app/components/home-holdings/home-holdings.component.ts index 19a48ccd8..61a8935eb 100644 --- a/apps/client/src/app/components/home-holdings/home-holdings.component.ts +++ b/apps/client/src/app/components/home-holdings/home-holdings.component.ts @@ -74,7 +74,7 @@ export class GfHomeHoldingsComponent implements OnInit { private changeDetectorRef: ChangeDetectorRef, private dataService: DataService, private destroyRef: DestroyRef, - private deviceService: DeviceDetectorService, + private deviceDetectorService: DeviceDetectorService, private impersonationStorageService: ImpersonationStorageService, private router: Router, private userService: UserService @@ -83,7 +83,7 @@ export class GfHomeHoldingsComponent implements OnInit { } public ngOnInit() { - this.deviceType = this.deviceService.getDeviceInfo().deviceType; + this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; this.impersonationStorageService .onChangeHasImpersonation() diff --git a/apps/client/src/app/components/home-market/home-market.component.ts b/apps/client/src/app/components/home-market/home-market.component.ts index 2ddb89408..cb645f2ef 100644 --- a/apps/client/src/app/components/home-market/home-market.component.ts +++ b/apps/client/src/app/components/home-market/home-market.component.ts @@ -50,10 +50,10 @@ export class GfHomeMarketComponent implements OnInit { private changeDetectorRef: ChangeDetectorRef, private dataService: DataService, private destroyRef: DestroyRef, - private deviceService: DeviceDetectorService, + private deviceDetectorService: DeviceDetectorService, private userService: UserService ) { - this.deviceType = this.deviceService.getDeviceInfo().deviceType; + this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; this.info = this.dataService.fetchInfo(); this.userService.stateChanged 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 58284d27d..e134ad6b1 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 @@ -65,7 +65,7 @@ export class GfHomeOverviewComponent implements OnInit { private changeDetectorRef: ChangeDetectorRef, private dataService: DataService, private destroyRef: DestroyRef, - private deviceService: DeviceDetectorService, + private deviceDetectorService: DeviceDetectorService, private impersonationStorageService: ImpersonationStorageService, private layoutService: LayoutService, private userService: UserService @@ -87,7 +87,7 @@ export class GfHomeOverviewComponent implements OnInit { } public ngOnInit() { - this.deviceType = this.deviceService.getDeviceInfo().deviceType; + this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; this.showDetails = !this.user.settings.isRestrictedView && diff --git a/apps/client/src/app/components/home-summary/home-summary.component.ts b/apps/client/src/app/components/home-summary/home-summary.component.ts index 719cfbd29..60960480d 100644 --- a/apps/client/src/app/components/home-summary/home-summary.component.ts +++ b/apps/client/src/app/components/home-summary/home-summary.component.ts @@ -43,7 +43,7 @@ export class GfHomeSummaryComponent implements OnInit { private changeDetectorRef: ChangeDetectorRef, private dataService: DataService, private destroyRef: DestroyRef, - private deviceService: DeviceDetectorService, + private deviceDetectorService: DeviceDetectorService, private impersonationStorageService: ImpersonationStorageService, private userService: UserService ) { @@ -71,7 +71,7 @@ export class GfHomeSummaryComponent implements OnInit { } public ngOnInit() { - this.deviceType = this.deviceService.getDeviceInfo().deviceType; + this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; this.impersonationStorageService .onChangeHasImpersonation() diff --git a/apps/client/src/app/components/markets/markets.component.ts b/apps/client/src/app/components/markets/markets.component.ts index 4214ee989..d2f64f3fc 100644 --- a/apps/client/src/app/components/markets/markets.component.ts +++ b/apps/client/src/app/components/markets/markets.component.ts @@ -58,10 +58,10 @@ export class GfMarketsComponent implements OnInit { private changeDetectorRef: ChangeDetectorRef, private dataService: DataService, private destroyRef: DestroyRef, - private deviceService: DeviceDetectorService, + private deviceDetectorService: DeviceDetectorService, private userService: UserService ) { - this.deviceType = this.deviceService.getDeviceInfo().deviceType; + this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; this.userService.stateChanged .pipe(takeUntilDestroyed(this.destroyRef)) diff --git a/apps/client/src/app/components/rule/rule.component.ts b/apps/client/src/app/components/rule/rule.component.ts index 23f870ff3..6dcbaf1ff 100644 --- a/apps/client/src/app/components/rule/rule.component.ts +++ b/apps/client/src/app/components/rule/rule.component.ts @@ -61,7 +61,7 @@ export class GfRuleComponent implements OnInit { private deviceType: string; public constructor( private destroyRef: DestroyRef, - private deviceService: DeviceDetectorService, + private deviceDetectorService: DeviceDetectorService, private dialog: MatDialog ) { addIcons({ @@ -75,7 +75,7 @@ export class GfRuleComponent implements OnInit { } public ngOnInit() { - this.deviceType = this.deviceService.getDeviceInfo().deviceType; + this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; } public onCustomizeRule(rule: PortfolioReportRule) { diff --git a/apps/client/src/app/components/user-account-access/user-account-access.component.ts b/apps/client/src/app/components/user-account-access/user-account-access.component.ts index f8620c745..e6686e80f 100644 --- a/apps/client/src/app/components/user-account-access/user-account-access.component.ts +++ b/apps/client/src/app/components/user-account-access/user-account-access.component.ts @@ -69,7 +69,7 @@ export class GfUserAccountAccessComponent implements OnInit { private changeDetectorRef: ChangeDetectorRef, private dataService: DataService, private destroyRef: DestroyRef, - private deviceService: DeviceDetectorService, + private deviceDetectorService: DeviceDetectorService, private dialog: MatDialog, private formBuilder: FormBuilder, private notificationService: NotificationService, @@ -123,7 +123,7 @@ export class GfUserAccountAccessComponent implements OnInit { } public ngOnInit() { - this.deviceType = this.deviceService.getDeviceInfo().deviceType; + this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; this.update(); } diff --git a/apps/client/src/app/core/layout.service.ts b/apps/client/src/app/core/layout.service.ts index fd435acdb..2624c4dd6 100644 --- a/apps/client/src/app/core/layout.service.ts +++ b/apps/client/src/app/core/layout.service.ts @@ -16,12 +16,12 @@ export class LayoutService { private shouldReloadSubject = new Subject(); public constructor( - private deviceService: DeviceDetectorService, + private deviceDetectorService: DeviceDetectorService, private notificationService: NotificationService ) { this.shouldReloadContent$ = this.shouldReloadSubject.asObservable(); - const deviceType = this.deviceService.getDeviceInfo().deviceType; + const deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; this.notificationService.setDialogWidth( deviceType === 'mobile' diff --git a/apps/client/src/app/pages/about/about-page.component.ts b/apps/client/src/app/pages/about/about-page.component.ts index 1e749d1cd..b23377f3c 100644 --- a/apps/client/src/app/pages/about/about-page.component.ts +++ b/apps/client/src/app/pages/about/about-page.component.ts @@ -44,7 +44,7 @@ export class AboutPageComponent implements OnInit { private changeDetectorRef: ChangeDetectorRef, private dataService: DataService, private destroyRef: DestroyRef, - private deviceService: DeviceDetectorService, + private deviceDetectorService: DeviceDetectorService, private userService: UserService ) { const { globalPermissions } = this.dataService.fetchInfo(); @@ -114,6 +114,6 @@ export class AboutPageComponent implements OnInit { } public ngOnInit() { - this.deviceType = this.deviceService.getDeviceInfo().deviceType; + this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; } } diff --git a/apps/client/src/app/pages/accounts/accounts-page.component.ts b/apps/client/src/app/pages/accounts/accounts-page.component.ts index fdc78a8c4..08513ef3e 100644 --- a/apps/client/src/app/pages/accounts/accounts-page.component.ts +++ b/apps/client/src/app/pages/accounts/accounts-page.component.ts @@ -58,7 +58,7 @@ export class GfAccountsPageComponent implements OnInit { private changeDetectorRef: ChangeDetectorRef, private dataService: DataService, private destroyRef: DestroyRef, - private deviceService: DeviceDetectorService, + private deviceDetectorService: DeviceDetectorService, private dialog: MatDialog, private impersonationStorageService: ImpersonationStorageService, private notificationService: NotificationService, @@ -95,7 +95,7 @@ export class GfAccountsPageComponent implements OnInit { } public ngOnInit() { - this.deviceType = this.deviceService.getDeviceInfo().deviceType; + this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; this.impersonationStorageService .onChangeHasImpersonation() diff --git a/apps/client/src/app/pages/admin/admin-page.component.ts b/apps/client/src/app/pages/admin/admin-page.component.ts index 66312ef33..8ec094915 100644 --- a/apps/client/src/app/pages/admin/admin-page.component.ts +++ b/apps/client/src/app/pages/admin/admin-page.component.ts @@ -26,7 +26,7 @@ export class AdminPageComponent implements OnInit { public deviceType: string; public tabs: TabConfiguration[] = []; - public constructor(private deviceService: DeviceDetectorService) { + public constructor(private deviceDetectorService: DeviceDetectorService) { addIcons({ flashOutline, peopleOutline, @@ -37,7 +37,7 @@ export class AdminPageComponent implements OnInit { } public ngOnInit() { - this.deviceType = this.deviceService.getDeviceInfo().deviceType; + this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; this.tabs = [ { diff --git a/apps/client/src/app/pages/faq/faq-page.component.ts b/apps/client/src/app/pages/faq/faq-page.component.ts index 83171254a..8ae074bee 100644 --- a/apps/client/src/app/pages/faq/faq-page.component.ts +++ b/apps/client/src/app/pages/faq/faq-page.component.ts @@ -26,7 +26,7 @@ export class GfFaqPageComponent implements OnInit { public constructor( private dataService: DataService, - private deviceService: DeviceDetectorService + private deviceDetectorService: DeviceDetectorService ) { const { globalPermissions } = this.dataService.fetchInfo(); @@ -58,6 +58,6 @@ export class GfFaqPageComponent implements OnInit { } public ngOnInit() { - this.deviceType = this.deviceService.getDeviceInfo().deviceType; + this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; } } diff --git a/apps/client/src/app/pages/home/home-page.component.ts b/apps/client/src/app/pages/home/home-page.component.ts index 5130c8166..958428331 100644 --- a/apps/client/src/app/pages/home/home-page.component.ts +++ b/apps/client/src/app/pages/home/home-page.component.ts @@ -42,7 +42,7 @@ export class GfHomePageComponent implements OnInit { public constructor( private changeDetectorRef: ChangeDetectorRef, private destroyRef: DestroyRef, - private deviceService: DeviceDetectorService, + private deviceDetectorService: DeviceDetectorService, private impersonationStorageService: ImpersonationStorageService, private userService: UserService ) { @@ -104,7 +104,7 @@ export class GfHomePageComponent implements OnInit { } public ngOnInit() { - this.deviceType = this.deviceService.getDeviceInfo().deviceType; + this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; this.impersonationStorageService .onChangeHasImpersonation() diff --git a/apps/client/src/app/pages/landing/landing-page.component.ts b/apps/client/src/app/pages/landing/landing-page.component.ts index 9ee9cceb4..2255881c3 100644 --- a/apps/client/src/app/pages/landing/landing-page.component.ts +++ b/apps/client/src/app/pages/landing/landing-page.component.ts @@ -108,7 +108,7 @@ export class GfLandingPageComponent implements OnInit { public constructor( private dataService: DataService, - private deviceService: DeviceDetectorService + private deviceDetectorService: DeviceDetectorService ) { const { countriesOfSubscribers = [], @@ -150,6 +150,6 @@ export class GfLandingPageComponent implements OnInit { } public ngOnInit() { - this.deviceType = this.deviceService.getDeviceInfo().deviceType; + this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; } } diff --git a/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts b/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts index acd6cba10..e43af52c9 100644 --- a/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts @@ -72,7 +72,7 @@ export class GfActivitiesPageComponent implements OnInit { private changeDetectorRef: ChangeDetectorRef, private dataService: DataService, private destroyRef: DestroyRef, - private deviceService: DeviceDetectorService, + private deviceDetectorService: DeviceDetectorService, private dialog: MatDialog, private icsService: IcsService, private impersonationStorageService: ImpersonationStorageService, @@ -112,7 +112,7 @@ export class GfActivitiesPageComponent implements OnInit { } public ngOnInit() { - this.deviceType = this.deviceService.getDeviceInfo().deviceType; + this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; this.impersonationStorageService .onChangeHasImpersonation() diff --git a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts index 00f0508fe..10df36857 100644 --- a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts @@ -109,7 +109,7 @@ export class GfImportActivitiesDialogComponent { @Inject(MAT_DIALOG_DATA) public data: ImportActivitiesDialogParams, private dataService: DataService, private destroyRef: DestroyRef, - private deviceService: DeviceDetectorService, + private deviceDetectorService: DeviceDetectorService, private formBuilder: FormBuilder, public dialogRef: MatDialogRef, private importActivitiesService: ImportActivitiesService, @@ -119,7 +119,7 @@ export class GfImportActivitiesDialogComponent { } public ngOnInit() { - this.deviceType = this.deviceService.getDeviceInfo().deviceType; + this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; this.stepperOrientation = this.deviceType === 'mobile' ? 'vertical' : 'horizontal'; diff --git a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts index b0282c937..ebfd336d3 100644 --- a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts +++ b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts @@ -127,7 +127,7 @@ export class GfAllocationsPageComponent implements OnInit { private changeDetectorRef: ChangeDetectorRef, private dataService: DataService, private destroyRef: DestroyRef, - private deviceService: DeviceDetectorService, + private deviceDetectorService: DeviceDetectorService, private dialog: MatDialog, private impersonationStorageService: ImpersonationStorageService, private route: ActivatedRoute, @@ -144,7 +144,7 @@ export class GfAllocationsPageComponent implements OnInit { } public ngOnInit() { - this.deviceType = this.deviceService.getDeviceInfo().deviceType; + this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; this.impersonationStorageService .onChangeHasImpersonation() diff --git a/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts b/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts index 47845ea6f..03fd0767a 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts @@ -106,7 +106,7 @@ export class GfAnalysisPageComponent implements OnInit { private clipboard: Clipboard, private dataService: DataService, private destroyRef: DestroyRef, - private deviceService: DeviceDetectorService, + private deviceDetectorService: DeviceDetectorService, private impersonationStorageService: ImpersonationStorageService, private snackBar: MatSnackBar, private userService: UserService @@ -129,7 +129,7 @@ export class GfAnalysisPageComponent implements OnInit { } public ngOnInit() { - this.deviceType = this.deviceService.getDeviceInfo().deviceType; + this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; this.impersonationStorageService .onChangeHasImpersonation() diff --git a/apps/client/src/app/pages/portfolio/portfolio-page.component.ts b/apps/client/src/app/pages/portfolio/portfolio-page.component.ts index eb4935127..dca39c4a6 100644 --- a/apps/client/src/app/pages/portfolio/portfolio-page.component.ts +++ b/apps/client/src/app/pages/portfolio/portfolio-page.component.ts @@ -36,7 +36,7 @@ export class PortfolioPageComponent implements OnInit { public constructor( private changeDetectorRef: ChangeDetectorRef, private destroyRef: DestroyRef, - private deviceService: DeviceDetectorService, + private deviceDetectorService: DeviceDetectorService, private userService: UserService ) { this.userService.stateChanged @@ -88,6 +88,6 @@ export class PortfolioPageComponent implements OnInit { } public ngOnInit() { - this.deviceType = this.deviceService.getDeviceInfo().deviceType; + this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; } } diff --git a/apps/client/src/app/pages/register/register-page.component.ts b/apps/client/src/app/pages/register/register-page.component.ts index db199143f..ecc83d8f3 100644 --- a/apps/client/src/app/pages/register/register-page.component.ts +++ b/apps/client/src/app/pages/register/register-page.component.ts @@ -40,7 +40,7 @@ export class GfRegisterPageComponent implements OnInit { public constructor( private dataService: DataService, private destroyRef: DestroyRef, - private deviceService: DeviceDetectorService, + private deviceDetectorService: DeviceDetectorService, private dialog: MatDialog, private router: Router, private tokenStorageService: TokenStorageService, @@ -54,7 +54,7 @@ export class GfRegisterPageComponent implements OnInit { public ngOnInit() { const { globalPermissions } = this.dataService.fetchInfo(); - this.deviceType = this.deviceService.getDeviceInfo().deviceType; + this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; this.hasPermissionForAuthGoogle = hasPermission( globalPermissions, diff --git a/apps/client/src/app/pages/resources/resources-page.component.ts b/apps/client/src/app/pages/resources/resources-page.component.ts index c25ef00d6..016f92fa8 100644 --- a/apps/client/src/app/pages/resources/resources-page.component.ts +++ b/apps/client/src/app/pages/resources/resources-page.component.ts @@ -46,11 +46,11 @@ export class ResourcesPageComponent implements OnInit { } ]; - public constructor(private deviceService: DeviceDetectorService) { + public constructor(private deviceDetectorService: DeviceDetectorService) { addIcons({ bookOutline, libraryOutline, newspaperOutline, readerOutline }); } public ngOnInit() { - this.deviceType = this.deviceService.getDeviceInfo().deviceType; + this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; } } diff --git a/apps/client/src/app/pages/user-account/user-account-page.component.ts b/apps/client/src/app/pages/user-account/user-account-page.component.ts index 24c150408..7d5af5423 100644 --- a/apps/client/src/app/pages/user-account/user-account-page.component.ts +++ b/apps/client/src/app/pages/user-account/user-account-page.component.ts @@ -33,7 +33,7 @@ export class GfUserAccountPageComponent implements OnInit { public constructor( private changeDetectorRef: ChangeDetectorRef, private destroyRef: DestroyRef, - private deviceService: DeviceDetectorService, + private deviceDetectorService: DeviceDetectorService, private userService: UserService ) { this.userService.stateChanged @@ -70,6 +70,6 @@ export class GfUserAccountPageComponent implements OnInit { } public ngOnInit() { - this.deviceType = this.deviceService.getDeviceInfo().deviceType; + this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; } } diff --git a/apps/client/src/app/pages/zen/zen-page.component.ts b/apps/client/src/app/pages/zen/zen-page.component.ts index 633f15885..b3c14b4e0 100644 --- a/apps/client/src/app/pages/zen/zen-page.component.ts +++ b/apps/client/src/app/pages/zen/zen-page.component.ts @@ -31,7 +31,7 @@ export class GfZenPageComponent implements OnInit { public constructor( private changeDetectorRef: ChangeDetectorRef, private destroyRef: DestroyRef, - private deviceService: DeviceDetectorService, + private deviceDetectorService: DeviceDetectorService, private userService: UserService ) { this.userService.stateChanged @@ -60,6 +60,6 @@ export class GfZenPageComponent implements OnInit { } public ngOnInit() { - this.deviceType = this.deviceService.getDeviceInfo().deviceType; + this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; } } diff --git a/apps/client/src/app/services/user/user.service.ts b/apps/client/src/app/services/user/user.service.ts index e92eb6bd4..caeea775e 100644 --- a/apps/client/src/app/services/user/user.service.ts +++ b/apps/client/src/app/services/user/user.service.ts @@ -26,7 +26,7 @@ export class UserService extends ObservableStore { public constructor( private destroyRef: DestroyRef, - private deviceService: DeviceDetectorService, + private deviceDetectorService: DeviceDetectorService, private dialog: MatDialog, private http: HttpClient, private webAuthnService: WebAuthnService @@ -35,7 +35,7 @@ export class UserService extends ObservableStore { this.setState({ user: undefined }, UserStoreActions.Initialize); - this.deviceType = this.deviceService.getDeviceInfo().deviceType; + this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; } public get(force = false) { From 3fc926444ac636acb882d849953bd3e841ebb4d5 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 1 May 2026 17:02:19 +0200 Subject: [PATCH 029/132] Feature/add angular-developer skills (#6823) * Add angular-developer skills * Update changelog --- .agents/skills/angular-developer/SKILL.md | 130 +++ .../references/angular-animations.md | 160 ++++ .../references/angular-aria.md | 410 ++++++++ .../angular-developer/references/cli.md | 86 ++ .../references/component-harnesses.md | 59 ++ .../references/component-styling.md | 91 ++ .../references/components.md | 117 +++ .../references/creating-services.md | 97 ++ .../references/data-resolvers.md | 69 ++ .../references/define-routes.md | 67 ++ .../references/defining-providers.md | 72 ++ .../references/di-fundamentals.md | 120 +++ .../references/e2e-testing.md | 66 ++ .../angular-developer/references/effects.md | 83 ++ .../references/hierarchical-injectors.md | 43 + .../references/host-elements.md | 80 ++ .../references/injection-context.md | 63 ++ .../angular-developer/references/inputs.md | 101 ++ .../references/linked-signal.md | 59 ++ .../references/loading-strategies.md | 61 ++ .../angular-developer/references/mcp.md | 106 +++ .../references/migrations.md | 30 + .../references/navigate-to-routes.md | 69 ++ .../angular-developer/references/outputs.md | 86 ++ .../references/reactive-forms.md | 122 +++ .../references/rendering-strategies.md | 44 + .../angular-developer/references/resource.md | 77 ++ .../references/route-animations.md | 56 ++ .../references/route-guards.md | 52 + .../references/router-lifecycle.md | 45 + .../references/router-testing.md | 87 ++ .../references/show-routes-with-outlets.md | 68 ++ .../references/signal-forms.md | 897 ++++++++++++++++++ .../references/signals-overview.md | 94 ++ .../references/tailwind-css.md | 69 ++ .../references/template-driven-forms.md | 114 +++ .../references/testing-fundamentals.md | 66 ++ .claude/skills/angular-developer | 1 + .prettierignore | 1 + CHANGELOG.md | 4 + skills-lock.json | 11 + 41 files changed, 4133 insertions(+) create mode 100644 .agents/skills/angular-developer/SKILL.md create mode 100644 .agents/skills/angular-developer/references/angular-animations.md create mode 100644 .agents/skills/angular-developer/references/angular-aria.md create mode 100644 .agents/skills/angular-developer/references/cli.md create mode 100644 .agents/skills/angular-developer/references/component-harnesses.md create mode 100644 .agents/skills/angular-developer/references/component-styling.md create mode 100644 .agents/skills/angular-developer/references/components.md create mode 100644 .agents/skills/angular-developer/references/creating-services.md create mode 100644 .agents/skills/angular-developer/references/data-resolvers.md create mode 100644 .agents/skills/angular-developer/references/define-routes.md create mode 100644 .agents/skills/angular-developer/references/defining-providers.md create mode 100644 .agents/skills/angular-developer/references/di-fundamentals.md create mode 100644 .agents/skills/angular-developer/references/e2e-testing.md create mode 100644 .agents/skills/angular-developer/references/effects.md create mode 100644 .agents/skills/angular-developer/references/hierarchical-injectors.md create mode 100644 .agents/skills/angular-developer/references/host-elements.md create mode 100644 .agents/skills/angular-developer/references/injection-context.md create mode 100644 .agents/skills/angular-developer/references/inputs.md create mode 100644 .agents/skills/angular-developer/references/linked-signal.md create mode 100644 .agents/skills/angular-developer/references/loading-strategies.md create mode 100644 .agents/skills/angular-developer/references/mcp.md create mode 100644 .agents/skills/angular-developer/references/migrations.md create mode 100644 .agents/skills/angular-developer/references/navigate-to-routes.md create mode 100644 .agents/skills/angular-developer/references/outputs.md create mode 100644 .agents/skills/angular-developer/references/reactive-forms.md create mode 100644 .agents/skills/angular-developer/references/rendering-strategies.md create mode 100644 .agents/skills/angular-developer/references/resource.md create mode 100644 .agents/skills/angular-developer/references/route-animations.md create mode 100644 .agents/skills/angular-developer/references/route-guards.md create mode 100644 .agents/skills/angular-developer/references/router-lifecycle.md create mode 100644 .agents/skills/angular-developer/references/router-testing.md create mode 100644 .agents/skills/angular-developer/references/show-routes-with-outlets.md create mode 100644 .agents/skills/angular-developer/references/signal-forms.md create mode 100644 .agents/skills/angular-developer/references/signals-overview.md create mode 100644 .agents/skills/angular-developer/references/tailwind-css.md create mode 100644 .agents/skills/angular-developer/references/template-driven-forms.md create mode 100644 .agents/skills/angular-developer/references/testing-fundamentals.md create mode 120000 .claude/skills/angular-developer create mode 100644 skills-lock.json diff --git a/.agents/skills/angular-developer/SKILL.md b/.agents/skills/angular-developer/SKILL.md new file mode 100644 index 000000000..f332e3e08 --- /dev/null +++ b/.agents/skills/angular-developer/SKILL.md @@ -0,0 +1,130 @@ +--- +name: angular-developer +description: Generates Angular code and provides architectural guidance. Trigger when creating projects, components, or services, or for best practices on reactivity (signals, linkedSignal, resource), forms, dependency injection, routing, SSR, accessibility (ARIA), animations, styling (component styles, Tailwind CSS), testing, or CLI tooling. +license: MIT +metadata: + author: Copyright 2026 Google LLC + version: '1.0' +--- + +# Angular Developer Guidelines + +1. Always analyze the project's Angular version before providing guidance, as best practices and available features can vary significantly between versions. If creating a new project with Angular CLI, do not specify a version unless prompted by the user. + +2. When generating code, follow Angular's style guide and best practices for maintainability and performance. Use the Angular CLI for scaffolding components, services, directives, pipes, and routes to ensure consistency. + +3. Once you finish generating code, run `ng build` to ensure there are no build errors. If there are errors, analyze the error messages and fix them before proceeding. Do not skip this step, as it is critical for ensuring the generated code is correct and functional. + +## Creating New Projects + +If no guidelines are provided by the user, here are same default rules to follow when creating a new Angular project: + +1. Use the latest stable version of Angular unless the user specifies otherwise. +2. Use Signals Forms for form management in new projects (available in Angular v21 and newer) [Find out more](references/signal-forms.md). + +**Execution Rules for `ng new`:** +When asked to create a new Angular project, you must determine the correct execution command by following these strict steps: + +**Step 1: Check for an explicit user version.** + +- **IF** the user requests a specific version (e.g., Angular 15), bypass local installations and strictly use `npx`. +- **Command:** `npx @angular/cli@ new ` + +**Step 2: Check for an existing Angular installation.** + +- **IF** no specific version is requested, run `ng version` in the terminal to check if the Angular CLI is already installed on the system. +- **IF** the command succeeds and returns an installed version, use the local/global installation directly. +- **Command:** `ng new ` + +**Step 3: Fallback to Latest.** + +- **IF** no specific version is requested AND the `ng version` command fails (indicating no Angular installation exists), you must use `npx` to fetch the latest version. +- **Command:** `npx @angular/cli@latest new ` + +## Components + +When working with Angular components, consult the following references based on the task: + +- **Fundamentals**: Anatomy, metadata, core concepts, and template control flow (@if, @for, @switch). Read [components.md](references/components.md) +- **Inputs**: Signal-based inputs, transforms, and model inputs. Read [inputs.md](references/inputs.md) +- **Outputs**: Signal-based outputs and custom event best practices. Read [outputs.md](references/outputs.md) +- **Host Elements**: Host bindings and attribute injection. Read [host-elements.md](references/host-elements.md) + +If you require deeper documentation not found in the references above, read the documentation at `https://angular.dev/guide/components`. + +## Reactivity and Data Management + +When managing state and data reactivity, use Angular Signals and consult the following references: + +- **Signals Overview**: Core signal concepts (`signal`, `computed`), reactive contexts, and `untracked`. Read [signals-overview.md](references/signals-overview.md) +- **Dependent State (`linkedSignal`)**: Creating writable state linked to source signals. Read [linked-signal.md](references/linked-signal.md) +- **Async Reactivity (`resource`)**: Fetching asynchronous data directly into signal state. Read [resource.md](references/resource.md) +- **Side Effects (`effect`)**: Logging, third-party DOM manipulation (`afterRenderEffect`), and when NOT to use effects. Read [effects.md](references/effects.md) + +## Forms + +In most cases for new apps, **prefer signal forms**. When making a forms decision, analyze the project and consider the following guidelines: + +- if the application is using v21 or newer and this is a new form, **prefer signal forms**. + -For older applications or when working with existing forms, use the appropriate form type that matches the applications current form strategy. + +- **Signal Forms**: Use signals for form state management. Read [signal-forms.md](references/signal-forms.md) +- **Template-driven forms**: Use for simple forms. Read [template-driven-forms.md](references/template-driven-forms.md) +- **Reactive forms**: Use for complex forms. Read [reactive-forms.md](references/reactive-forms.md) + +## Dependency Injection + +When implementing dependency injection in Angular, follow these guidelines: + +- **Fundamentals**: Overview of Dependency Injection, services, and the `inject()` function. Read [di-fundamentals.md](references/di-fundamentals.md) +- **Creating and Using Services**: Creating services, the `providedIn: 'root'` option, and injecting into components or other services. Read [creating-services.md](references/creating-services.md) +- **Defining Dependency Providers**: Automatic vs manual provision, `InjectionToken`, `useClass`, `useValue`, `useFactory`, and scopes. Read [defining-providers.md](references/defining-providers.md) +- **Injection Context**: Where `inject()` is allowed, `runInInjectionContext`, and `assertInInjectionContext`. Read [injection-context.md](references/injection-context.md) +- **Hierarchical Injectors**: The `EnvironmentInjector` vs `ElementInjector`, resolution rules, modifiers (`optional`, `skipSelf`), and `providers` vs `viewProviders`. Read [hierarchical-injectors.md](references/hierarchical-injectors.md) + +## Angular Aria + +When building accessible custom components for any of the following patterns: Accordion, Listbox, Combobox, Menu, Tabs, Toolbar, Tree, Grid, consult the following reference: + +- **Angular Aria Components**: Building headless, accessible components (Accordion, Listbox, Combobox, Menu, Tabs, Toolbar, Tree, Grid) and styling ARIA attributes. Read [angular-aria.md](references/angular-aria.md) + +## Routing + +When implementing navigation in Angular, consult the following references: + +- **Define Routes**: URL paths, static vs dynamic segments, wildcards, and redirects. Read [define-routes.md](references/define-routes.md) +- **Route Loading Strategies**: Eager vs lazy loading, and context-aware loading. Read [loading-strategies.md](references/loading-strategies.md) +- **Show Routes with Outlets**: Using ``, nested outlets, and named outlets. Read [show-routes-with-outlets.md](references/show-routes-with-outlets.md) +- **Navigate to Routes**: Declarative navigation with `RouterLink` and programmatic navigation with `Router`. Read [navigate-to-routes.md](references/navigate-to-routes.md) +- **Control Route Access with Guards**: Implementing `CanActivate`, `CanMatch`, and other guards for security. Read [route-guards.md](references/route-guards.md) +- **Data Resolvers**: Pre-fetching data before route activation with `ResolveFn`. Read [data-resolvers.md](references/data-resolvers.md) +- **Router Lifecycle and Events**: Chronological order of navigation events and debugging. Read [router-lifecycle.md](references/router-lifecycle.md) +- **Rendering Strategies**: CSR, SSG (Prerendering), and SSR with hydration. Read [rendering-strategies.md](references/rendering-strategies.md) +- **Route Transition Animations**: Enabling and customizing the View Transitions API. Read [route-animations.md](references/route-animations.md) + +If you require deeper documentation or more context, visit the [official Angular Routing guide](https://angular.dev/guide/routing). + +## Styling and Animations + +When implementing styling and animations in Angular, consult the following references: + +- **Using Tailwind CSS with Angular**: Integrating Tailwind CSS into Angular projects. Read [tailwind-css.md](references/tailwind-css.md) +- **Angular Animations**: Using native CSS (recommended) or the legacy DSL for dynamic effects. Read [angular-animations.md](references/angular-animations.md) +- **Styling components**: Best practices for component styles and encapsulation. Read [component-styling.md](references/component-styling.md) + +## Testing + +When writing or updating tests, consult the following references based on the task: + +- **Fundamentals**: Best practices for unit testing (Vitest), async patterns, and `TestBed`. Read [testing-fundamentals.md](references/testing-fundamentals.md) +- **Component Harnesses**: Standard patterns for robust component interaction. Read [component-harnesses.md](references/component-harnesses.md) +- **Router Testing**: Using `RouterTestingHarness` for reliable navigation tests. Read [router-testing.md](references/router-testing.md) +- **End-to-End (E2E) Testing**: Best practices for E2E tests with Cypress. Read [e2e-testing.md](references/e2e-testing.md) + +## Tooling + +When working with Angular tooling, consult the following references: + +- **Angular CLI**: Creating applications, generating code (components, routes, services), serving, and building. Read [cli.md](references/cli.md) +- **Code Modernization**: Automatically refactoring to modern standards using migrations. Read [migrations.md](references/migrations.md) +- **Angular MCP Server**: Available tools, configuration, and experimental features. Read [mcp.md](references/mcp.md) diff --git a/.agents/skills/angular-developer/references/angular-animations.md b/.agents/skills/angular-developer/references/angular-animations.md new file mode 100644 index 000000000..c96c4d9c6 --- /dev/null +++ b/.agents/skills/angular-developer/references/angular-animations.md @@ -0,0 +1,160 @@ +# Angular Animations + +When animating elements in Angular, **first analyze the project's Angular version** in `package.json`. +For modern applications (**Angular v20.2 and above**), prefer using native CSS with `animate.enter` and `animate.leave`. For older applications, you may need to use the deprecated `@angular/animations` package. + +## 1. Native CSS Animations (v20.2+ Recommended) + +Modern Angular provides `animate.enter` and `animate.leave` to animate elements as they enter or leave the DOM. They apply CSS classes at the appropriate times. + +### `animate.enter` and `animate.leave` + +Use these directly on elements to apply CSS classes during the enter or leave phase. Angular automatically removes the enter classes when the animation completes. For `animate.leave`, Angular waits for the animation to finish before removing the element from the DOM. + +`animate.enter` example: + +```html +@if (isShown()) { +
    +

    The box is entering.

    +
    +} +``` + +```css +/* Ensure you have a starting style if using transitions instead of keyframes */ +.enter-container { + border: 1px solid #dddddd; + margin-top: 1em; + padding: 20px; + font-weight: bold; + font-size: 20px; +} +.enter-container p { + margin: 0; +} +.enter-animation { + animation: slide-fade 1s; +} +@keyframes slide-fade { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +``` + +_Note: `animate.leave` may be added to child elements being removed._ + +### Event Bindings and Third-party Libraries + +You can bind to `(animate.enter)` and `(animate.leave)` to call functions or use JS libraries like GSAP. + +```html +@if(show()) { +
    ...
    +} +``` + +```ts +import { AnimationCallbackEvent } from '@angular/core'; + +onLeave(event: AnimationCallbackEvent) { + // Custom animation logic here + // CRITICAL: You MUST call animationComplete() when done so Angular removes the element! + event.animationComplete(); +} +``` + +## 2. Advanced CSS Animations + +CSS offers robust tools for advanced animation sequences. + +### Animating State and Styles + +Toggle CSS classes on elements using property binding to trigger transitions. + +```html +
    ...
    +``` + +```css +div { + transition: height 0.3s ease-out; + height: 100px; +} +div.open { + height: 200px; +} +``` + +### Animating Auto Height + +You can use `css-grid` to animate to auto height. + +```css +.container { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.3s; +} +.container.open { + grid-template-rows: 1fr; +} +.container > div { + overflow: hidden; +} +``` + +### Staggering and Parallel Animations + +- **Staggering**: Use `animation-delay` or `transition-delay` with different values for items in a list. +- **Parallel**: Apply multiple animations in the `animation` shorthand (e.g., `animation: rotate 3s, fade-in 2s;`). + +### Programmatic Control + +Retrieve animations directly using standard Web APIs: + +```ts +const animations = element.getAnimations(); +animations.forEach((anim) => anim.pause()); +``` + +## 3. Legacy Animations DSL (Deprecated) + +For older projects (pre v20.2 or where `@angular/animations` is already heavily used), you use the component metadata DSL. + +**Important:** Do not mix legacy animations and `animate.enter`/`leave` in the same component. + +### Setup + +```ts +bootstrapApplication(App, { + providers: [provideAnimationsAsync()], +}); +``` + +### Defining Transitions + +```ts +import {signal} from '@angular/core'; +import {trigger, state, style, animate, transition} from '@angular/animations'; + +@Component({ + animations: [ + trigger('openClose', [ + state('open', style({opacity: 1})), + state('closed', style({opacity: 0})), + transition('open <=> closed', [animate('0.5s')]), + ]), + ], + template: `
    ...
    `, +}) +export class OpenClose { + isOpen = signal(true); +} +``` diff --git a/.agents/skills/angular-developer/references/angular-aria.md b/.agents/skills/angular-developer/references/angular-aria.md new file mode 100644 index 000000000..2dd0cf451 --- /dev/null +++ b/.agents/skills/angular-developer/references/angular-aria.md @@ -0,0 +1,410 @@ +# Angular Aria + +Angular Aria (`@angular/aria`) is a collection of headless, accessible directives that implement common WAI-ARIA patterns. These directives handle keyboard interactions, ARIA attributes, focus management, and screen reader support. + +**As an AI Agent, your role is to provide the HTML structure and CSS styling**, while the directives handle the complex accessibility logic. + +## Styling Headless Components + +Because Angular Aria components are headless, they do not come with default styles. You **must** use CSS to style different states based on the ARIA attributes or structural classes the directives automatically apply. + +Common ARIA attributes to target in CSS: + +- `[aria-expanded="true"]` / `[aria-expanded="false"]` +- `[aria-selected="true"]` +- `[aria-disabled="true"]` +- `[aria-current="page"]` (for navigation) + +--- + +**CRITICAL**: Before using this package, it must be installed via the package manager. Confirm that it has been installed in the project. Use `npm install @angular/aria` to install if necessary. + +## 1. Accordion + +Organizes related content into expandable/collapsible sections. + +**Usage:** The Accordion is a layout component designed to organize content into logical groups that users can expand one at a time to reduce scrolling on content-heavy pages. Use it for FAQs, long forms, or progressive disclosure of information, but avoid it for primary navigation or scenarios where users must view multiple sections of content simultaneously. + +**Imports:** `import { AccordionContent, AccordionGroup, AccordionPanel, AccordionTrigger } from '@angular/aria/accordion';` + +**Directives:** `ngAccordionGroup`, `ngAccordionTrigger`, `ngAccordionPanel`, `ngAccordionContent` (for lazy loading). + +```ts +@Component({ + selector: 'app-cmp', + imports: [AccordionContent, AccordionGroup, AccordionPanel, AccordionTrigger], + template: `...`, + styles: [], +}) +export class App { + protected readonly title = signal('angular-app'); +} +``` + +```html +
    +
    + +
    + +

    Lazy loaded content here.

    +
    +
    +
    +
    +``` + +**Styling Strategy:** +Target the `[aria-expanded]` attribute on the trigger to rotate icons, and style the panel visibility. + +```css +.accordion-header[aria-expanded='true'] .icon { + transform: rotate(180deg); +} + +/* The panel directive handles DOM removal, but you can style the transition */ +.accordion-panel { + padding: 1rem; + border-top: 1px solid #ccc; +} +``` + +--- + +## 2. Listbox + +A foundational directive for displaying a list of options. Used for visible selection lists (not dropdowns). + +**Usage:** Visible selectable lists (single or multi-select). + +**Imports:** `import {Listbox, Option} from '@angular/aria/listbox';` + +**Directives:** `ngListbox`, `ngOption`. + +```ts +@Component({ + selector: 'app-cmp', + imports: [Listbox, Option], + template: `...`, + styles: [], +}) +export class App { + protected readonly title = signal('angular-app'); +} +``` + +```html + +
      +
    • Apple
    • +
    • Banana
    • +
    +``` + +**Styling Strategy:** +Target `[aria-selected="true"]` for selected state and `:focus-visible` or `[data-active]` for the focused item (Angular Aria uses roving tabindex or activedescendant). + +```css +.option { + padding: 8px; + cursor: pointer; +} +.option[aria-selected='true'] { + background: #e0f7fa; + font-weight: bold; +} +/* Focus state managed by aria */ +.option:focus-visible { + outline: 2px solid blue; +} +``` + +--- + +## 3. Combobox, Select, and Multiselect + +These patterns combine `ngCombobox` with a popup containing an `ngListbox`. + +- **Combobox**: Text input + popup (used for Autocomplete). +- **Select**: Readonly Combobox + single-select Listbox. +- **Multiselect**: Readonly Combobox + multi-select Listbox. + +**Usage:** The Combobox is a low-level primitive directive that synchronizes a text input with a popup, serving as the foundational logic for autocomplete, select, and multiselect patterns. Use it specifically for building custom filtering, unique selection requirements, or specialized input-to-popup coordination that deviates from standard, documented components. + +**Imports:** + +``` + import {Combobox, ComboboxInput, ComboboxPopupContainer} from '@angular/aria/combobox'; + import {Listbox, Option} from '@angular/aria/listbox'; +``` + +**Directives:** `ngCombobox`, `ngComboboxInput`, `ngComboboxPopupContainer`, `ngListbox`, `ngOption`. + +```html + +
    + + + + + +
    +``` + +**Styling Strategy:** +Style the popup container to look like a dropdown floating above content (often paired with CDK Overlay). + +```css +.select-trigger { + width: 200px; + padding: 8px; + text-align: left; +} +.dropdown-menu { + list-style: none; + padding: 0; + margin: 0; + border: 1px solid #ccc; + background: white; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} +``` + +--- + +## 4. Menu and Menubar + +For actions, commands, and context menus (not for form selection). + +**Usage:** The Menubar is a high-level navigation pattern designed for building desktop-style application command bars (e.g., File, Edit, View) that stay persistent across an interface. It is best utilized for organizing complex commands into logical top-level categories with full horizontal keyboard support, but it should be avoided for simple standalone action lists or mobile-first layouts where horizontal space is constrained. + +**Imports:** `import {MenuBar, Menu, MenuContent, MenuItem} from '@angular/aria/menu';` + +**Directives:** `ngMenuBar`, `ngMenu`, `ngMenuItem`, `ngMenuTrigger`. + +```html + + + + +``` + +**Styling Strategy:** +Use flexbox for the menubar. Hide/show submenus based on the trigger's state. + +```css +.menubar { + display: flex; + gap: 10px; + list-style: none; + padding: 0; +} +.menu { + background: white; + border: 1px solid #ccc; + padding: 5px 0; +} +.menu li { + padding: 5px 15px; + cursor: pointer; +} +``` + +--- + +## 5. Tabs + +Layered content sections where only one panel is visible. + +**Usage:** The Tabs component is used to organize related content into distinct, navigable sections, allowing users to switch between categories or views without leaving the page. It is ideal for settings panels, multi-topic documentation, or dashboards, but should be avoided for sequential workflows (steppers) or when navigation involves more than 7–8 sections. + +**Imports:** `import {Tab, Tabs, TabList, TabPanel, TabContent} from '@angular/aria/tabs';` + +**Directives:** `ngTabs`, `ngTabList`, `ngTab`, `ngTabPanel`, `ngTabContent`. + +```html +
    +
      +
    • Profile
    • +
    • Security
    • +
    + +
    + Profile Settings +
    +
    + Security Settings +
    +
    +``` + +**Styling Strategy:** +Target `[aria-selected="true"]` on the tab buttons. + +```css +.tab-list { + display: flex; + border-bottom: 2px solid #ccc; + list-style: none; + padding: 0; +} +.tab-btn { + padding: 10px 20px; + cursor: pointer; + border-bottom: 2px solid transparent; +} +.tab-btn[aria-selected='true'] { + border-bottom-color: blue; + font-weight: bold; +} +.tab-panel { + padding: 20px; +} +``` + +--- + +## 6. Toolbar + +Groups related controls (like text formatting). + +**Usage:** The Toolbar is an organizational component designed to group frequently accessed, related controls into a single logical container. It is best used to enhance keyboard efficiency (via arrow-key navigation) and visual structure for workflows requiring repeated actions, such as text formatting or media controls. + +**Imports:** `import {Toolbar, ToolbarWidget, ToolbarWidgetGroup} from '@angular/aria/toolbar';` + +**Directives:** `ngToolbar`, `ngToolbarWidget`, `ngToolbarWidgetGroup`. + +```html +
    +
    + + +
    +
    +``` + +**Styling Strategy:** +Target `[aria-pressed="true"]` (for toggle buttons) or `[aria-checked="true"]` (for radio groups) within the toolbar. + +```css +.toolbar { + display: flex; + gap: 5px; + padding: 8px; + background: #f5f5f5; +} +.tool-btn { + padding: 5px 10px; + border: 1px solid #ccc; +} +.tool-btn[aria-pressed='true'], +.tool-btn[aria-checked='true'] { + background: #ddd; +} +``` + +--- + +## 7. Tree + +Displays hierarchical data (file systems, nested nav). + +**Usage:** The Tree component is designed for navigating and displaying deeply nested, hierarchical data structures like file systems, organization charts, or complex site architectures. It should be used specifically for multi-level relationships where users need to expand or collapse branches, but it should be avoided for flat lists, data tables, or simple selection menus. + +**Imports:** `import {Tree, TreeItem, TreeItemGroup} from '@angular/aria/tree';` + +**Directives:** `ngTree`, `ngTreeItem`, `ngTreeGroup`. + +```html +
      +
    • + Documents +
        +
      • Resume.pdf
      • +
      +
    • +
    +``` + +**Styling Strategy:** +Target `[aria-expanded]` to show/hide children or rotate chevron icons. Use `padding-left` on nested groups to show hierarchy. + +```css +.tree, +.tree-group { + list-style: none; + padding-left: 20px; +} +.tree-label::before { + content: '▶ '; + display: inline-block; + transition: transform 0.2s; +} +li[aria-expanded='true'] > .tree-label::before { + transform: rotate(90deg); +} +``` + +## 8. Grid + +A two-dimensional interactive collection of cells enabling navigation via arrow keys. + +**Usage:** Data tables, calendars, spreadsheets, and layout patterns for interactive elements. +**Directives:** `ngGrid`, `ngGridRow`, `ngGridCell`, `ngGridCellWidget`. + +```html + + + + + + + + + +
    NameStatus
    Project A + +
    +``` + +**Styling Strategy:** +Target `[aria-selected="true"]` for selected cells and `:focus-visible` for the active cell (roving tabindex) or `[aria-activedescendant]` on the container. + +```css +.grid-table { + border-collapse: collapse; +} +[ngGridCell] { + padding: 8px; + border: 1px solid #ddd; +} +[ngGridCell][aria-selected='true'] { + background: #e3f2fd; +} +/* Focus state managed by roving tabindex */ +[ngGridCell]:focus-visible { + outline: 2px solid #2196f3; + outline-offset: -2px; +} +``` + +## General Rules for Agents + +1. **Never use native HTML elements like ` + +
    + +
    + +
    + @for (alias of aliases.controls; track $index) { + + } +
    + + + +``` + +## Accessing Controls + +Use getters for easy access to controls, especially for `FormArray`. + +```ts +get aliases() { + return this.profileForm.get('aliases') as FormArray; +} + +addAlias() { + this.aliases.push(this.fb.control('')); +} +``` + +## Updating Values + +- `patchValue()`: Updates only the specified properties. Fails silently on structural mismatches. +- `setValue()`: Replaces the entire model. Strictly enforces the form structure. + +```ts +updateProfile() { + this.profileForm.patchValue({ + firstName: 'Nancy', + address: { street: '123 Drew Street' } + }); +} +``` + +## Unified Change Events + +Modern Angular (v18+) provides a single `events` observable on all controls to track value, status, pristine, touched, reset, and submit events. + +```ts +import {ValueChangeEvent, StatusChangeEvent} from '@angular/forms'; + +this.profileForm.events.subscribe((event) => { + if (event instanceof ValueChangeEvent) { + console.log('New value:', event.value); + } +}); +``` + +## Manual State Management + +- `markAsTouched()` / `markAllAsTouched()`: Useful for showing validation errors on submit. +- `markAsDirty()` / `markAsPristine()`: Tracks if the value has been modified. +- `updateValueAndValidity()`: Manually triggers recalculation of value and status. +- Options `{ emitEvent: false }` or `{ onlySelf: true }` can be passed to most methods to control propagation. diff --git a/.agents/skills/angular-developer/references/rendering-strategies.md b/.agents/skills/angular-developer/references/rendering-strategies.md new file mode 100644 index 000000000..3b4260022 --- /dev/null +++ b/.agents/skills/angular-developer/references/rendering-strategies.md @@ -0,0 +1,44 @@ +# Rendering Strategies + +Angular supports multiple rendering strategies to optimize for SEO, performance, and interactivity. + +## 1. Client-Side Rendering (CSR) + +**Default Strategy.** Content is rendered entirely in the browser. + +- **Use case**: Interactive dashboards, internal tools. +- **Pros**: Simplest to configure, low server cost. +- **Cons**: Poor SEO, slower initial content visibility (must wait for JS). + +## 2. Static Site Generation (SSG / Prerendering) + +Content is pre-rendered into static HTML files at **build time**. + +- **Use case**: Marketing pages, blogs, documentation. +- **Pros**: Fastest initial load, excellent SEO, CDN-friendly. +- **Cons**: Requires rebuild for content updates, not for user-specific data. + +## 3. Server-Side Rendering (SSR) + +Content is rendered on the server for the **initial request**. Subsequent navigations happen client-side (SPA style). + +- **Use case**: E-commerce product pages, news sites, personalized dynamic content. +- **Pros**: Excellent SEO, fast initial content visibility. +- **Cons**: Requires a server (Node.js), higher server cost/latency. + +## Hydration + +Hydration is the process of making server-rendered HTML interactive in the browser. + +- **Full Hydration**: The entire app becomes interactive at once. +- **Incremental Hydration**: (Advanced) Parts become interactive as needed using `@defer` blocks. +- **Event Replay**: Captures and replays user events that happened before hydration finished. + +## Decision Matrix + +| Requirement | Strategy | +| :------------------------------ | :------------------- | +| **SEO + Static Content** | SSG | +| **SEO + Dynamic Content** | SSR | +| **No SEO + High Interactivity** | CSR | +| **Mixed** | Hybrid (Route-based) | diff --git a/.agents/skills/angular-developer/references/resource.md b/.agents/skills/angular-developer/references/resource.md new file mode 100644 index 000000000..e356ea51b --- /dev/null +++ b/.agents/skills/angular-developer/references/resource.md @@ -0,0 +1,77 @@ +# Async Reactivity with `resource` + +> [!IMPORTANT] +> The `resource` API is currently experimental in Angular. + +A `Resource` incorporates asynchronous data fetching into Angular's signal-based reactivity. It executes an async loader function whenever its dependencies change, exposing the status and result as synchronous signals. + +## Basic Usage + +The `resource` function accepts an options object with two main properties: + +1. `params`: A reactive computation (like `computed`). When signals read here change, the resource re-fetches. +2. `loader`: An async function that fetches data based on the parameters. + +```ts +import { Component, resource, signal, computed } from '@angular/core'; + +@Component({...}) +export class UserProfile { + userId = signal('123'); + + userResource = resource({ + // Reactively tracking userId + params: () => ({ id: this.userId() }), + + // Executes whenever params change + loader: async ({ params, abortSignal }) => { + const response = await fetch(`/api/users/${params.id}`, { signal: abortSignal }); + if (!response.ok) throw new Error('Network error'); + return response.json(); + } + }); + + // Use the resource value in computed signals + userName = computed(() => { + if (this.userResource.hasValue()) { + return this.userResource.value()?.name; + } else { + return 'Loading...'; + } + }); +} +``` + +## Aborting Requests + +If the `params` signal changes while a previous loader is still running, the `Resource` will attempt to abort the outstanding request using the provided `abortSignal`. **Always pass `abortSignal` to your `fetch` calls.** + +## Reloading Data + +You can imperatively force the resource to re-run the loader without the params changing by calling `.reload()`. + +```ts +this.userResource.reload(); +``` + +## Resource Status Signals + +The `Resource` object provides several signals to read its current state: + +- `value()`: The resolved data, or `undefined`. +- `hasValue()`: Type-guard boolean. `true` if a value exists. +- `isLoading()`: Boolean indicating if the loader is currently running. +- `error()`: The error thrown by the loader, or `undefined`. +- `status()`: A string constant representing the exact state (`'idle'`, `'loading'`, `'resolved'`, `'error'`, `'reloading'`, `'local'`). + +## Local Mutation + +You can optimistically update the resource's value directly. This changes the status to `'local'`. + +```ts +this.userResource.value.set({name: 'Optimistic Update'}); +``` + +## Reactive Data Fetching with `httpResource` + +If you are using Angular's `HttpClient`, prefer using `httpResource`. It is a specialized wrapper that leverages the Angular HTTP stack (including interceptors) while providing the same signal-based resource API. diff --git a/.agents/skills/angular-developer/references/route-animations.md b/.agents/skills/angular-developer/references/route-animations.md new file mode 100644 index 000000000..56cebbede --- /dev/null +++ b/.agents/skills/angular-developer/references/route-animations.md @@ -0,0 +1,56 @@ +# Route Transition Animations + +Angular Router supports the browser's **View Transitions API** for smooth visual transitions between routes. + +## Enabling View Transitions + +Add `withViewTransitions()` to your router configuration. + +```ts +provideRouter(routes, withViewTransitions()); +``` + +This is a **progressive enhancement**. In browsers that don't support the API, the router will still work but without the transition animation. + +## How it Works + +1. Browser takes a screenshot of the old state. +2. Router updates the DOM (activates new component). +3. Browser takes a screenshot of the new state. +4. Browser animates between the two states. + +## Customizing with CSS + +Transitions are customized in **global CSS files** (not component-scoped CSS). + +Use the `::view-transition-old()` and `::view-transition-new()` pseudo-elements. + +```css +/* Example: Cross-fade + Slide */ +::view-transition-old(root) { + animation: 90ms cubic-bezier(0.4, 0, 1, 1) both fade-out; +} +::view-transition-new(root) { + animation: 210ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in; +} +``` + +## Advanced Control + +Use `onViewTransitionCreated` to skip transitions or customize behavior based on the navigation context. + +```ts +withViewTransitions({ + onViewTransitionCreated: ({transition, from, to}) => { + // Skip animation for specific routes + if (to.url === '/no-animation') { + transition.skipTransition(); + } + }, +}); +``` + +## Best Practices + +- **Global Styles**: Always define transition animations in `styles.css` to avoid view encapsulation issues. +- **View Transition Names**: Assign unique `view-transition-name` to elements that should transition smoothly across routes (e.g., a header image). diff --git a/.agents/skills/angular-developer/references/route-guards.md b/.agents/skills/angular-developer/references/route-guards.md new file mode 100644 index 000000000..9169d5431 --- /dev/null +++ b/.agents/skills/angular-developer/references/route-guards.md @@ -0,0 +1,52 @@ +# Route Guards + +Route guards control whether a user can navigate to or leave a route. + +## Types of Guards + +- **`CanActivate`**: Can the user access this route? (e.g., Auth check). +- **`CanActivateChild`**: Can the user access children of this route? +- **`CanDeactivate`**: Can the user leave this route? (e.g., Unsaved changes). +- **`CanMatch`**: Should this route even be considered for matching? (e.g., Feature flags). If it returns `false`, the router continues checking other routes. + +## Creating a Guard + +Guards are typically functional since Angular 15. + +```ts +export const authGuard: CanActivateFn = (route, state) => { + const authService = inject(AuthService); + const router = inject(Router); + + if (authService.isLoggedIn()) { + return true; + } + + // Redirect to login + return router.parseUrl('/login'); +}; +``` + +## Applying Guards + +Add them to the route configuration as an array. They execute in order. + +```ts +{ + path: 'admin', + component: Admin, + canActivate: [authGuard], + canActivateChild: [adminChildGuard], + canDeactivate: [unsavedChangesGuard] +} +``` + +## Return Values + +- `boolean`: `true` to allow, `false` to block. +- `UrlTree` or `RedirectCommand`: Redirect to a different route. +- `Observable` or `Promise`: Resolves to the above types. + +## Security Note + +**Client-side guards are NOT a substitute for server-side security.** Always verify permissions on the server. diff --git a/.agents/skills/angular-developer/references/router-lifecycle.md b/.agents/skills/angular-developer/references/router-lifecycle.md new file mode 100644 index 000000000..be9aeb6ec --- /dev/null +++ b/.agents/skills/angular-developer/references/router-lifecycle.md @@ -0,0 +1,45 @@ +# Router Lifecycle and Events + +Angular Router emits events through the `Router.events` observable, allowing you to track the navigation lifecycle from start to finish. + +## Common Router Events (Chronological) + +1. **`NavigationStart`**: Navigation begins. +2. **`RoutesRecognized`**: Router matches the URL to a route. +3. **`GuardsCheckStart` / `End`**: Evaluation of `canActivate`, `canMatch`, etc. +4. **`ResolveStart` / `End`**: Data resolution phase (fetching data via resolvers). +5. **`NavigationEnd`**: Navigation completed successfully. +6. **`NavigationCancel`**: Navigation canceled (e.g., guard returned `false`). +7. **`NavigationError`**: Navigation failed (e.g., error in resolver). + +## Subscribing to Events + +Inject the `Router` and filter the `events` observable. + +```ts +import {Router, NavigationStart, NavigationEnd} from '@angular/router'; + +export class MyService { + private router = inject(Router); + + constructor() { + this.router.events.pipe(filter((e) => e instanceof NavigationEnd)).subscribe((event) => { + console.log('Navigated to:', event.url); + }); + } +} +``` + +## Debugging + +Enable detailed console logging of all routing events during application bootstrap. + +```ts +provideRouter(routes, withDebugTracing()); +``` + +## Common Use Cases + +- **Loading Indicators**: Show a spinner when `NavigationStart` fires and hide it on `NavigationEnd`/`Cancel`/`Error`. +- **Analytics**: Track page views by listening for `NavigationEnd`. +- **Scroll Management**: Respond to `Scroll` events for custom scroll behavior. diff --git a/.agents/skills/angular-developer/references/router-testing.md b/.agents/skills/angular-developer/references/router-testing.md new file mode 100644 index 000000000..fc18d9cad --- /dev/null +++ b/.agents/skills/angular-developer/references/router-testing.md @@ -0,0 +1,87 @@ +# Testing with the RouterTestingHarness + +When testing components that involve routing, it is crucial **not to mock the Router or related services**. Instead, use the `RouterTestingHarness`, which provides a robust and reliable way to test routing logic in an environment that closely mirrors a real application. + +Using the harness ensures you are testing the actual router configuration, guards, and resolvers, leading to more meaningful tests. + +## Setting Up for Router Testing + +The `RouterTestingHarness` is the primary tool for testing routing scenarios. You also need to provide your test routes using the `provideRouter` function in your `TestBed` configuration. + +### Example Setup + +```ts +import {TestBed} from '@angular/core/testing'; +import {provideRouter} from '@angular/router'; +import {RouterTestingHarness} from '@angular/router/testing'; +import {Dashboard} from './dashboard.component'; +import {HeroDetail} from './hero-detail.component'; + +describe('Dashboard Component Routing', () => { + let harness: RouterTestingHarness; + + beforeEach(async () => { + // 1. Configure TestBed with test routes + await TestBed.configureTestingModule({ + providers: [ + // Use provideRouter with your test-specific routes + provideRouter([ + {path: '', component: Dashboard}, + {path: 'heroes/:id', component: HeroDetail}, + ]), + ], + }).compileComponents(); + + // 2. Create the RouterTestingHarness + harness = await RouterTestingHarness.create(); + }); +}); +``` + +### Key Concepts + +1. **`provideRouter([...])`**: Provide a test-specific routing configuration. This should include the routes necessary for the component-under-test to function correctly. +2. **`RouterTestingHarness.create()`**: Asynchronously creates and initializes the harness and performs an initial navigation to the root URL (`/`). + +## Writing Router Tests + +Once the harness is created, you can use it to drive navigation and make assertions on the state of the router and the activated components. + +### Example: Testing Navigation + +```ts +it('should navigate to a hero detail when a hero is selected', async () => { + // 1. Navigate to the initial component and get its instance + const dashboard = await harness.navigateByUrl('/', Dashboard); + + // Suppose the dashboard has a method to select a hero + const heroToSelect = {id: 42, name: 'Test Hero'}; + dashboard.selectHero(heroToSelect); + + // Wait for stability after the action that triggers navigation + await harness.fixture.whenStable(); + + // 2. Assert on the URL + expect(harness.router.url).toEqual('/heroes/42'); + + // 3. Get the activated component after navigation + const heroDetail = await harness.getHarness(HeroDetail); + + // 4. Assert on the state of the new component + expect(await heroDetail.componentInstance.hero.name).toBe('Test Hero'); +}); + +it('should get the activated component directly', async () => { + // Navigate and get the component instance in one step + const dashboardInstance = await harness.navigateByUrl('/', Dashboard); + + expect(dashboardInstance).toBeInstanceOf(Dashboard); +}); +``` + +### Best Practices + +- **Navigate with the Harness:** Always use `harness.navigateByUrl()` to simulate navigation. This method returns a promise that resolves with the instance of the activated component. +- **Access the Router State:** Use `harness.router` to access the live router instance and assert on its state (e.g., `harness.router.url`). +- **Get Activated Components:** Use `harness.getHarness(ComponentType)` to get an instance of a component harness for the currently activated routed component, or `harness.routeDebugElement` to get the `DebugElement`. +- **Wait for Stability:** After performing an action that causes navigation, always `await harness.fixture.whenStable()` to ensure the routing is complete before making assertions. diff --git a/.agents/skills/angular-developer/references/show-routes-with-outlets.md b/.agents/skills/angular-developer/references/show-routes-with-outlets.md new file mode 100644 index 000000000..af43f014f --- /dev/null +++ b/.agents/skills/angular-developer/references/show-routes-with-outlets.md @@ -0,0 +1,68 @@ +# Show Routes with Outlets + +The `RouterOutlet` directive is a placeholder where Angular renders the component for the current URL. + +## Basic Usage + +Include `` in your template. Angular inserts the routed component as a sibling immediately following the outlet. + +```html + + + +``` + +## Nested Outlets + +Child routes require their own `` within the parent component's template. + +```ts +// Parent component template +

    Settings

    + +``` + +## Named Outlets (Secondary Routes) + +Pages can have multiple outlets. Assign a `name` to an outlet to target it specifically. The default name is `'primary'`. + +```html + + + + +``` + +Define the `outlet` in the route config: + +```ts +{ + path: 'chat', + component: Chat, + outlet: 'sidebar' +} +``` + +## Outlet Lifecycle Events + +`RouterOutlet` emits events when components are changed: + +- `activate`: New component instantiated. +- `deactivate`: Component destroyed. +- `attach` / `detach`: Used with `RouteReuseStrategy`. + +```html + +``` + +## Passing Data via `routerOutletData` + +You can pass contextual data to the routed component using the `routerOutletData` input. The component accesses this via the `ROUTER_OUTLET_DATA` injection token as a signal. + +```ts +// In Parent + + +// In Routed Component +outletData = inject(ROUTER_OUTLET_DATA) as Signal<{ theme: string }>; +``` diff --git a/.agents/skills/angular-developer/references/signal-forms.md b/.agents/skills/angular-developer/references/signal-forms.md new file mode 100644 index 000000000..992fab10c --- /dev/null +++ b/.agents/skills/angular-developer/references/signal-forms.md @@ -0,0 +1,897 @@ +# Signal Forms + +Signal Forms are the recommended approach for handling forms in modern Angular applications (v21+). They provide a reactive, type-safe, and model-driven way to manage form state using Angular Signals. + +**CRITICAL**: You MUST use Angular's new Signal Forms API for all form-related functionality. Do NOT use null as a value or type of any fields. + +## Imports + +You can import the following from `@angular/forms/signals`: + +```ts +import { + form, + FormField, + submit, + // Rules for field state + disabled, + hidden, + readonly, + debounce, + // Schema helpers + applyWhen, + applyEach, + schema, + // Custom validation + validate, + validateHttp, + validateStandardSchema, + // Metadata + metadata, +} from '@angular/forms/signals'; +``` + +## Creating a Form + +Use the `form()` function with a Signal model. The structure of the form is derived directly from the model. + +```ts +import {Component, signal} from '@angular/core'; +import {form, FormField} from '@angular/forms/signals'; + +@Component({ + // ... + imports: [FormField], +}) +export class Example { + // 1. Define your model with initial values (avoid undefined) + userModel = signal({ + name: '', // CRITICAL: NEVER use null or undefined as initial values + email: '', + age: 0, // Use 0 for numbers, NOT null + address: { + street: '', + city: '', + }, + hobbies: [] as string[], // Use [] for arrays, NOT null + }); + + // WRONG - DO NOT DO THIS: + // badModel = signal({ + // name: null, // ERROR: use '' instead + // age: null, // ERROR: use 0 instead + // items: null // ERROR: use [] instead + // }); + + // 2. Create the form + userForm = form(this.userModel); +} +``` + +## Validation + +Import validators from `@angular/forms/signals`. + +```ts +import {required, email, min, max, minLength, maxLength, pattern} from '@angular/forms/signals'; +``` + +Use them in the schema function passed to `form()`: + +```ts +userForm = form(this.userModel, (schemaPath) => { + // Required + required(schemaPath.name, {message: 'Name is required'}); + + // Conditional required. + required(schemaPath.name, { + when({valueOf}) { + return valueOf(schemaPath.age) > 10; + }, + }); + // when is only available for required + // Do NOT do this: pattern(p.name, /xxx/, {when /* ERROR */) + + // Email + email(schemaPath.email, {message: 'Invalid email'}); + + // Min/Max for numbers + min(schemaPath.age, 18); + max(schemaPath.age, 100); + + // MinLength/MaxLength for strings/arrays + minLength(schemaPath.password, 8); + maxLength(schemaPath.description, 500); + + // Pattern (Regex) + pattern(schemaPath.zipCode, /^\d{5}$/); +}); +``` + +## FieldState vs FormField: The Parental Requirement + +It's important to understand the difference between **FormField** (the structure) and **FieldState** (the actual data/signals). + +**RULE**: You must **CALL** a field as a function to access its state signals (valid, touched, dirty, hidden, etc.). + +```ts +// f is a FormField (structural) +const f = form(signal({cat: {name: 'pirojok-the-cat', age: 5}})); + +f.cat.name; // FormField: You can't get flags from here! +f.cat.name.touched(); // ERROR: touched() does not exist on FormField + +f.cat.name(); // FieldState: Calling it gives you access to signals +f.cat.name().touched(); // VALID: Accessing the signal +f.cat().name.touched(); // ERROR: f.cat() is state, it doesn't have children! +``` + +Similarly in a template: + +```html + +@if (bookingForm.hotelDetails.hidden()) { ... } + + +@if (bookingForm.hotelDetails().hidden()) { ... } +``` + +## Disabled / Readonly / Hidden + +Control field status using rules in the schema. + +```ts +import {disabled, readonly, hidden} from '@angular/forms/signals'; + +userForm = form(this.userModel, (schemaPath) => { + // Conditionally disabled + disabled(schemaPath.password, ({valueOf}) => !valueOf(schemaPath.createAccount)); + + // Conditionally hidden (does NOT remove from model, just marks as hidden) + hidden(schemaPath.shippingAddress, ({valueOf}) => valueOf(schemaPath.sameAsBilling)); + + // Readonly + readonly(schemaPath.username); +}); +``` + +## Binding + +Import `FormField` and use the `[formField]` directive. + +```ts +import {FormField} from '@angular/forms/signals'; +``` + +All props on state, such as `disabled`, `hidden`, `readonly` and `name` are bound automatically. +Do _NOT_ bind the `name` field. + +**CRITICAL: FORBIDDEN ATTRIBUTES** +When using `[formField]`, you MUST NOT set the following attributes in the template (either static or bound): + +- `min`, `max` (Use validators in the schema instead) +- `value`, `[value]`, `[attr.value]` (Already handled by `[formField]`) +- `[attr.min]`, `[attr.max]` +- `[disabled]`, `[readonly]` (Already handled by `[formField]`) + +Do NOT do this: `` or ``. + +```html + + + + + + + + + + + +``` + +## Reactive Forms + +**Do NOT import** `FormControl`, `FormGroup`, `FormArray`, or `FormBuilder` from `@angular/forms`. Signal Forms replace these concepts entirely. +Signal forms does NOT have a builder. + +## Accessing State + +Each field in the form is a function that returns its state. + +```ts +// Access the field by calling it +const emailState = this.userForm.email(); + +// Value (WritableSignal) +const value = this.userForm().value(); + +// Validation State (Signals) +const isValid = this.userForm().valid(); +const isInvalid = this.userForm().invalid(); +const errors = this.userForm().errors(); // Array of errors +const isPending = this.userForm().pending(); // Async validation pending + +// Interaction State (Signals) +const isTouched = this.userForm().touched(); +const isDirty = this.userForm().dirty(); + +// Availability State (Signals) +const isDisabled = this.userForm().disabled(); +const isHidden = this.userForm().hidden(); +const isReadonly = this.userForm().readonly(); +``` + +IMPORTANT!: Make sure to call the field to get it state. + +```ts +form().invalid() +form.field().dirty() +form.field.subfield().touched() +form.a.b.c.d().value() +form.address.ssn().pending() +form().reset() + +// The only exception is length: +form.children.length +form.length // NOTE: no parenthesis! +form.client.addresses.length // No "()" + +@for (income of form.addresses; track $index) {/**/} +``` + +## Submitting + +Use the `submit()` function. It automatically marks all fields as touched before running the action. + +**CRITICAL**: The callback to `submit()` MUST be `async` and MUST return a Promise. + +```ts +import { submit } from '@angular/forms/signals'; + +// CORRECT - async callback +onSubmit() { + submit(this.userForm, async () => { + // This only runs if the form is valid + await this.apiService.save(this.userModel()); + console.log('Saved!'); + }); +} + +// WRONG - missing async keyword +onSubmit() { + submit(this.userForm, () => { // ERROR: must be async + console.log('Saved!'); + }); +} +``` + +## Handling Errors + +`field().errors()` returns the errors array of ValidationError: + +```ts +interface ValidationError { + readonly kind: string; + readonly message?: string; +} +``` + +Do _NOT_ return null from validators. +When there are no errors, return undefined + +### Context + +Functions passed to rules like `validate()`, `disabled()`, `applyWhen` take a context object. It is **CRITICAL** to understand its structure: + +```ts +validate( + schemaPath.username, + ({ + value, // Signal: Writable current value of the field + fieldTree, // FieldTree: Sub-fields (if it's a group/array) + state, // FieldState: Access flags like state.valid(), state.dirty() + valueOf, // (path) => T: Read values of OTHER fields (tracking dependencies), e.g. valueOf(schemaPath.password) + stateOf, // (path) => FieldState: Access state (valid/dirty) of OTHER fields, e.g. stateOf(schemaPath.password).valid() + pathKeys, // Signal: Path from root to this field + }) => { + // WRONG: if (touched()) ... (touched is not in context) + // RIGHT: if (state.touched()) ... + + if (value() === 'admin') { + return {kind: 'reserved', message: 'Username admin is reserved'}; + } + }, +); +``` + +### IMPORTANT: Paths are NOT Signals + +Inside the `form()` callback, `schemaPath` and its children (e.g., `schemaPath.user.name`) are **NOT** signals and are **NOT** callable. + +```ts +// WRONG - This will throw an error: +applyWhen(p.ssn, () => p.ssn().touched(), (ssnField) => { ... }); + +// RIGHT - Use stateOf() to get the state of a path: +applyWhen(p.ssn, ({ stateOf }) => stateOf(p.ssn).touched(), (ssnField) => { ... }); + +// RIGHT - Use valueOf() to get the value of a path: +applyWhen(p.ssn, ({ valueOf }) => valueOf(p.ssn) !== '', (ssnField) => { ... }); +``` + +### Multiple Items + +- Use `applyEach` for applying rules per item. +- **CRITICAL**: `applyEach` callback takes ONLY ONE argument (the item path), NOT two: + +```ts +// CORRECT - single argument +applyEach(s.items, (item) => { + required(item.name); +}); + +// WRONG - do NOT pass index +applyEach(s.items, (item, index) => { + // ERROR: callback takes 1 argument + required(item.name); +}); +``` + +- In the template use `@for` to iterate over the items. +- To remove an item from an array, just remove appropriate item from the array in the data. +- **`select` binding**: You CAN bind to `` (string[]) | Use checkboxes for array fields | +| **readonly attribute** | `` | Use `readonly()` rule in schema | +| **min/max attributes** | `` | Use `min()` and `max()` rules in schema | +| **value binding** | `` | Do NOT use `[value]` with `[formField]` | +| **when option** | `pattern(p.x, /.../, {when: ...})` | `when` only works with `required()` | +| **Submit callback** | `submit(form, () => { ... })` | `submit(form, async () => { ... })` | +| **Async params** | `params: s.field` | `params: ({ value }) => value()` | +| **Async onError** | Omitting `onError` | `onError` is REQUIRED in `validateAsync` | +| **resource() API** | `request: signal` | `params: signal` | +| **applyEach args** | `applyEach(s.items, (item, index) => ...)` | `applyEach(s.items, (item) => ...)` | +| **Nested @for** | `$parent.$index` | Use `let outerIndex = $index` | +| **FormState import** | `import { FormState }` | `FormState` does not exist, use `FieldState` | +| **Null in model** | `signal({ name: null })` | `signal({ name: '' })` or `signal({ age: 0 })` | +| **Validate syntax** | `validate(s.field, { value } => ...)` | `validate(s.field, ({ value }) => ...)` | +| **Checkbox Array** | `[formField]="form.tags"` (string[]) | Checkboxes ONLY bind to `boolean` | + +## Big Form Example + +### `src/app/app.ts` + +```ts +import {Component, signal, ChangeDetectionStrategy} from '@angular/core'; +import { + form, + FormField, + submit, + required, + email, + min, + hidden, + applyEach, + validate, +} from '@angular/forms/signals'; + +@Component({ + selector: 'app-root', + standalone: true, + imports: [FormField], + templateUrl: './app.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class App { + model = signal({ + personalInfo: { + firstName: '', + lastName: '', + email: '', + age: 0, + }, + tripDetails: { + destination: 'Mars', + launchDate: '', + }, + package: { + tier: 'economy', + extras: [] as string[], + }, + companions: [] as Array<{name: string; relation: string}>, + }); + + bookingForm = form(this.model, (s) => { + required(s.personalInfo.firstName, {message: 'First name is required'}); + required(s.personalInfo.lastName, {message: 'Last name is required'}); + required(s.personalInfo.email, {message: 'Email is required'}); + email(s.personalInfo.email, {message: 'Invalid email address'}); + required(s.personalInfo.age, {message: 'Age is required'}); + min(s.personalInfo.age, 18, {message: 'Must be at least 18'}); + + required(s.tripDetails.destination); + required(s.tripDetails.launchDate); + validate(s.tripDetails.launchDate, ({value}) => { + const date = new Date(value()); + if (isNaN(date.getTime())) return undefined; + const today = new Date(); + if (date < today) { + return {kind: 'pastData', message: 'Launch date must be in the future'}; + } + return undefined; + }); + + // valueOf is used to access values of other fields in rules + hidden(s.package.extras, ({valueOf}) => valueOf(s.package.tier) === 'economy'); + + applyEach(s.companions, (companion) => { + required(companion.name, {message: 'Companion name required'}); + required(companion.relation, {message: 'Relation required'}); + }); + }); + + addCompanion() { + this.model.update((m) => ({ + ...m, + companions: [...m.companions, {name: '', relation: ''}], + })); + } + + removeCompanion(index: number) { + this.model.update((m) => ({ + ...m, + companions: m.companions.filter((_, i) => i !== index), + })); + } + + onSubmit() { + // CRITICAL: submit callback MUST be async + submit(this.bookingForm, async () => { + console.log('Booking Confirmed:', this.model()); + // If you need to do async work: + // await this.apiService.save(this.model()); + }); + } +} +``` + +### `src/app/app.html` + +```html +
    +

    Interstellar Booking

    + +
    +

    Personal Info

    + + + + + + + + +
    + +
    +

    Trip Details

    + + + + +
    + +
    +

    Package

    + + + + + + @if (!bookingForm.package.extras().hidden()) { +
    +

    Extras

    + + +
    + } +
    + +
    +

    Companions

    + + + @for (companion of bookingForm.companions; track $index) { +
    + + @if (companion.name().touched() && companion.name().errors().length) { + {{ companion.name().errors()[0].message }} + } + + + @if (companion.relation().touched() && companion.relation().errors().length) { + {{ companion.relation().errors()[0].message }} + } + + +
    + } +
    + + +
    +``` + +## Recovering from Build Errors + +If you encounter build errors, here are the most common fixes: + +### `Property 'value' does not exist on type 'FieldTree'` + +**Problem**: Accessing `.value()` directly on a field without calling it first. + +```ts +// WRONG +const val = this.form.field.value(); +// RIGHT +const val = this.form.field().value(); +``` + +### `Property 'set' does not exist on type 'FieldTree'` + +**Problem**: Trying to set values on the form tree. Signal Forms are model-driven. + +```ts +// WRONG +this.form.address.street.set('Main St'); +// RIGHT - update the model signal instead +this.model.update((m) => ({...m, address: {...m.address, street: 'Main St'}})); +``` + +### `Type 'string[]' is not assignable to type 'string'` + +**Problem**: Binding `[formField]` to an array field with a single-value ` + ... + + + + +``` + +### `NG8022: Setting the 'readonly/min/max/value' attribute is not allowed` + +**Problem**: Conflict between HTML attributes and `[formField]` directive. + +```html + + + + + +min(s.age, 18); max(s.age, 99); // Then just: + +``` + +### `TS2322: Type 'string[]' is not assignable to type 'boolean'` + +**Problem**: Binding a checkbox to an array field instead of a boolean field. + +```html + + + + + + + +model = signal({ hasWifi: false, hasGym: false }); + +``` + +### `'when' does not exist in type` for pattern/email/min/max + +**Problem**: Using `when` option with validators other than `required`. + +```ts +// WRONG - when only works with required +pattern(s.ssn, /^\d{3}-\d{2}-\d{4}$/, {when: isJoint}); + +// RIGHT - use applyWhen for conditional non-required validators +applyWhen(s.ssn, isJoint, (ssnPath) => { + pattern(ssnPath, /^\d{3}-\d{2}-\d{4}$/); +}); +``` + +### `Expected 3 arguments, but got 2` for applyWhen + +**Problem**: Missing the path argument in `applyWhen`. + +```ts +// WRONG +applyWhen(isJoint, () => { ... }); + +// RIGHT - applyWhen(path, condition, schemaFn) +applyWhen(s.spouse, ({valueOf}) => valueOf(s.status) === 'joint', (spousePath) => { + required(spousePath.name); +}); +``` + +### `Module has no exported member 'FormState'` + +**Problem**: Importing a non-existent type. + +```ts +// WRONG +import {FormState} from '@angular/forms/signals'; + +// FormState does not exist. If you need type access, the form +// instance provides all necessary state through field().valid(), etc. +``` + +### `No pipe found with name 'number'` / `'json'` / `'date'` + +**Problem**: Using pipes in templates. + +```html + +{{ totalPrice() | number:'1.2-2' }} + + +totalPriceFormatted = computed(() => this.totalPrice().toFixed(2)); + +{{ totalPriceFormatted() }} +``` + +### `$parent.$index` in nested @for loops + +**Problem**: Angular doesn't have `$parent`. + +```html + +@for (item of items; track $index) { @for (sub of item.subs; track $index) { + +} } + + +@for (item of items; track $index; let outerIdx = $index) { @for (sub of item.subs; track $index) { + +} } +``` diff --git a/.agents/skills/angular-developer/references/signals-overview.md b/.agents/skills/angular-developer/references/signals-overview.md new file mode 100644 index 000000000..07fb6ed9d --- /dev/null +++ b/.agents/skills/angular-developer/references/signals-overview.md @@ -0,0 +1,94 @@ +# Angular Signals Overview + +Signals are the foundation of reactivity in modern Angular applications. A **signal** is a wrapper around a value that notifies interested consumers when that value changes. + +## Writable Signals (`signal`) + +Use `signal()` to create state that can be directly updated. + +```ts +import {signal} from '@angular/core'; + +// Create a writable signal +const count = signal(0); + +// Read the value (always requires calling the getter function) +console.log(count()); + +// Update the value directly +count.set(3); + +// Update based on the previous value +count.update((value) => value + 1); +``` + +### Exposing as Readonly + +When exposing state from a service, it is a best practice to expose a readonly version to prevent external mutation. + +```ts +private readonly _count = signal(0); +// Consumers can read this, but cannot call .set() or .update() +readonly count = this._count.asReadonly(); +``` + +## Computed Signals (`computed`) + +Use `computed()` to create read-only signals that derive their value from other signals. + +- **Lazily Evaluated**: The derivation function doesn't run until the computed signal is read. +- **Memoized**: The result is cached. It only recalculates when one of the signals it depends on changes. +- **Dynamic Dependencies**: Only the signals _actually read_ during the derivation are tracked. + +```ts +import {signal, computed} from '@angular/core'; + +const count = signal(0); +const doubleCount = computed(() => count() * 2); + +// doubleCount automatically updates when count changes. +``` + +## Reactive Contexts + +A **reactive context** is a runtime state where Angular monitors signal reads to establish a dependency. + +Angular automatically enters a reactive context when evaluating: + +- `computed` signals +- `effect` callbacks +- `linkedSignal` computations +- Component templates + +### Untracked Reads (`untracked`) + +If you need to read a signal inside a reactive context _without_ creating a dependency (so that the context doesn't re-run when the signal changes), use `untracked()`. + +```ts +import {effect, untracked} from '@angular/core'; + +effect(() => { + // This effect only runs when currentUser changes. + // It does NOT run when counter changes, even though counter is read here. + console.log(`User: ${currentUser()}, Count: ${untracked(counter)}`); +}); +``` + +### Async Operations in Reactive Contexts + +The reactive context is only active for **synchronous** code. Signal reads after an `await` will not be tracked. **Always read signals before asynchronous boundaries.** + +```ts +// ❌ INCORRECT: theme() is not tracked because it is read after await +effect(async () => { + const data = await fetchUserData(); + console.log(theme()); +}); + +// ✅ CORRECT: Read the signal before the await +effect(async () => { + const currentTheme = theme(); + const data = await fetchUserData(); + console.log(currentTheme); +}); +``` diff --git a/.agents/skills/angular-developer/references/tailwind-css.md b/.agents/skills/angular-developer/references/tailwind-css.md new file mode 100644 index 000000000..9139fcdca --- /dev/null +++ b/.agents/skills/angular-developer/references/tailwind-css.md @@ -0,0 +1,69 @@ +# Using Tailwind CSS with Angular + +Tailwind CSS is a utility-first CSS framework that integrates seamlessly with Angular. + +**CRITICAL AGENT GUIDANCE: ALWAYS focus on Tailwind CSS v4 practices. DO NOT revert to old Tailwind v3 patterns (like creating `tailwind.config.js` with `@tailwind` directives) as this will break the application build. Modern Angular projects use Tailwind v4.** + +## Automated Setup (Recommended) + +The easiest way to add Tailwind CSS to an Angular project is via the Angular CLI: + +```shell +ng add tailwindcss +``` + +This will automatically: + +1. Install `tailwindcss` and peer dependencies. +2. Configure the project to use Tailwind CSS. +3. Add the proper import to your global styles. + +## Manual Setup (Tailwind v4) + +If setting up manually, use the following Tailwind v4 pattern: + +### 1. Install Dependencies + +Install Tailwind CSS and PostCSS: + +```shell +npm install tailwindcss @tailwindcss/postcss postcss +``` + +### 2. Configure PostCSS + +Create a `.postcssrc.json` file in the project root: + +```json +{ + "plugins": { + "@tailwindcss/postcss": {} + } +} +``` + +_(Do NOT create a `tailwind.config.js` file! Configuration in v4 is handled through CSS variables)._ + +### 3. Import Tailwind CSS + +In your global styles file (e.g., `src/styles.css`), add the standard v4 import: + +```css +@import 'tailwindcss'; +``` + +_(If using SCSS, use `@use 'tailwindcss';` instead)._ + +### 4. Use Utility Classes + +You can now use Tailwind classes directly in your component templates: + +```html +

    Hello world!

    +``` + +## Summary for AI Agents + +- **Do not use `@tailwind base; @tailwind components; @tailwind utilities;`**. Use `@import 'tailwindcss';`. +- **Do not create `tailwind.config.js`**. Configuration is managed directly in CSS via theme variables or using PostCSS configurations. +- Stick strictly to v4 syntax and workflows. diff --git a/.agents/skills/angular-developer/references/template-driven-forms.md b/.agents/skills/angular-developer/references/template-driven-forms.md new file mode 100644 index 000000000..1907eeb99 --- /dev/null +++ b/.agents/skills/angular-developer/references/template-driven-forms.md @@ -0,0 +1,114 @@ +# Template-Driven Forms + +Template-driven forms use two-way data binding (`[(ngModel)]`) to update the data model in the component as changes are made in the template and vice versa. They are ideal for simple forms and use directives in the HTML template to manage form state and validation. + +## Core Directives + +Template-driven forms rely on the `FormsModule` which provides these key directives: + +- `NgModel`: Reconciles value changes in the form element with the data model (`[(ngModel)]`). +- `NgForm`: Automatically creates a top-level `FormGroup` bound to the `
    ` tag. +- `NgModelGroup`: Creates a nested `FormGroup` bound to a DOM element. + +## Setup + +First, import `FormsModule` into your component or module. + +```ts +import {Component} from '@angular/core'; +import {FormsModule} from '@angular/forms'; + +@Component({ + selector: 'app-user-form', + imports: [FormsModule], + templateUrl: './user-form.component.html', +}) +export class UserForm { + user = {name: '', role: 'Guest'}; + + onSubmit() { + console.log('Form submitted!', this.user); + } +} +``` + +## Building the Form Template + +### Two-Way Binding with `[(ngModel)]` + +Use `[(ngModel)]` on input elements. **Every element using `[(ngModel)]` MUST have a `name` attribute.** Angular uses the `name` attribute to register the control with the parent `NgForm`. + +```html + + +
    + + +
    + + +
    + + +
    + + + +
    +``` + +## Form and Control State + +Angular automatically applies CSS classes to controls and forms based on their state: + +| State | Class if True | Class if False | +| :------------- | :-------------------------------- | :------------- | +| Visited | `ng-touched` | `ng-untouched` | +| Value Changed | `ng-dirty` | `ng-pristine` | +| Value is Valid | `ng-valid` | `ng-invalid` | +| Form Submitted | `ng-submitted` (on `
    ` only) | - | + +You can use these classes to provide visual feedback in your CSS: + +```css +.ng-valid[required], +.ng-valid.required { + border-left: 5px solid #42a948; /* green */ +} +.ng-invalid:not(form) { + border-left: 5px solid #a94442; /* red */ +} +``` + +## Validation and Error Messages + +To display error messages conditionally, export the `ngModel` directive to a template reference variable (e.g., `#nameCtrl="ngModel"`). + +```html + + + +@if (nameCtrl.invalid && (nameCtrl.dirty || nameCtrl.touched)) { +
    + @if (nameCtrl.errors?.['required']) { +
    Name is required.
    + } +
    +} +``` + +## Submitting the Form + +1. Use the `(ngSubmit)` event on the `` element. +2. Bind the submit button's disabled state to the overall form validity using the `NgForm` template reference variable (e.g., `[disabled]="!userForm.form.valid"`). + +## Resetting the Form + +To programmatically reset the form to its pristine state (clearing values and validation flags), use the `reset()` method on the `NgForm` instance. + +```html + +``` diff --git a/.agents/skills/angular-developer/references/testing-fundamentals.md b/.agents/skills/angular-developer/references/testing-fundamentals.md new file mode 100644 index 000000000..d58ada3a5 --- /dev/null +++ b/.agents/skills/angular-developer/references/testing-fundamentals.md @@ -0,0 +1,66 @@ +# Testing Fundamentals + +This guide covers the fundamental principles and practices for writing unit tests in this repository, which uses Vitest as the test runner. + +## Core Philosophy: Zoneless & Async-First + +This project follows a modern, zoneless testing approach. State changes schedule updates asynchronously, and tests must account for this. + +**Do NOT** use `fixture.detectChanges()` to manually trigger updates. +**ALWAYS** use the "Act, Wait, Assert" pattern: + +1. **Act:** Update state or perform an action (e.g., set a component input, click a button). +2. **Wait:** Use `await fixture.whenStable()` to allow the framework to process the scheduled update and render the changes. +3. **Assert:** Verify the outcome. + +### Basic Test Structure Example + +```ts +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {MyComponent} from './my.component'; + +describe('MyComponent', () => { + let component: MyComponent; + let fixture: ComponentFixture; + let h1: HTMLElement; + + beforeEach(async () => { + // 1. Configure the test module + await TestBed.configureTestingModule({ + imports: [MyComponent], + }).compileComponents(); + + // 2. Create the component fixture + fixture = TestBed.createComponent(MyComponent); + component = fixture.componentInstance; + h1 = fixture.nativeElement.querySelector('h1'); + }); + + it('should display the default title', async () => { + // ACT: (Implicit) Component is created with default state. + // WAIT for initial data binding. + await fixture.whenStable(); + // ASSERT the initial state. + expect(h1.textContent).toContain('Default Title'); + }); + + it('should display a different title after a change', async () => { + // ACT: Change the component's title property. + component.title.set('New Test Title'); + + // WAIT for the asynchronous update to complete. + await fixture.whenStable(); + + // ASSERT the DOM has been updated. + expect(h1.textContent).toContain('New Test Title'); + }); +}); +``` + +## TestBed and ComponentFixture + +- **`TestBed`**: The primary utility for creating a test-specific Angular module. Use `TestBed.configureTestingModule({...})` in your `beforeEach` to declare components, provide services, and set up imports needed for your test. +- **`ComponentFixture`**: A handle on the created component instance and its environment. + - `fixture.componentInstance`: Access the component's class instance. + - `fixture.nativeElement`: Access the component's root DOM element. + - `fixture.debugElement`: An Angular-specific wrapper around the `nativeElement` that provides safer, platform-agnostic ways to query the DOM (e.g., `debugElement.query(By.css('p'))`). diff --git a/.claude/skills/angular-developer b/.claude/skills/angular-developer new file mode 120000 index 000000000..f6e9d2af3 --- /dev/null +++ b/.claude/skills/angular-developer @@ -0,0 +1 @@ +../../.agents/skills/angular-developer \ No newline at end of file diff --git a/.prettierignore b/.prettierignore index 27e509393..5524c76f6 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,4 @@ +/.agents/skills/angular-developer /.nx/cache /.nx/workspace-data /apps/client/src/polyfills.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 536453dbf..460f54d3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Added `angular-developer` skills + ### Changed - Upgraded `stripe` from version `20.4.1` to `21.0.1` diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 000000000..a15c56b59 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "skills": { + "angular-developer": { + "source": "angular/skills", + "sourceType": "github", + "skillPath": "angular-developer/SKILL.md", + "computedHash": "28eb592b92e5a24c4e3a1c0229a854069f0b8c49bed7b8d2bf6b852812dbe214" + } + } +} From a04099b12d23e2cbf71274aadd2868ff6826cd32 Mon Sep 17 00:00:00 2001 From: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com> Date: Sat, 2 May 2026 15:02:43 +0700 Subject: [PATCH 030/132] Task/improve type safety in fire page component (#6807) * Improve type safety --- .../portfolio/fire/fire-page.component.ts | 121 ++++++++++-------- .../app/pages/portfolio/fire/fire-page.html | 2 +- 2 files changed, 66 insertions(+), 57 deletions(-) diff --git a/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts b/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts index 7b5b10c7d..4ab7d6392 100644 --- a/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts +++ b/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts @@ -16,7 +16,9 @@ import { CommonModule, NgStyle } from '@angular/common'; import { ChangeDetectorRef, Component, + computed, DestroyRef, + inject, OnInit } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -42,33 +44,40 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; templateUrl: './fire-page.html' }) export class GfFirePageComponent implements OnInit { - public deviceType: string; - public fireWealth: FireWealth; - public hasImpersonationId: boolean; - public hasPermissionToUpdateUserSettings: boolean; - public isLoading = false; - public projectedTotalAmount: number; - public retirementDate: Date; - public safeWithdrawalRateControl = new FormControl(undefined); - public safeWithdrawalRateOptions = [0.025, 0.03, 0.035, 0.04, 0.045]; - public user: User; - public withdrawalRatePerMonth: Big; - public withdrawalRatePerMonthProjected: Big; - public withdrawalRatePerYear: Big; - public withdrawalRatePerYearProjected: Big; - - public constructor( - private changeDetectorRef: ChangeDetectorRef, - private dataService: DataService, - private destroyRef: DestroyRef, - private deviceService: DeviceDetectorService, - private impersonationStorageService: ImpersonationStorageService, - private userService: UserService - ) {} + protected readonly deviceType = computed( + () => this.deviceDetectorService.deviceInfo().deviceType + ); + + protected fireWealth: FireWealth; + protected hasImpersonationId: boolean; + protected hasPermissionToUpdateUserSettings: boolean; + protected isLoading = false; + protected retirementDate: Date; + protected readonly safeWithdrawalRateControl = new FormControl< + number | undefined + >(undefined); + protected readonly safeWithdrawalRateOptions = [ + 0.025, 0.03, 0.035, 0.04, 0.045 + ] as const; + protected user: User; + protected withdrawalRatePerMonth: Big; + protected withdrawalRatePerMonthProjected: Big; + protected withdrawalRatePerYear: Big; + protected withdrawalRatePerYearProjected: Big; + + private projectedTotalAmount: number; + + private readonly changeDetectorRef = inject(ChangeDetectorRef); + private readonly dataService = inject(DataService); + private readonly destroyRef = inject(DestroyRef); + private readonly deviceDetectorService = inject(DeviceDetectorService); + private readonly impersonationStorageService = inject( + ImpersonationStorageService + ); + private readonly userService = inject(UserService); public ngOnInit() { this.isLoading = true; - this.deviceType = this.deviceService.getDeviceInfo().deviceType; this.dataService .fetchPortfolioDetails() @@ -76,7 +85,7 @@ export class GfFirePageComponent implements OnInit { .subscribe(({ summary }) => { this.fireWealth = { today: { - valueInBaseCurrency: summary.fireWealth + valueInBaseCurrency: summary?.fireWealth ? summary.fireWealth.today.valueInBaseCurrency : 0 } @@ -104,7 +113,7 @@ export class GfFirePageComponent implements OnInit { this.safeWithdrawalRateControl.valueChanges .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((value) => { - this.onSafeWithdrawalRateChange(Number(value)); + this.updateSafeWithdrawalRate(Number(value)); }); this.userService.stateChanged @@ -133,7 +142,7 @@ export class GfFirePageComponent implements OnInit { }); } - public onAnnualInterestRateChange(annualInterestRate: number) { + protected onAnnualInterestRateChange(annualInterestRate: number) { this.dataService .putUserSetting({ annualInterestRate }) .pipe(takeUntilDestroyed(this.destroyRef)) @@ -149,7 +158,7 @@ export class GfFirePageComponent implements OnInit { }); } - public onCalculationComplete({ + protected onCalculationComplete({ projectedTotalAmount, retirementDate }: FireCalculationCompleteEvent) { @@ -161,11 +170,11 @@ export class GfFirePageComponent implements OnInit { this.isLoading = false; } - public onRetirementDateChange(retirementDate: Date) { + protected onProjectedTotalAmountChange(projectedTotalAmount: number) { this.dataService .putUserSetting({ - retirementDate: retirementDate.toISOString(), - projectedTotalAmount: null + projectedTotalAmount, + retirementDate: undefined }) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { @@ -180,9 +189,12 @@ export class GfFirePageComponent implements OnInit { }); } - public onSafeWithdrawalRateChange(safeWithdrawalRate: number) { + protected onRetirementDateChange(retirementDate: Date) { this.dataService - .putUserSetting({ safeWithdrawalRate }) + .putUserSetting({ + projectedTotalAmount: undefined, + retirementDate: retirementDate.toISOString() + }) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { this.userService @@ -191,15 +203,12 @@ export class GfFirePageComponent implements OnInit { .subscribe((user) => { this.user = user; - this.calculateWithdrawalRates(); - this.calculateWithdrawalRatesProjected(); - this.changeDetectorRef.markForCheck(); }); }); } - public onSavingsRateChange(savingsRate: number) { + protected onSavingsRateChange(savingsRate: number) { this.dataService .putUserSetting({ savingsRate }) .pipe(takeUntilDestroyed(this.destroyRef)) @@ -215,25 +224,6 @@ export class GfFirePageComponent implements OnInit { }); } - public onProjectedTotalAmountChange(projectedTotalAmount: number) { - this.dataService - .putUserSetting({ - projectedTotalAmount, - retirementDate: null - }) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => { - this.userService - .get(true) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((user) => { - this.user = user; - - this.changeDetectorRef.markForCheck(); - }); - }); - } - private calculateWithdrawalRates() { if (this.fireWealth && this.user?.settings?.safeWithdrawalRate) { this.withdrawalRatePerYear = new Big( @@ -258,4 +248,23 @@ export class GfFirePageComponent implements OnInit { this.withdrawalRatePerYearProjected.div(12); } } + + private updateSafeWithdrawalRate(safeWithdrawalRate: number) { + this.dataService + .putUserSetting({ safeWithdrawalRate }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.userService + .get(true) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((user) => { + this.user = user; + + this.calculateWithdrawalRates(); + this.calculateWithdrawalRatesProjected(); + + this.changeDetectorRef.markForCheck(); + }); + }); + } } diff --git a/apps/client/src/app/pages/portfolio/fire/fire-page.html b/apps/client/src/app/pages/portfolio/fire/fire-page.html index b441b2563..cabd7fcb4 100644 --- a/apps/client/src/app/pages/portfolio/fire/fire-page.html +++ b/apps/client/src/app/pages/portfolio/fire/fire-page.html @@ -13,7 +13,7 @@ [annualInterestRate]="user?.settings?.annualInterestRate" [colorScheme]="user?.settings?.colorScheme" [currency]="user?.settings?.baseCurrency" - [deviceType]="deviceType" + [deviceType]="deviceType()" [fireWealth]="fireWealth?.today.valueInBaseCurrency" [hasPermissionToUpdateUserSettings]=" !hasImpersonationId && hasPermissionToUpdateUserSettings From 2af973bedba2f7b429aa16c80fd8aa772db77e6b Mon Sep 17 00:00:00 2001 From: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com> Date: Sat, 2 May 2026 22:26:34 +0700 Subject: [PATCH 031/132] Bugfix/reactivate auto calculate for retirement date and projected total amount in FIRE page (#6831) * fix(lib): update fields to accept null * fix(client): set to null --- .../app/pages/portfolio/fire/fire-page.component.ts | 4 ++-- libs/common/src/lib/dtos/update-user-setting.dto.ts | 12 ++++++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts b/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts index 4ab7d6392..dc0a1d776 100644 --- a/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts +++ b/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts @@ -174,7 +174,7 @@ export class GfFirePageComponent implements OnInit { this.dataService .putUserSetting({ projectedTotalAmount, - retirementDate: undefined + retirementDate: null }) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { @@ -192,7 +192,7 @@ export class GfFirePageComponent implements OnInit { protected onRetirementDateChange(retirementDate: Date) { this.dataService .putUserSetting({ - projectedTotalAmount: undefined, + projectedTotalAmount: null, retirementDate: retirementDate.toISOString() }) .pipe(takeUntilDestroyed(this.destroyRef)) diff --git a/libs/common/src/lib/dtos/update-user-setting.dto.ts b/libs/common/src/lib/dtos/update-user-setting.dto.ts index cf7dff7e8..f2781835c 100644 --- a/libs/common/src/lib/dtos/update-user-setting.dto.ts +++ b/libs/common/src/lib/dtos/update-user-setting.dto.ts @@ -96,13 +96,21 @@ export class UpdateUserSettingDto { @IsOptional() locale?: string; + /** + * The target financial amount the user aims to reach before retiring. + * Can be explicitly set to null to clear the value and calculate it dynamically. + */ @IsNumber() @IsOptional() - projectedTotalAmount?: number; + projectedTotalAmount?: number | null; + /** + * The target date when the user plans to retire. + * Can be explicitly set to null to clear the value and calculate it dynamically. + */ @IsISO8601() @IsOptional() - retirementDate?: string; + retirementDate?: string | null; @IsNumber() @IsOptional() From fff9c1667064f176b68530a79f9bb64dcbfec708 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 2 May 2026 19:02:06 +0200 Subject: [PATCH 032/132] Task/harmonize unit styling in value component (#6832) * Harmonize unit styling * Update changelog --- CHANGELOG.md | 1 + libs/ui/src/lib/value/value.component.html | 52 +++++++--------------- 2 files changed, 16 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 460f54d3f..045f92c2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Harmonized the unit styling in the value component - Upgraded `stripe` from version `20.4.1` to `21.0.1` ## 3.1.0 - 2026-04-29 diff --git a/libs/ui/src/lib/value/value.component.html b/libs/ui/src/lib/value/value.component.html index c4d6532a7..48e9c02e7 100644 --- a/libs/ui/src/lib/value/value.component.html +++ b/libs/ui/src/lib/value/value.component.html @@ -38,45 +38,23 @@
    -
    } } - @if (isPercent) { -
    - @if (value === null) { - *****% - } @else { - {{ formattedValue }}% - } -
    - } @else { -
    - @if (value === null) { - ***** - } @else { - {{ formattedValue }} - } -
    - } - @if (unit) { - @if (size === 'medium') { - - {{ unit }} - +
    + @if (value === null) { + ***** } @else { -
    - {{ unit }} -
    + {{ formattedValue }} } +
    + @if (isPercent || unit) { +
    + {{ isPercent ? '%' : unit }} +
    } } @if (isString) { From 4a81658f60226617dc6b8cf85d86e6c603885799 Mon Sep 17 00:00:00 2001 From: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com> Date: Sun, 3 May 2026 14:43:06 +0700 Subject: [PATCH 033/132] Bugfix/improve typography for client app and charts (#6833) * Improve typography for client app and charts * Update changelog --- CHANGELOG.md | 4 ++++ apps/client/src/app/app.component.ts | 4 ++++ apps/client/src/styles/theme.scss | 22 +++++++++++++++------- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 045f92c2c..e0b601427 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Harmonized the unit styling in the value component - Upgraded `stripe` from version `20.4.1` to `21.0.1` +### Fixed + +- Resolved an issue where charts and components defaulted to _Roboto_ instead of the preconfigured _Inter_ font family + ## 3.1.0 - 2026-04-29 ### Added diff --git a/apps/client/src/app/app.component.ts b/apps/client/src/app/app.component.ts index 0a9be3a75..e1967970d 100644 --- a/apps/client/src/app/app.component.ts +++ b/apps/client/src/app/app.component.ts @@ -28,6 +28,7 @@ import { RouterOutlet } from '@angular/router'; import { DataSource } from '@prisma/client'; +import { Chart } from 'chart.js'; import { addIcons } from 'ionicons'; import { openOutline } from 'ionicons/icons'; import { DeviceDetectorService } from 'ngx-device-detector'; @@ -255,6 +256,9 @@ export class GfAppComponent implements OnInit { this.toggleTheme(isDarkTheme); + // Default chart styles + Chart.defaults.font.family = getCssVariable('--font-family-sans-serif'); + window.matchMedia('(prefers-color-scheme: dark)').addListener((event) => { if (!this.user?.settings.colorScheme) { this.toggleTheme(event.matches); diff --git a/apps/client/src/styles/theme.scss b/apps/client/src/styles/theme.scss index dfde47c77..296270e7f 100644 --- a/apps/client/src/styles/theme.scss +++ b/apps/client/src/styles/theme.scss @@ -139,20 +139,28 @@ $_tertiary: map.merge(map.get($_palettes, tertiary), $_rest); @include mat.elevation-classes(); @include mat.table-density(-1); -// $gf-typography: mat.m2-define-typography-config(); +$gf-typography: ( + // Font families + brand-family: var(--font-family-sans-serif), + plain-family: var(--font-family-sans-serif), + // Font weights + bold-weight: 700, + medium-weight: 500, + regular-weight: 400 +); .theme-light { $gf-theme-default: mat.define-theme( ( color: ( primary: $_primary, - theme-type: light, - tertiary: $_tertiary + tertiary: $_tertiary, + theme-type: light ), density: ( scale: -3 ), - // typography: $gf-typography + typography: $gf-typography ) ); @@ -229,13 +237,13 @@ $_tertiary: map.merge(map.get($_palettes, tertiary), $_rest); ( color: ( primary: $_primary, - theme-type: dark, - tertiary: $_tertiary + tertiary: $_tertiary, + theme-type: dark ), density: ( scale: -3 ), - // typography: $gf-typography + typography: $gf-typography ) ); From 19abb6cbea84a592586ac5b875b4a6308a943f9d Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 3 May 2026 10:00:46 +0200 Subject: [PATCH 034/132] Bugfix/handling of string inputs in form of asset profile dialog (#6834) * Fix string inputs handling * Update changelog --- CHANGELOG.md | 1 + .../asset-profile-dialog/asset-profile-dialog.component.ts | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0b601427..534477a89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Resolved a validation error with an empty URL in the asset profile details dialog of the admin control panel - Resolved an issue where charts and components defaulted to _Roboto_ instead of the preconfigured _Inter_ font family ## 3.1.0 - 2026-04-29 diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts index 76f6cb9c2..f0721c87f 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts @@ -575,13 +575,13 @@ export class GfAssetProfileDialogComponent implements OnInit { assetClass: this.assetProfileForm.controls.assetClass.value ?? undefined, assetSubClass: this.assetProfileForm.controls.assetSubClass.value ?? undefined, - comment: this.assetProfileForm.controls.comment.value ?? undefined, + comment: this.assetProfileForm.controls.comment.value || undefined, currency: this.assetProfileForm.controls.currency.value ?? undefined, isActive: isBoolean(this.assetProfileForm.controls.isActive.value) ? this.assetProfileForm.controls.isActive.value : undefined, - name: this.assetProfileForm.controls.name.value ?? undefined, - url: this.assetProfileForm.controls.url.value ?? undefined + name: this.assetProfileForm.controls.name.value || undefined, + url: this.assetProfileForm.controls.url.value || undefined }; try { From 03f10c35dfefda1cf1c1486df2dde4d291051910 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 3 May 2026 10:04:28 +0200 Subject: [PATCH 035/132] Release 3.2.0 (#6836) --- CHANGELOG.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 534477a89..43119ff4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## Unreleased +## 3.2.0 - 2026-05-03 ### Added diff --git a/package-lock.json b/package-lock.json index 3e35d09fc..f507d8eb8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ghostfolio", - "version": "3.1.0", + "version": "3.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ghostfolio", - "version": "3.1.0", + "version": "3.2.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/package.json b/package.json index 99b774bfb..54c1f048f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostfolio", - "version": "3.1.0", + "version": "3.2.0", "homepage": "https://ghostfol.io", "license": "AGPL-3.0", "repository": "https://github.com/ghostfolio/ghostfolio", From fab06f8d42bc1c70082ae1bd93999e251c269cff Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Tue, 5 May 2026 08:00:04 +0200 Subject: [PATCH 036/132] Task/extend referral brokers (#6841) Add Revolut --- apps/client/src/app/pages/pricing/pricing-page.component.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/client/src/app/pages/pricing/pricing-page.component.ts b/apps/client/src/app/pages/pricing/pricing-page.component.ts index 0b76fcd1d..80a62d693 100644 --- a/apps/client/src/app/pages/pricing/pricing-page.component.ts +++ b/apps/client/src/app/pages/pricing/pricing-page.component.ts @@ -78,6 +78,7 @@ export class GfPricingPageComponent implements OnInit { 'Interactive Brokers', 'Mintos', 'Monefit SmartSaver', + 'Revolut', 'Swissquote', 'VIAC', 'Zak' From 5113b221238375d8af3a39171a202aca035b7ecc Mon Sep 17 00:00:00 2001 From: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com> Date: Tue, 5 May 2026 22:24:28 +0700 Subject: [PATCH 037/132] Bugfix/remove bottom border in mobile navbar container (#6839) * Remove border * Update changelog --- CHANGELOG.md | 6 ++++++ apps/client/src/styles.scss | 12 ++++++------ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43119ff4c..50ec99a82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Fixed + +- Fixed a visual regression in the bottom navigation bar on mobile + ## 3.2.0 - 2026-05-03 ### Added diff --git a/apps/client/src/styles.scss b/apps/client/src/styles.scss index 0b7f6f029..4138d9d9b 100644 --- a/apps/client/src/styles.scss +++ b/apps/client/src/styles.scss @@ -520,15 +520,15 @@ ngx-skeleton-loader { padding: 2rem 0; } + @include mat.tabs-overrides( + ( + divider-height: 0 + ) + ); + @media (min-width: 576px) { flex-direction: row-reverse; - @include mat.tabs-overrides( - ( - divider-height: 0 - ) - ); - .mat-mdc-tab-header { background-color: rgba(var(--palette-foreground-base), 0.02); padding: 2rem 0; From 3602562aacce36dbdd0007da95bb01c0862e7bd6 Mon Sep 17 00:00:00 2001 From: David Requeno <108202767+DavidReque@users.noreply.github.com> Date: Tue, 5 May 2026 10:34:57 -0600 Subject: [PATCH 038/132] Task/migrate Angular components from NgClass to class bindings (#6843) * Migrate components from NgClass to class bindings * Update changelog --- CHANGELOG.md | 4 ++ .../account-detail-dialog.component.ts | 2 - .../account-detail-dialog.html | 2 +- .../app/components/admin-jobs/admin-jobs.html | 2 +- .../admin-market-data/admin-market-data.html | 6 +- .../components/admin-users/admin-users.html | 14 ++-- .../components/header/header.component.html | 72 +++++++++---------- .../app/components/header/header.component.ts | 2 - .../holding-detail-dialog.component.ts | 2 - .../holding-detail-dialog.html | 18 +++-- .../home-holdings/home-holdings.component.ts | 2 - .../home-holdings/home-holdings.html | 2 +- .../home-overview/home-overview.component.ts | 2 - .../home-overview/home-overview.html | 7 +- .../portfolio-performance.component.html | 4 +- .../portfolio-performance.component.ts | 3 +- .../portfolio-summary.component.html | 11 ++- .../portfolio-summary.component.ts | 3 +- .../rule-settings-dialog.component.ts | 2 - .../rule-settings-dialog.html | 10 +-- .../app/components/rule/rule.component.html | 2 +- .../src/app/components/rule/rule.component.ts | 9 +-- .../overview/about-overview-page.component.ts | 3 +- .../about/overview/about-overview-page.html | 2 +- ...eate-or-update-account-dialog.component.ts | 3 +- .../create-or-update-account-dialog.html | 2 +- .../pages/landing/landing-page.component.ts | 2 - .../src/app/pages/landing/landing-page.html | 6 +- ...ate-or-update-activity-dialog.component.ts | 2 - .../create-or-update-activity-dialog.html | 55 +++++++------- .../allocations/allocations-page.component.ts | 4 +- .../allocations/allocations-page.html | 4 +- .../app/pages/portfolio/fire/fire-page.html | 2 +- .../portfolio/x-ray/x-ray-page.component.html | 7 +- .../portfolio/x-ray/x-ray-page.component.ts | 2 - .../pages/pricing/pricing-page.component.ts | 2 - .../src/app/pages/pricing/pricing-page.html | 4 +- .../app/pages/public/public-page.component.ts | 2 - .../src/app/pages/public/public-page.html | 2 +- .../accounts-table.component.html | 6 +- .../accounts-table.component.ts | 2 - .../activities-table.component.html | 8 +-- .../activity-type.component.html | 2 +- .../activity-type/activity-type.component.ts | 3 +- .../lib/benchmark/benchmark.component.html | 6 +- .../src/lib/benchmark/benchmark.component.ts | 2 - .../dialog-header.component.html | 2 +- .../dialog-header/dialog-header.component.ts | 3 +- .../entity-logo/entity-logo.component.html | 2 +- .../lib/entity-logo/entity-logo.component.ts | 2 - ...storical-market-data-editor.component.html | 2 +- libs/ui/src/lib/logo/logo.component.html | 2 +- libs/ui/src/lib/logo/logo.component.ts | 2 - .../membership-card.component.html | 4 +- .../membership-card.component.ts | 9 +-- libs/ui/src/lib/toggle/toggle.component.html | 4 +- libs/ui/src/lib/toggle/toggle.component.ts | 3 +- .../top-holdings/top-holdings.component.html | 6 +- .../top-holdings/top-holdings.component.ts | 2 - .../trend-indicator.component.html | 4 +- libs/ui/src/lib/value/value.component.html | 6 +- 61 files changed, 141 insertions(+), 226 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50ec99a82..b65a289e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Changed + +- Migrated various components from `NgClass` to class bindings + ### Fixed - Fixed a visual regression in the bottom navigation bar on mobile diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts index f51315e91..05f13f804 100644 --- a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts @@ -20,7 +20,6 @@ import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table'; import { DataService } from '@ghostfolio/ui/services'; import { GfValueComponent } from '@ghostfolio/ui/value'; -import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, ChangeDetectorRef, @@ -57,7 +56,6 @@ import { AccountDetailDialogParams } from './interfaces/interfaces'; changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'd-flex flex-column h-100' }, imports: [ - CommonModule, GfAccountBalancesComponent, GfActivitiesTableComponent, GfDialogFooterComponent, diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html index e63fb9fc1..73412d49d 100644 --- a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html @@ -91,8 +91,8 @@ diff --git a/apps/client/src/app/components/admin-jobs/admin-jobs.html b/apps/client/src/app/components/admin-jobs/admin-jobs.html index 1570ce82b..d57704b86 100644 --- a/apps/client/src/app/components/admin-jobs/admin-jobs.html +++ b/apps/client/src/app/components/admin-jobs/admin-jobs.html @@ -172,7 +172,7 @@ } @else if (element.state === 'failed') { {{ element.id }} {{ `${(element.id | slice: 0 : 5)}...` }} @if (element.subscription?.expiresAt) { @@ -273,11 +269,11 @@
    @if (user) { -
    +
    @@ -18,7 +18,7 @@ class="d-none d-sm-block rounded" i18n mat-button - [ngClass]="{ + [class]="{ 'font-weight-bold': currentRoute === internalRoutes.home.path || currentRoute === internalRoutes.zen.path, @@ -35,7 +35,7 @@ class="d-none d-sm-block rounded" i18n mat-button - [ngClass]="{ + [class]="{ 'font-weight-bold': currentRoute === internalRoutes.portfolio.path, 'text-decoration-underline': currentRoute === internalRoutes.portfolio.path @@ -49,7 +49,7 @@ class="d-none d-sm-block rounded" i18n mat-button - [ngClass]="{ + [class]="{ 'font-weight-bold': currentRoute === internalRoutes.accounts.path, 'text-decoration-underline': currentRoute === internalRoutes.accounts.path @@ -64,7 +64,7 @@ class="d-none d-sm-block rounded" i18n mat-button - [ngClass]="{ + [class]="{ 'font-weight-bold': currentRoute === internalRoutes.adminControl.path, 'text-decoration-underline': @@ -80,7 +80,7 @@ class="d-none d-sm-block rounded" i18n mat-button - [ngClass]="{ + [class]="{ 'font-weight-bold': currentRoute === routeResources, 'text-decoration-underline': currentRoute === routeResources }" @@ -95,7 +95,7 @@ Overview @@ -251,9 +250,9 @@ class="d-flex d-sm-none" i18n mat-menu-item - [ngClass]="{ - 'font-weight-bold': currentRoute === internalRoutes.portfolio.path - }" + [class.font-weight-bold]=" + currentRoute === internalRoutes.portfolio.path + " [routerLink]="routerLinkPortfolio" >Portfolio @@ -261,18 +260,18 @@ class="d-flex d-sm-none" i18n mat-menu-item - [ngClass]="{ - 'font-weight-bold': currentRoute === internalRoutes.accounts.path - }" + [class.font-weight-bold]=" + currentRoute === internalRoutes.accounts.path + " [routerLink]="routerLinkAccounts" >Accounts My Ghostfolio @@ -281,10 +280,9 @@ class="d-flex d-sm-none" i18n mat-menu-item - [ngClass]="{ - 'font-weight-bold': - currentRoute === internalRoutes.adminControl.path - }" + [class.font-weight-bold]=" + currentRoute === internalRoutes.adminControl.path + " [routerLink]="routerLinkAdminControl" >Admin Control @@ -294,9 +292,7 @@ class="d-flex d-sm-none" i18n mat-menu-item - [ngClass]="{ - 'font-weight-bold': currentRoute === routeResources - }" + [class.font-weight-bold]="currentRoute === routeResources" [routerLink]="routerLinkResources" >Resources @@ -306,7 +302,7 @@ @@ -321,7 +317,7 @@ class="d-flex d-sm-none" i18n mat-menu-item - [ngClass]="{ 'font-weight-bold': currentRoute === routeAbout }" + [class.font-weight-bold]="currentRoute === routeAbout" [routerLink]="routerLinkAbout" >About Ghostfolio @@ -332,11 +328,11 @@ } @if (user === null) { -
    +
    } -
    + diff --git a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts b/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts index 04c0f507c..dd3ce248e 100644 --- a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts +++ b/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts @@ -10,7 +10,6 @@ import { import { NotificationService } from '@ghostfolio/ui/notifications'; import { GfValueComponent } from '@ghostfolio/ui/value'; -import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -28,7 +27,7 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, - imports: [CommonModule, GfValueComponent, IonIcon, NgxSkeletonLoaderModule], + imports: [GfValueComponent, IonIcon, NgxSkeletonLoaderModule], selector: 'gf-portfolio-performance', styleUrls: ['./portfolio-performance.component.scss'], templateUrl: './portfolio-performance.component.html' diff --git a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html index 0e26a49a8..e14479425 100644 --- a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html +++ b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html @@ -181,12 +181,11 @@
    +
    Threshold Min:
    -
    +
    Threshold Max: -
    +
    Platform
    -
    +
    Account @@ -103,18 +104,16 @@
    -
    +
    Update Cash Balance
    Name, symbol or ISIN @@ -127,9 +126,9 @@
    Name @@ -173,13 +172,12 @@
    Quantity @@ -188,7 +186,7 @@
    @@ -215,7 +213,7 @@
    @for (currency of currencies; track currency) { @@ -247,12 +245,11 @@
    Fee @@ -260,7 +257,7 @@
    {{ activityForm.get('currencyOfUnitPrice')?.value }}
    @@ -280,7 +277,7 @@
    Asset Class @@ -299,7 +296,7 @@
    Asset Sub Class diff --git a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts index ebfd336d3..a7f8cd2ec 100644 --- a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts +++ b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts @@ -21,7 +21,6 @@ import { GfTopHoldingsComponent } from '@ghostfolio/ui/top-holdings'; import { GfValueComponent } from '@ghostfolio/ui/value'; import { GfWorldMapChartComponent } from '@ghostfolio/ui/world-map-chart'; -import { NgClass } from '@angular/common'; import { ChangeDetectorRef, Component, @@ -51,8 +50,7 @@ import { DeviceDetectorService } from 'ngx-device-detector'; GfValueComponent, GfWorldMapChartComponent, MatCardModule, - MatProgressBarModule, - NgClass + MatProgressBarModule ], selector: 'gf-allocations-page', styleUrls: ['./allocations-page.scss'], diff --git a/apps/client/src/app/pages/portfolio/allocations/allocations-page.html b/apps/client/src/app/pages/portfolio/allocations/allocations-page.html index 33431ce5d..a1000189b 100644 --- a/apps/client/src/app/pages/portfolio/allocations/allocations-page.html +++ b/apps/client/src/app/pages/portfolio/allocations/allocations-page.html @@ -319,9 +319,7 @@
    diff --git a/apps/client/src/app/pages/portfolio/fire/fire-page.html b/apps/client/src/app/pages/portfolio/fire/fire-page.html index cabd7fcb4..76ad6cbf6 100644 --- a/apps/client/src/app/pages/portfolio/fire/fire-page.html +++ b/apps/client/src/app/pages/portfolio/fire/fire-page.html @@ -62,7 +62,7 @@ />
    } @else { -
    +
    If you retire today, you would be able to withdraw @for (category of categories; track category.key) { -
    +

    {{ category.name }} @if (user?.subscription?.type === 'Basic') { diff --git a/apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.ts b/apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.ts index 650522896..0d37bab9b 100644 --- a/apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.ts +++ b/apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.ts @@ -12,7 +12,6 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { DataService } from '@ghostfolio/ui/services'; -import { NgClass } from '@angular/common'; import { ChangeDetectorRef, Component, DestroyRef } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { IonIcon } from '@ionic/angular/standalone'; @@ -29,7 +28,6 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; GfPremiumIndicatorComponent, GfRulesComponent, IonIcon, - NgClass, NgxSkeletonLoaderModule ], selector: 'gf-x-ray-page', diff --git a/apps/client/src/app/pages/pricing/pricing-page.component.ts b/apps/client/src/app/pages/pricing/pricing-page.component.ts index 80a62d693..a1fe0c0b5 100644 --- a/apps/client/src/app/pages/pricing/pricing-page.component.ts +++ b/apps/client/src/app/pages/pricing/pricing-page.component.ts @@ -7,7 +7,6 @@ import { NotificationService } from '@ghostfolio/ui/notifications'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { DataService } from '@ghostfolio/ui/services'; -import { CommonModule } from '@angular/common'; import { ChangeDetectorRef, Component, @@ -35,7 +34,6 @@ import { catchError } from 'rxjs/operators'; @Component({ host: { class: 'page' }, imports: [ - CommonModule, GfPremiumIndicatorComponent, IonIcon, MatButtonModule, diff --git a/apps/client/src/app/pages/pricing/pricing-page.html b/apps/client/src/app/pages/pricing/pricing-page.html index d755cd303..b951baa98 100644 --- a/apps/client/src/app/pages/pricing/pricing-page.html +++ b/apps/client/src/app/pages/pricing/pricing-page.html @@ -103,7 +103,7 @@
    @@ -169,7 +169,7 @@ @if (label) { diff --git a/apps/client/src/app/pages/public/public-page.component.ts b/apps/client/src/app/pages/public/public-page.component.ts index ab2bd2b4a..c12a2d5de 100644 --- a/apps/client/src/app/pages/public/public-page.component.ts +++ b/apps/client/src/app/pages/public/public-page.component.ts @@ -14,7 +14,6 @@ import { DataService } from '@ghostfolio/ui/services'; import { GfValueComponent } from '@ghostfolio/ui/value'; import { GfWorldMapChartComponent } from '@ghostfolio/ui/world-map-chart'; -import { CommonModule } from '@angular/common'; import { HttpErrorResponse } from '@angular/common/http'; import { ChangeDetectorRef, @@ -40,7 +39,6 @@ import { catchError } from 'rxjs/operators'; @Component({ host: { class: 'page' }, imports: [ - CommonModule, GfActivitiesTableComponent, GfHoldingsTableComponent, GfPortfolioProportionChartComponent, diff --git a/apps/client/src/app/pages/public/public-page.html b/apps/client/src/app/pages/public/public-page.html index 44696cd7c..57bc1e95f 100644 --- a/apps/client/src/app/pages/public/public-page.html +++ b/apps/client/src/app/pages/public/public-page.html @@ -202,7 +202,7 @@

    } -
    +
    diff --git a/libs/ui/src/lib/accounts-table/accounts-table.component.html b/libs/ui/src/lib/accounts-table/accounts-table.component.html index 15f5bb21f..1ba0ecc56 100644 --- a/libs/ui/src/lib/accounts-table/accounts-table.component.html +++ b/libs/ui/src/lib/accounts-table/accounts-table.component.html @@ -340,15 +340,13 @@
    diff --git a/libs/ui/src/lib/accounts-table/accounts-table.component.ts b/libs/ui/src/lib/accounts-table/accounts-table.component.ts index aa104f795..1526ffea5 100644 --- a/libs/ui/src/lib/accounts-table/accounts-table.component.ts +++ b/libs/ui/src/lib/accounts-table/accounts-table.component.ts @@ -4,7 +4,6 @@ import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo'; import { NotificationService } from '@ghostfolio/ui/notifications'; import { GfValueComponent } from '@ghostfolio/ui/value'; -import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -37,7 +36,6 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, imports: [ - CommonModule, GfEntityLogoComponent, GfValueComponent, IonIcon, diff --git a/libs/ui/src/lib/activities-table/activities-table.component.html b/libs/ui/src/lib/activities-table/activities-table.component.html index 5a4ae641c..ae5cf0384 100644 --- a/libs/ui/src/lib/activities-table/activities-table.component.html +++ b/libs/ui/src/lib/activities-table/activities-table.component.html @@ -516,9 +516,7 @@ @@ -536,11 +534,9 @@ } } diff --git a/libs/ui/src/lib/benchmark/benchmark.component.ts b/libs/ui/src/lib/benchmark/benchmark.component.ts index 5dbc4648b..73edfd482 100644 --- a/libs/ui/src/lib/benchmark/benchmark.component.ts +++ b/libs/ui/src/lib/benchmark/benchmark.component.ts @@ -11,7 +11,6 @@ import { } from '@ghostfolio/common/interfaces'; import { NotificationService } from '@ghostfolio/ui/notifications'; -import { CommonModule } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA, ChangeDetectionStrategy, @@ -46,7 +45,6 @@ import { BenchmarkDetailDialogParams } from './benchmark-detail-dialog/interface @Component({ changeDetection: ChangeDetectionStrategy.OnPush, imports: [ - CommonModule, GfTrendIndicatorComponent, GfValueComponent, IonIcon, 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 019d85a52..ed9fc41f4 100644 --- a/libs/ui/src/lib/dialog-header/dialog-header.component.html +++ b/libs/ui/src/lib/dialog-header/dialog-header.component.html @@ -1,7 +1,7 @@
    {{ title }} @if (deviceType !== 'mobile') { 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 ce3173d0e..4868b22b8 100644 --- a/libs/ui/src/lib/dialog-header/dialog-header.component.ts +++ b/libs/ui/src/lib/dialog-header/dialog-header.component.ts @@ -1,4 +1,3 @@ -import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -15,7 +14,7 @@ import { close } from 'ionicons/icons'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'justify-content-center' }, - imports: [CommonModule, IonIcon, MatButtonModule, MatDialogModule], + imports: [IonIcon, MatButtonModule, MatDialogModule], selector: 'gf-dialog-header', styleUrls: ['./dialog-header.component.scss'], templateUrl: './dialog-header.component.html' diff --git a/libs/ui/src/lib/entity-logo/entity-logo.component.html b/libs/ui/src/lib/entity-logo/entity-logo.component.html index 942ea23e5..d8aeba136 100644 --- a/libs/ui/src/lib/entity-logo/entity-logo.component.html +++ b/libs/ui/src/lib/entity-logo/entity-logo.component.html @@ -1,7 +1,7 @@ @if (src) { diff --git a/libs/ui/src/lib/entity-logo/entity-logo.component.ts b/libs/ui/src/lib/entity-logo/entity-logo.component.ts index 212e232be..ba7d64ae0 100644 --- a/libs/ui/src/lib/entity-logo/entity-logo.component.ts +++ b/libs/ui/src/lib/entity-logo/entity-logo.component.ts @@ -1,6 +1,5 @@ import { EntityLogoImageSourceService } from '@ghostfolio/ui/entity-logo/entity-logo-image-source.service'; -import { CommonModule } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA, ChangeDetectionStrategy, @@ -12,7 +11,6 @@ import { DataSource } from '@prisma/client'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, - imports: [CommonModule], schemas: [CUSTOM_ELEMENTS_SCHEMA], selector: 'gf-entity-logo', styleUrls: ['./entity-logo.component.scss'], diff --git a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.html b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.html index 91e3dd8d7..93fd8d97f 100644 --- a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.html +++ b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.html @@ -6,7 +6,7 @@ @for (day of days; track day) {
    + > @if (showLabel) { {{ label ?? 'Ghostfolio' }} } diff --git a/libs/ui/src/lib/logo/logo.component.ts b/libs/ui/src/lib/logo/logo.component.ts index 0b766429c..a5f6a58e4 100644 --- a/libs/ui/src/lib/logo/logo.component.ts +++ b/libs/ui/src/lib/logo/logo.component.ts @@ -1,4 +1,3 @@ -import { CommonModule } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA, ChangeDetectionStrategy, @@ -9,7 +8,6 @@ import { @Component({ changeDetection: ChangeDetectionStrategy.OnPush, - imports: [CommonModule], schemas: [CUSTOM_ELEMENTS_SCHEMA], selector: 'gf-logo', styleUrls: ['./logo.component.scss'], diff --git a/libs/ui/src/lib/membership-card/membership-card.component.html b/libs/ui/src/lib/membership-card/membership-card.component.html index 9faac0d3d..ab014901b 100644 --- a/libs/ui/src/lib/membership-card/membership-card.component.html +++ b/libs/ui/src/lib/membership-card/membership-card.component.html @@ -1,5 +1,5 @@
    -
    + diff --git a/libs/ui/src/lib/membership-card/membership-card.component.ts b/libs/ui/src/lib/membership-card/membership-card.component.ts index be223758d..96260ae6f 100644 --- a/libs/ui/src/lib/membership-card/membership-card.component.ts +++ b/libs/ui/src/lib/membership-card/membership-card.component.ts @@ -1,6 +1,5 @@ import { publicRoutes } from '@ghostfolio/common/routes/routes'; -import { CommonModule } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA, ChangeDetectionStrategy, @@ -19,13 +18,7 @@ import { GfLogoComponent } from '../logo'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ - CommonModule, - GfLogoComponent, - IonIcon, - MatButtonModule, - RouterModule - ], + imports: [GfLogoComponent, IonIcon, MatButtonModule, RouterModule], schemas: [CUSTOM_ELEMENTS_SCHEMA], selector: 'gf-membership-card', styleUrls: ['./membership-card.component.scss'], diff --git a/libs/ui/src/lib/toggle/toggle.component.html b/libs/ui/src/lib/toggle/toggle.component.html index d6271ef58..3fe7d00ba 100644 --- a/libs/ui/src/lib/toggle/toggle.component.html +++ b/libs/ui/src/lib/toggle/toggle.component.html @@ -6,12 +6,12 @@ @for (option of options(); track option) { {{ option.label }} diff --git a/libs/ui/src/lib/toggle/toggle.component.ts b/libs/ui/src/lib/toggle/toggle.component.ts index db7c45487..9324b3be7 100644 --- a/libs/ui/src/lib/toggle/toggle.component.ts +++ b/libs/ui/src/lib/toggle/toggle.component.ts @@ -1,6 +1,5 @@ import { ToggleOption } from '@ghostfolio/common/interfaces'; -import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -13,7 +12,7 @@ import { MatRadioModule } from '@angular/material/radio'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, - imports: [CommonModule, MatRadioModule, ReactiveFormsModule], + imports: [MatRadioModule, ReactiveFormsModule], selector: 'gf-toggle', styleUrls: ['./toggle.component.scss'], templateUrl: './toggle.component.html' diff --git a/libs/ui/src/lib/top-holdings/top-holdings.component.html b/libs/ui/src/lib/top-holdings/top-holdings.component.html index 06b27b97f..71e94ff4e 100644 --- a/libs/ui/src/lib/top-holdings/top-holdings.component.html +++ b/libs/ui/src/lib/top-holdings/top-holdings.component.html @@ -120,7 +120,7 @@ 0, expanded: element.expand ?? false }" @@ -150,7 +150,7 @@ *matRowDef="let row; columns: ['expandedDetail']" class="holding-detail" mat-row - [ngClass]="{ 'd-none': !row.parents?.length }" + [class.d-none]="!row.parents?.length" >
    diff --git a/libs/ui/src/lib/top-holdings/top-holdings.component.ts b/libs/ui/src/lib/top-holdings/top-holdings.component.ts index 7c9ae033f..a10d3c897 100644 --- a/libs/ui/src/lib/top-holdings/top-holdings.component.ts +++ b/libs/ui/src/lib/top-holdings/top-holdings.component.ts @@ -12,7 +12,6 @@ import { transition, trigger } from '@angular/animations'; -import { CommonModule } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA, ChangeDetectionStrategy, @@ -44,7 +43,6 @@ import { GfValueComponent } from '../value/value.component'; ], changeDetection: ChangeDetectionStrategy.OnPush, imports: [ - CommonModule, GfSymbolPipe, GfValueComponent, MatButtonModule, diff --git a/libs/ui/src/lib/trend-indicator/trend-indicator.component.html b/libs/ui/src/lib/trend-indicator/trend-indicator.component.html index b9f65a2ea..7cf6210f8 100644 --- a/libs/ui/src/lib/trend-indicator/trend-indicator.component.html +++ b/libs/ui/src/lib/trend-indicator/trend-indicator.component.html @@ -16,7 +16,7 @@ } @else if (value > -0.0005 && value < 0.0005) { @@ -29,7 +29,7 @@ } diff --git a/libs/ui/src/lib/value/value.component.html b/libs/ui/src/lib/value/value.component.html index 48e9c02e7..57949fbe2 100644 --- a/libs/ui/src/lib/value/value.component.html +++ b/libs/ui/src/lib/value/value.component.html @@ -27,7 +27,7 @@ @if (value || value === 0 || value === null) {
    @if (isNumber || value === null) { @if (colorizeSign && !useAbsoluteValue) { @@ -40,7 +40,7 @@ }
    Date: Wed, 6 May 2026 08:01:28 +0200 Subject: [PATCH 039/132] Task/upgrade Nx to version 22.7.1 (#6825) * Upgrade Nx to version 22.7.1 * Update changelog --- .gitignore | 2 + .prettierignore | 1 + CHANGELOG.md | 1 + package-lock.json | 1299 +++++++++++++++++++++------------------------ package.json | 22 +- 5 files changed, 624 insertions(+), 701 deletions(-) diff --git a/.gitignore b/.gitignore index 0f5a1651e..ab31ae269 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,8 @@ npm-debug.log .env.prod .github/instructions/nx.instructions.md .nx/cache +.nx/polygraph +.nx/self-healing .nx/workspace-data /.sass-cache /connect.lock diff --git a/.prettierignore b/.prettierignore index 5524c76f6..d04427c52 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,6 @@ /.agents/skills/angular-developer /.nx/cache +/.nx/self-healing /.nx/workspace-data /apps/client/src/polyfills.ts /dist diff --git a/CHANGELOG.md b/CHANGELOG.md index b65a289e3..27a02a0e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Migrated various components from `NgClass` to class bindings +- Upgraded `Nx` from version `22.6.5` to `22.7.1` ### Fixed diff --git a/package-lock.json b/package-lock.json index f507d8eb8..e730f44f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -113,16 +113,16 @@ "@eslint/js": "9.35.0", "@nestjs/schematics": "11.1.0", "@nestjs/testing": "11.1.19", - "@nx/angular": "22.6.5", - "@nx/eslint-plugin": "22.6.5", - "@nx/jest": "22.6.5", - "@nx/js": "22.6.5", - "@nx/module-federation": "22.6.5", - "@nx/nest": "22.6.5", - "@nx/node": "22.6.5", - "@nx/storybook": "22.6.5", - "@nx/web": "22.6.5", - "@nx/workspace": "22.6.5", + "@nx/angular": "22.7.1", + "@nx/eslint-plugin": "22.7.1", + "@nx/jest": "22.7.1", + "@nx/js": "22.7.1", + "@nx/module-federation": "22.7.1", + "@nx/nest": "22.7.1", + "@nx/node": "22.7.1", + "@nx/storybook": "22.7.1", + "@nx/web": "22.7.1", + "@nx/workspace": "22.7.1", "@schematics/angular": "21.2.6", "@storybook/addon-docs": "10.1.10", "@storybook/addon-themes": "10.1.10", @@ -149,7 +149,7 @@ "jest": "30.2.0", "jest-environment-jsdom": "30.2.0", "jest-preset-angular": "16.0.0", - "nx": "22.6.5", + "nx": "22.7.1", "prettier": "3.8.3", "prettier-plugin-organize-attributes": "1.0.0", "prisma": "7.7.0", @@ -3661,9 +3661,9 @@ } }, "node_modules/@colordx/core": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@colordx/core/-/core-5.2.0.tgz", - "integrity": "sha512-wifnqsGCXRh+lJdX4975nKEPJaSk7k8rMiA/VeGrS4tOTn06WZrow6cUA7wFJKPXfcqj0rXeH4BMgGoKZvBf7g==", + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/@colordx/core/-/core-5.4.3.tgz", + "integrity": "sha512-kIxYSfA5T8HXjav55UaaH/o/cKivF6jCCGIb8eqtcsfI46wsvlSiT8jMDyrl779qLec3c2c2oHBZo4oAhvbjrQ==", "dev": true, "license": "MIT" }, @@ -7393,13 +7393,13 @@ } }, "node_modules/@module-federation/bridge-react-webpack-plugin": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@module-federation/bridge-react-webpack-plugin/-/bridge-react-webpack-plugin-2.3.3.tgz", - "integrity": "sha512-W2jQ3Wuqree9Dq3UAx8jGbYtvHuuYgzrd2j9FP8Bt6NaynaNU1yYG86MBnAhZJPTltex0CguudR1dgFpYdvLUg==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@module-federation/bridge-react-webpack-plugin/-/bridge-react-webpack-plugin-2.4.0.tgz", + "integrity": "sha512-yxDv/FJoLiKo2eqIcEWvSnSpJgyYkCzJvNaFsQ2QE3rNv68IeAarlSzCo+d0QyQoPJnTETyHsOh1SSBazIzecw==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/sdk": "2.3.3", + "@module-federation/sdk": "2.4.0", "@types/semver": "7.5.8", "semver": "7.6.3" } @@ -7418,14 +7418,14 @@ } }, "node_modules/@module-federation/cli": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@module-federation/cli/-/cli-2.3.3.tgz", - "integrity": "sha512-g3f3aEruv07zK4VcUlAllswrp2ncA/jF0P0yoEWNRa9K7N+xNCfqcdzw2aVWOJ30qNMurhLWuyzYqfDIx0LpfQ==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@module-federation/cli/-/cli-2.4.0.tgz", + "integrity": "sha512-c46g9srroc2hDfrlHyd4Y404SLnw3v9t7Kqij+yK01Hx8C2FyZpyanTGUHVyrmzqp/0y3lPrWURUHkHfk/cJQA==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/dts-plugin": "2.3.3", - "@module-federation/sdk": "2.3.3", + "@module-federation/dts-plugin": "2.4.0", + "@module-federation/sdk": "2.4.0", "commander": "11.1.0", "jiti": "2.4.2" }, @@ -7436,40 +7436,17 @@ "node": ">=16.0.0" } }, - "node_modules/@module-federation/data-prefetch": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@module-federation/data-prefetch/-/data-prefetch-2.3.3.tgz", - "integrity": "sha512-ZM1QtyjbWYnhUizHFhwYjHGXlkZek3vzTpL35d5FkAhVrOU0u0Qv6zpZjdcCm0FJznqVsUQx1w0vagUyGWQf0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@module-federation/runtime": "2.3.3", - "@module-federation/sdk": "2.3.3" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } - } - }, "node_modules/@module-federation/dts-plugin": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@module-federation/dts-plugin/-/dts-plugin-2.3.3.tgz", - "integrity": "sha512-VNtURt+hvieNKCBleAqHKffLAU4clKmuuqLQIbvDkFbGe4bo7hUaq5DruTnJBWWDOizZx0OQrdQYPijCnBK6UQ==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@module-federation/dts-plugin/-/dts-plugin-2.4.0.tgz", + "integrity": "sha512-sa6v5ByyqMRHzpwDu0zc7s5mZ39EFIkG0jkRfZU09pzkrJEIy4uZ1Kt9SLysFB8RBMIAvAakAfqDlVWvf1lndg==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/error-codes": "2.3.3", - "@module-federation/managers": "2.3.3", - "@module-federation/sdk": "2.3.3", - "@module-federation/third-party-dts-extractor": "2.3.3", + "@module-federation/error-codes": "2.4.0", + "@module-federation/managers": "2.4.0", + "@module-federation/sdk": "2.4.0", + "@module-federation/third-party-dts-extractor": "2.4.0", "adm-zip": "0.5.10", "ansi-colors": "4.1.3", "isomorphic-ws": "5.0.0", @@ -7498,24 +7475,23 @@ } }, "node_modules/@module-federation/enhanced": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@module-federation/enhanced/-/enhanced-2.3.3.tgz", - "integrity": "sha512-BJSs56lqO9NI9aC+hVhg2CU/UwG1TphVl1b7WBx6Jv6DYUyVQbgXeQpgqYVsxYVRKYOl7eDZmjXl2eA/n1IP/Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@module-federation/bridge-react-webpack-plugin": "2.3.3", - "@module-federation/cli": "2.3.3", - "@module-federation/data-prefetch": "2.3.3", - "@module-federation/dts-plugin": "2.3.3", - "@module-federation/error-codes": "2.3.3", - "@module-federation/inject-external-runtime-core-plugin": "2.3.3", - "@module-federation/managers": "2.3.3", - "@module-federation/manifest": "2.3.3", - "@module-federation/rspack": "2.3.3", - "@module-federation/runtime-tools": "2.3.3", - "@module-federation/sdk": "2.3.3", - "@module-federation/webpack-bundler-runtime": "2.3.3", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@module-federation/enhanced/-/enhanced-2.4.0.tgz", + "integrity": "sha512-NiccK03x7V6bK2LvJNuW520kT+Onx+LJe8lyPsENjXctECCIFJdJOmYr8ABif/kLayWKrrYCzCGVNNiQXANEGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@module-federation/bridge-react-webpack-plugin": "2.4.0", + "@module-federation/cli": "2.4.0", + "@module-federation/dts-plugin": "2.4.0", + "@module-federation/error-codes": "2.4.0", + "@module-federation/inject-external-runtime-core-plugin": "2.4.0", + "@module-federation/managers": "2.4.0", + "@module-federation/manifest": "2.4.0", + "@module-federation/rspack": "2.4.0", + "@module-federation/runtime-tools": "2.4.0", + "@module-federation/sdk": "2.4.0", + "@module-federation/webpack-bundler-runtime": "2.4.0", "schema-utils": "4.3.0", "tapable": "2.3.0", "upath": "2.0.1" @@ -7579,56 +7555,56 @@ } }, "node_modules/@module-federation/error-codes": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-2.3.3.tgz", - "integrity": "sha512-UVtKBoKnRDcHgByIDvPRZSxQqjqbNH7NvJm1KHLoce33+EDiIdZYs0HvvUQv43RgESpB9s7HjrqFlq3bEcAgfQ==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-2.4.0.tgz", + "integrity": "sha512-ktCZtwOoiKR1URJyBt223OsOFAUvc13rICYif55mt7+DomtELlh5FicnEz6mPLBUwmNM9vyBMvkxOdp+fQ5oUg==", "dev": true, "license": "MIT" }, "node_modules/@module-federation/inject-external-runtime-core-plugin": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@module-federation/inject-external-runtime-core-plugin/-/inject-external-runtime-core-plugin-2.3.3.tgz", - "integrity": "sha512-ImSft6hOkMdnpZX8O+RydwkYENxhAwT92n1OAT3Xf01DXMrEpSO0PqBlPGgontxuiaV9dM2/xWSLGuIWaOtupA==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@module-federation/inject-external-runtime-core-plugin/-/inject-external-runtime-core-plugin-2.4.0.tgz", + "integrity": "sha512-GucUMQmQXcnJC/OnJGvMz3Qy7ap8nAffhQPwDpOSi0Qwm+Iq/ppzG8N3tlLBDmv/O8hiF8HHlg789XK2kcCQtg==", "dev": true, "license": "MIT", "peerDependencies": { - "@module-federation/runtime-tools": "2.3.3" + "@module-federation/runtime-tools": "2.4.0" } }, "node_modules/@module-federation/managers": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@module-federation/managers/-/managers-2.3.3.tgz", - "integrity": "sha512-sYL0t2guakJ+nDSQANH54uz5q1YxaNCn5C3lr+7BoRD49dX7Z6k7094yqOPEy8trzqdIoQVFpgewVA6IC/FeyQ==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@module-federation/managers/-/managers-2.4.0.tgz", + "integrity": "sha512-Z8j6aog44G1gt4yIAaeDowwZ7xg0aAxTA1Hq69euJK9cR9MDEaLbLUk57jDoiRj6xLwlCiw7ozY+U15BQATk6Q==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/sdk": "2.3.3", + "@module-federation/sdk": "2.4.0", "find-pkg": "2.0.0" } }, "node_modules/@module-federation/manifest": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@module-federation/manifest/-/manifest-2.3.3.tgz", - "integrity": "sha512-mAEXuo5sGt8FUDzftU8f0ci0PbsZIDcLRYX9AkXwbXg0JRyVEvWyiBrEKF+zZuy7YM7eRdyp6JjLJPDzufhj5w==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@module-federation/manifest/-/manifest-2.4.0.tgz", + "integrity": "sha512-ZL+W5rbtgRf9TWRP7Dupt/Svia4bJEOS6gWSj9jzemiLPRPkMO5hjWZKVHIc8oG+Vb25yzozFMmQ+luGi695wg==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/dts-plugin": "2.3.3", - "@module-federation/managers": "2.3.3", - "@module-federation/sdk": "2.3.3", + "@module-federation/dts-plugin": "2.4.0", + "@module-federation/managers": "2.4.0", + "@module-federation/sdk": "2.4.0", "find-pkg": "2.0.0" } }, "node_modules/@module-federation/node": { - "version": "2.7.41", - "resolved": "https://registry.npmjs.org/@module-federation/node/-/node-2.7.41.tgz", - "integrity": "sha512-ZaNrfX+7ua8UvnRa6qgrDtViU9Oz3oBGpFprldU8ek0NB2QWNACu9RMU7fHdTD/FKlzGVLi/LgdKnNKkmJD2TA==", + "version": "2.7.42", + "resolved": "https://registry.npmjs.org/@module-federation/node/-/node-2.7.42.tgz", + "integrity": "sha512-aX/T4L9bPbOgNLIW+30k/dA2Iohoy9/jf4yG1ka6Hkuo5h7iEBeZiQkwIqC06cnCbtKL1HnAiYlXHmrDPW5xvg==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/enhanced": "2.3.3", - "@module-federation/runtime": "2.3.3", - "@module-federation/sdk": "2.3.3", + "@module-federation/enhanced": "2.4.0", + "@module-federation/runtime": "2.4.0", + "@module-federation/sdk": "2.4.0", "encoding": "0.1.13", "node-fetch": "2.7.0", "tapable": "2.3.0" @@ -7642,66 +7618,20 @@ } } }, - "node_modules/@module-federation/node/node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/@module-federation/node/node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@module-federation/node/node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/@module-federation/node/node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/@module-federation/rspack": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@module-federation/rspack/-/rspack-2.3.3.tgz", - "integrity": "sha512-4s3G+wXZ6J3rKe0EeZnGLQUM7y+qpiI5NM3U6ylZuxD8q7mAwQVHThbH6ruDYUNDVEOc6N0j/+/LdfGRw+e5xw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@module-federation/rspack/-/rspack-2.4.0.tgz", + "integrity": "sha512-NWH5Vaj/fA9R7PfbwTuE1Ty/pfiAt12On0E3FzoeVPCyb5MxO1i0z+xxRHbPhF4ZOrAPGEMaMQ8Z9vH94EiElw==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/bridge-react-webpack-plugin": "2.3.3", - "@module-federation/dts-plugin": "2.3.3", - "@module-federation/inject-external-runtime-core-plugin": "2.3.3", - "@module-federation/managers": "2.3.3", - "@module-federation/manifest": "2.3.3", - "@module-federation/runtime-tools": "2.3.3", - "@module-federation/sdk": "2.3.3" + "@module-federation/bridge-react-webpack-plugin": "2.4.0", + "@module-federation/dts-plugin": "2.4.0", + "@module-federation/inject-external-runtime-core-plugin": "2.4.0", + "@module-federation/managers": "2.4.0", + "@module-federation/manifest": "2.4.0", + "@module-federation/runtime-tools": "2.4.0", + "@module-federation/sdk": "2.4.0" }, "peerDependencies": { "@rspack/core": "^0.7.0 || ^1.0.0 || ^2.0.0-0", @@ -7718,47 +7648,47 @@ } }, "node_modules/@module-federation/runtime": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@module-federation/runtime/-/runtime-2.3.3.tgz", - "integrity": "sha512-JYJ3qv9V85DtBtT/ppDuJNwBTUrYqqZDYcyiTzwY5+44dC5QPvgJ//F+BOhAhZ02WkZV0b4jsKTyLOC3vXKGqQ==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime/-/runtime-2.4.0.tgz", + "integrity": "sha512-IrLAMwUuteRgFlEkg9jrn4bk8uC897FnXvfNmkKD8/qIoNtSd+32e5ouQn+PEYbX/RjRUB1TYveY6rYHpTPkyg==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/error-codes": "2.3.3", - "@module-federation/runtime-core": "2.3.3", - "@module-federation/sdk": "2.3.3" + "@module-federation/error-codes": "2.4.0", + "@module-federation/runtime-core": "2.4.0", + "@module-federation/sdk": "2.4.0" } }, "node_modules/@module-federation/runtime-core": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@module-federation/runtime-core/-/runtime-core-2.3.3.tgz", - "integrity": "sha512-B07LDH9KxhBO3GbULGW64mQFVQBtrEd3PoaCBm7XR1IbU8rMQUJQjDNVZgXYcyhRPBVP+3KWZuiaKFRiNb6PQw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime-core/-/runtime-core-2.4.0.tgz", + "integrity": "sha512-0S8fDw28DXDW17lTQwq5vfJWe2lG0Lw3+w4vk3DVVImLwXXay+OGxLDxzWUfypWcMznfpnoAnFUMO3PtuXziuA==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/error-codes": "2.3.3", - "@module-federation/sdk": "2.3.3" + "@module-federation/error-codes": "2.4.0", + "@module-federation/sdk": "2.4.0" } }, "node_modules/@module-federation/runtime-tools": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@module-federation/runtime-tools/-/runtime-tools-2.3.3.tgz", - "integrity": "sha512-XODzyLbBYcy4wnYBXKIBqaHPVfBx1HshGdjZmSctDDnx9/VYgdx9DShb6UI+WuQBKJgPzTcx4xbvbCM4SdMilQ==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime-tools/-/runtime-tools-2.4.0.tgz", + "integrity": "sha512-BWQsGT4EWscV9bx3bVHEwp6lERBsiYm7rnPiDpwd2fx+hGEpz1IM9Pz35VryHNDXYxw7MzaAuwTMM+L7uN8OYQ==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/runtime": "2.3.3", - "@module-federation/webpack-bundler-runtime": "2.3.3" + "@module-federation/runtime": "2.4.0", + "@module-federation/webpack-bundler-runtime": "2.4.0" } }, "node_modules/@module-federation/sdk": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@module-federation/sdk/-/sdk-2.3.3.tgz", - "integrity": "sha512-mwCS+LQdqiSc6fM5iz/S60ibaFNSH6kNqlZkCRIuS4yjdZ+jgnihz+6xp1QzppvfFgKLhEHBiXOmcYOdk3Ckew==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@module-federation/sdk/-/sdk-2.4.0.tgz", + "integrity": "sha512-eZDdF5B69W9npuka0VL24FY7XDM+YAwwfkscSeWOSqv4/8Hm0xmcmSurlP6NIOrwbeogerRCtEcnx/TFXYjoow==", "dev": true, "license": "MIT", "peerDependencies": { - "node-fetch": "^3.3.2" + "node-fetch": "^2.7.0 || ^3.3.2" }, "peerDependenciesMeta": { "node-fetch": { @@ -7767,9 +7697,9 @@ } }, "node_modules/@module-federation/third-party-dts-extractor": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@module-federation/third-party-dts-extractor/-/third-party-dts-extractor-2.3.3.tgz", - "integrity": "sha512-rR94TjC1QVQLQPTazI0waLc76hI8dnv6aHTl+PUEIY9s5hXp8TA85XS0QJQqIf2KTjlPgZbWAwyFjOAJluTjaw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@module-federation/third-party-dts-extractor/-/third-party-dts-extractor-2.4.0.tgz", + "integrity": "sha512-4v24t6L3dET/6abMOM2fiM3roT0c8mi21/i+uDc6WG7U0i+Xp2SojBppTs6gnT0lkwMTe+u6xIpNQakdUftHsg==", "dev": true, "license": "MIT", "dependencies": { @@ -7778,15 +7708,15 @@ } }, "node_modules/@module-federation/webpack-bundler-runtime": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@module-federation/webpack-bundler-runtime/-/webpack-bundler-runtime-2.3.3.tgz", - "integrity": "sha512-W+P6ZF9J3gwnQuoF07YV0OiR1D6sI/uErUu4+c3QXxka3orANUHujkddNSsDxL1obAGoJa7Da99crZKf7u2j/w==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@module-federation/webpack-bundler-runtime/-/webpack-bundler-runtime-2.4.0.tgz", + "integrity": "sha512-Ntx0+QsgcwtXlpGjL/Vf2PMdPjUHl07b3yM4kBc1kbRogW3Ee84QneBRi/X3w4/jlz4JKbHjD+CMXaqi2W6hgw==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/error-codes": "2.3.3", - "@module-federation/runtime": "2.3.3", - "@module-federation/sdk": "2.3.3" + "@module-federation/error-codes": "2.4.0", + "@module-federation/runtime": "2.4.0", + "@module-federation/sdk": "2.4.0" } }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { @@ -9045,20 +8975,20 @@ } }, "node_modules/@nx/angular": { - "version": "22.6.5", - "resolved": "https://registry.npmjs.org/@nx/angular/-/angular-22.6.5.tgz", - "integrity": "sha512-NPkrGGatlUUK7twHKYpv3mv6jYL6dRiqdPuqAhQfmUnuz5lA4ZhpCfwEBKUFEKsGNwOft0ZCGZdSdODliaKZzA==", + "version": "22.7.1", + "resolved": "https://registry.npmjs.org/@nx/angular/-/angular-22.7.1.tgz", + "integrity": "sha512-tgEKO7fVQPchDISJZfI256ggzYoPcADomLDQFqs7oYTBaH7ZMDMzTOMRprrittbd1Me4o568ttdh/1Ya1fFv9g==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.6.5", - "@nx/eslint": "22.6.5", - "@nx/js": "22.6.5", - "@nx/module-federation": "22.6.5", - "@nx/rspack": "22.6.5", - "@nx/web": "22.6.5", - "@nx/webpack": "22.6.5", - "@nx/workspace": "22.6.5", + "@nx/devkit": "22.7.1", + "@nx/eslint": "22.7.1", + "@nx/js": "22.7.1", + "@nx/module-federation": "22.7.1", + "@nx/rspack": "22.7.1", + "@nx/web": "22.7.1", + "@nx/webpack": "22.7.1", + "@nx/workspace": "22.7.1", "@phenomnomnominal/tsquery": "~6.1.4", "@typescript-eslint/type-utils": "^8.0.0", "enquirer": "~2.3.6", @@ -9106,15 +9036,15 @@ } }, "node_modules/@nx/cypress": { - "version": "22.6.5", - "resolved": "https://registry.npmjs.org/@nx/cypress/-/cypress-22.6.5.tgz", - "integrity": "sha512-8pVXkVryoLRDEFjKrOIcPArY8RObf7SC1U8WMyxNS28Hs32eaENWNXtOncrXQz98jWhpi4jvmr0WPLCr9NCFsA==", + "version": "22.7.1", + "resolved": "https://registry.npmjs.org/@nx/cypress/-/cypress-22.7.1.tgz", + "integrity": "sha512-ieypLZFh4iCjfrcSWeYOPk/2Vg8O/CtmQBqqVhPdBLP9p0zgMBvym0XAtyaywhKphrYfVs1eIwwMMRA6Jn55Ng==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.6.5", - "@nx/eslint": "22.6.5", - "@nx/js": "22.6.5", + "@nx/devkit": "22.7.1", + "@nx/eslint": "22.7.1", + "@nx/js": "22.7.1", "@phenomnomnominal/tsquery": "~6.1.4", "detect-port": "^1.5.1", "semver": "^7.6.3", @@ -9131,9 +9061,9 @@ } }, "node_modules/@nx/devkit": { - "version": "22.6.5", - "resolved": "https://registry.npmjs.org/@nx/devkit/-/devkit-22.6.5.tgz", - "integrity": "sha512-9kvAI+kk2pfEXLqS8OyjI9XvWmp+Gdn7jPfxDAz8BOqxMyPy3p5hYl+jc4TIsLOWunAFl8azqrcYsHzEpaWCIA==", + "version": "22.7.1", + "resolved": "https://registry.npmjs.org/@nx/devkit/-/devkit-22.7.1.tgz", + "integrity": "sha512-z2ayFHq406MyVpNtksGnsfHOYZVTSInwQgZeg6u+S4sD21Wvb+oldhqkbYX46jiGJSaw5aUjFdzXJu2l4MYP1A==", "dev": true, "license": "MIT", "dependencies": { @@ -9202,49 +9132,53 @@ } }, "node_modules/@nx/docker": { - "version": "22.6.5", - "resolved": "https://registry.npmjs.org/@nx/docker/-/docker-22.6.5.tgz", - "integrity": "sha512-kEZi8sC7hL5WsUCJkmj7hvrTahK8yOsRfgRljbSQnQ6vAZPHHZQnB36ybgrb0JbhFPciRV5Mf9UQm13Ev49M2Q==", + "version": "22.7.1", + "resolved": "https://registry.npmjs.org/@nx/docker/-/docker-22.7.1.tgz", + "integrity": "sha512-jTB9pczTG7gQsaWdycWw8azm8iZ4AkuFttjmIVK4gGtoRf8rzJadn+pID/DHAwKay/Kxb7w2RyfFDJcyNCtvsQ==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.6.5", + "@nx/devkit": "22.7.1", "enquirer": "~2.3.6", "tslib": "^2.3.0" } }, "node_modules/@nx/eslint": { - "version": "22.6.5", - "resolved": "https://registry.npmjs.org/@nx/eslint/-/eslint-22.6.5.tgz", - "integrity": "sha512-rEV8CveVA3CCW8MHSKauUI+6XSpQ0nZ/z64fBvBulLUoUO10/mVpkbl3NpRyhCKXzOHYhW35wwuzq6YrfSi6gA==", + "version": "22.7.1", + "resolved": "https://registry.npmjs.org/@nx/eslint/-/eslint-22.7.1.tgz", + "integrity": "sha512-wBx/U1NTZ4arbjFrI7bI0zd1FMSBpqIszzfJ0pXyqrHA3KxNLFTQI713XoSD2hxTNrbh4owFQD7SYG4WpNN3CA==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.6.5", - "@nx/js": "22.6.5", + "@nx/devkit": "22.7.1", + "@nx/js": "22.7.1", "semver": "^7.6.3", "tslib": "^2.3.0", "typescript": "~5.9.2" }, "peerDependencies": { + "@nx/jest": "22.7.1", "@zkochan/js-yaml": "0.0.7", "eslint": "^8.0.0 || ^9.0.0 || ^10.0.0" }, "peerDependenciesMeta": { + "@nx/jest": { + "optional": true + }, "@zkochan/js-yaml": { "optional": true } } }, "node_modules/@nx/eslint-plugin": { - "version": "22.6.5", - "resolved": "https://registry.npmjs.org/@nx/eslint-plugin/-/eslint-plugin-22.6.5.tgz", - "integrity": "sha512-G1DkvBMzBAqxxrJ7Zfky5HN4HQjrULKZQ5J5YCnTm5qZpah58U7xAP4g8D0aJzKMWMUJkgXAU1ojiLbeZUDJkw==", + "version": "22.7.1", + "resolved": "https://registry.npmjs.org/@nx/eslint-plugin/-/eslint-plugin-22.7.1.tgz", + "integrity": "sha512-LkW2QwkiB0MslknVN8If2JQZHFPgD1FkfTkVzgw2j+3B/+4dBN8aJsWgr6b3T4vn/n6XxtwFd7NVuJlsk5nyyQ==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.6.5", - "@nx/js": "22.6.5", + "@nx/devkit": "22.7.1", + "@nx/js": "22.7.1", "@phenomnomnominal/tsquery": "~6.1.4", "@typescript-eslint/type-utils": "^8.0.0", "@typescript-eslint/utils": "^8.0.0", @@ -9279,16 +9213,16 @@ } }, "node_modules/@nx/jest": { - "version": "22.6.5", - "resolved": "https://registry.npmjs.org/@nx/jest/-/jest-22.6.5.tgz", - "integrity": "sha512-Px+ROXwl3s8tjS3OEVMtNY5krQ2zAWyK7s922zmtbXjNKGZ8bg6krnkq7n5XpQwyXTZNVxku8TXcN7jXZECGFQ==", + "version": "22.7.1", + "resolved": "https://registry.npmjs.org/@nx/jest/-/jest-22.7.1.tgz", + "integrity": "sha512-5e9O19Gv6vF/xgyu6fdW49fSB11VqAuWyI/ls0Fjy+2c71JuuJXftF/HfYf7SH008PqccVyXYMCZZNGRGuqWsg==", "dev": true, "license": "MIT", "dependencies": { "@jest/reporters": "^30.0.2", "@jest/test-result": "^30.0.2", - "@nx/devkit": "22.6.5", - "@nx/js": "22.6.5", + "@nx/devkit": "22.7.1", + "@nx/js": "22.7.1", "@phenomnomnominal/tsquery": "~6.1.4", "identity-obj-proxy": "3.0.0", "jest-config": "^30.0.2", @@ -9342,9 +9276,9 @@ } }, "node_modules/@nx/js": { - "version": "22.6.5", - "resolved": "https://registry.npmjs.org/@nx/js/-/js-22.6.5.tgz", - "integrity": "sha512-bmikz6qaBHfuAgsqPB/TfLIKfvI4g+EKIRAiU2FHnEtVWOKDAmSQXHFwE3rMS49jl2JLgxkdNjZHpg4g/OLy0g==", + "version": "22.7.1", + "resolved": "https://registry.npmjs.org/@nx/js/-/js-22.7.1.tgz", + "integrity": "sha512-zvPaamdAFehy4PsA963sJuwVXehsbpSJQJgEW6xcWy58lYLI/NRQHSZn4yJmuaFVnuuciRlmiacCom242byWnw==", "dev": true, "license": "MIT", "dependencies": { @@ -9355,8 +9289,8 @@ "@babel/preset-env": "^7.23.2", "@babel/preset-typescript": "^7.22.5", "@babel/runtime": "^7.22.6", - "@nx/devkit": "22.6.5", - "@nx/workspace": "22.6.5", + "@nx/devkit": "22.7.1", + "@nx/workspace": "22.7.1", "@zkochan/js-yaml": "0.0.7", "babel-plugin-const-enum": "^1.0.1", "babel-plugin-macros": "^3.1.0", @@ -9413,18 +9347,18 @@ } }, "node_modules/@nx/module-federation": { - "version": "22.6.5", - "resolved": "https://registry.npmjs.org/@nx/module-federation/-/module-federation-22.6.5.tgz", - "integrity": "sha512-nQS3qFGs8lQ87ZQ8hab+oL+BfjCYjNPkGrpH4fXovnFgwaRNudnQnh2vTud1+JcUl0e+sJi/wIwZH4AB75jzSA==", + "version": "22.7.1", + "resolved": "https://registry.npmjs.org/@nx/module-federation/-/module-federation-22.7.1.tgz", + "integrity": "sha512-dNPQU9Gx8PcdUjiSbQoya+/Q0oQvVT1l+tNDBx5M72zertoFdSogNqeudS0256/mHbbgGRYX6vteafJBOLuSjg==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/enhanced": "^2.1.0", + "@module-federation/enhanced": "^2.3.3", "@module-federation/node": "^2.7.21", "@module-federation/sdk": "^2.1.0", - "@nx/devkit": "22.6.5", - "@nx/js": "22.6.5", - "@nx/web": "22.6.5", + "@nx/devkit": "22.7.1", + "@nx/js": "22.7.1", + "@nx/web": "22.7.1", "@rspack/core": "1.6.8", "express": "^4.21.2", "http-proxy-middleware": "^3.0.5", @@ -9563,9 +9497,9 @@ } }, "node_modules/@nx/module-federation/node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", "dev": true, "license": "MIT", "dependencies": { @@ -9577,7 +9511,7 @@ "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", - "qs": "~6.14.0", + "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" @@ -9657,6 +9591,22 @@ "url": "https://opencollective.com/express" } }, + "node_modules/@nx/module-federation/node_modules/express/node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/@nx/module-federation/node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -9766,22 +9716,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@nx/module-federation/node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/@nx/module-federation/node_modules/raw-body": { "version": "2.5.3", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", @@ -9861,41 +9795,41 @@ } }, "node_modules/@nx/nest": { - "version": "22.6.5", - "resolved": "https://registry.npmjs.org/@nx/nest/-/nest-22.6.5.tgz", - "integrity": "sha512-HdQVzXEccVtoSakU+Mqib44DgbFi1JKkA0BVnUp6Fmp0/Bt0xo5l2lhP2x4qS8oKQCWHtoxNznWKLkmqGLRjig==", + "version": "22.7.1", + "resolved": "https://registry.npmjs.org/@nx/nest/-/nest-22.7.1.tgz", + "integrity": "sha512-Neo6ejcY/7EbvpyzamXSbvknAUljwn68ZU/zNz3F8zDchqX4hrPVxpEr2tOY5JhEtx0ZgNk5gPUls7O80NNqPg==", "dev": true, "license": "MIT", "dependencies": { "@nestjs/schematics": "^11.0.0", - "@nx/devkit": "22.6.5", - "@nx/eslint": "22.6.5", - "@nx/js": "22.6.5", - "@nx/node": "22.6.5", + "@nx/devkit": "22.7.1", + "@nx/eslint": "22.7.1", + "@nx/js": "22.7.1", + "@nx/node": "22.7.1", "tslib": "^2.3.0" } }, "node_modules/@nx/node": { - "version": "22.6.5", - "resolved": "https://registry.npmjs.org/@nx/node/-/node-22.6.5.tgz", - "integrity": "sha512-ahZRKpd6pqflRqBX4/oVSk5bAHtS2CM4I3T3c6iuTNenG8ehVxGmuJj8KpiC0tlLe3kJoR1oWQU1pDakXrVWow==", + "version": "22.7.1", + "resolved": "https://registry.npmjs.org/@nx/node/-/node-22.7.1.tgz", + "integrity": "sha512-RCKCDvI3EYkAE7XlGmvNlgZj5zG3a+AaY1WTi/3GsaiuDCRPjZWg2Jm4H/ay4wGqE4JZeFMISjcA+n6ABrL2yA==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.6.5", - "@nx/docker": "22.6.5", - "@nx/eslint": "22.6.5", - "@nx/jest": "22.6.5", - "@nx/js": "22.6.5", + "@nx/devkit": "22.7.1", + "@nx/docker": "22.7.1", + "@nx/eslint": "22.7.1", + "@nx/jest": "22.7.1", + "@nx/js": "22.7.1", "kill-port": "^1.6.1", "tcp-port-used": "^1.0.2", "tslib": "^2.3.0" } }, "node_modules/@nx/nx-darwin-arm64": { - "version": "22.6.5", - "resolved": "https://registry.npmjs.org/@nx/nx-darwin-arm64/-/nx-darwin-arm64-22.6.5.tgz", - "integrity": "sha512-qT77Omkg5xQuL2+pDbneX2tI+XW5ZeayMylu7UUgK8OhTrAkJLKjpuYRH4xT5XBipxbDtlxmO3aLS3Ib1pKzJQ==", + "version": "22.7.1", + "resolved": "https://registry.npmjs.org/@nx/nx-darwin-arm64/-/nx-darwin-arm64-22.7.1.tgz", + "integrity": "sha512-m00ZmBn39VUgb0Ahhu5iY6D56ETdXjDbVnOz0XF3DacJrcLtq9sZ+cg1bj6PshqtvRWVg+zJRrZBU6vL7hGuFQ==", "cpu": [ "arm64" ], @@ -9907,9 +9841,9 @@ ] }, "node_modules/@nx/nx-darwin-x64": { - "version": "22.6.5", - "resolved": "https://registry.npmjs.org/@nx/nx-darwin-x64/-/nx-darwin-x64-22.6.5.tgz", - "integrity": "sha512-9jICxb7vfJ56y/7Yuh3b/n1QJqWxO9xnXKYEs6SO8xPoW/KomVckILGc1C6RQSs6/3ixVJC7k1Dh1wm5tKPFrg==", + "version": "22.7.1", + "resolved": "https://registry.npmjs.org/@nx/nx-darwin-x64/-/nx-darwin-x64-22.7.1.tgz", + "integrity": "sha512-DmD8Qow+Yt7Yrmjlz1AsfiwxW+0kRzg+6MY70+d7qChtD2bTzvA/k0ut8SMy+CxU3kxgUbKhGOtml5JDXoX2ww==", "cpu": [ "x64" ], @@ -9921,9 +9855,9 @@ ] }, "node_modules/@nx/nx-freebsd-x64": { - "version": "22.6.5", - "resolved": "https://registry.npmjs.org/@nx/nx-freebsd-x64/-/nx-freebsd-x64-22.6.5.tgz", - "integrity": "sha512-6B1wEKpqz5dI3AGMqttAVnA6M3DB/besAtuGyQiymK9ROlta1iuWgCcIYwcCQyhLn2Rx7vqj447KKcgCa8HlVw==", + "version": "22.7.1", + "resolved": "https://registry.npmjs.org/@nx/nx-freebsd-x64/-/nx-freebsd-x64-22.7.1.tgz", + "integrity": "sha512-HboVrUCHcuYTXtuX3dMyRszP7JO90ZVBLWgnmaM7jUM7jnllZjmezUMtpNHfN1GQbVFafJf/NBShDWsu9LuaUA==", "cpu": [ "x64" ], @@ -9935,9 +9869,9 @@ ] }, "node_modules/@nx/nx-linux-arm-gnueabihf": { - "version": "22.6.5", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm-gnueabihf/-/nx-linux-arm-gnueabihf-22.6.5.tgz", - "integrity": "sha512-xV50B8mnDPboct7JkAHftajI02s+8FszA8WTzhore+YGR+lEKHTLpucwGEaQuMlSdLplH7pQix4B4uK5pcMhZw==", + "version": "22.7.1", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm-gnueabihf/-/nx-linux-arm-gnueabihf-22.7.1.tgz", + "integrity": "sha512-5Gm8Y7L8WXMLUjHhiy1eqGz5/PiRw1YLanFg5audBNkZvH6Jkwzdpoz0dbeKjwMDHz4NmniUV1s76Th8VLWmiQ==", "cpu": [ "arm" ], @@ -9949,9 +9883,9 @@ ] }, "node_modules/@nx/nx-linux-arm64-gnu": { - "version": "22.6.5", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-gnu/-/nx-linux-arm64-gnu-22.6.5.tgz", - "integrity": "sha512-2JkWuMGj+HpW6oPAvU5VdAx1afTnEbiM10Y3YOrl3fipWV4BiP5VDx762QTrfCraP4hl6yqTgvTe7F9xaby+jQ==", + "version": "22.7.1", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-gnu/-/nx-linux-arm64-gnu-22.7.1.tgz", + "integrity": "sha512-GdgPYMfbijBRFJs1absL/9QdSNLsTAGdyKykDf9CaVxEMZ92VB+pncpX9Vn/ZBCSeeWTLF+bSK3UM5v+loIObQ==", "cpu": [ "arm64" ], @@ -9963,9 +9897,9 @@ ] }, "node_modules/@nx/nx-linux-arm64-musl": { - "version": "22.6.5", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-musl/-/nx-linux-arm64-musl-22.6.5.tgz", - "integrity": "sha512-Z/zMqFClnEyqDXouJKEPoWVhMQIif5F0YuECWBYjd3ZLwQsXGTItoh+6Wm3XF/nGMA2uLOHyTq/X7iFXQY3RzA==", + "version": "22.7.1", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-musl/-/nx-linux-arm64-musl-22.7.1.tgz", + "integrity": "sha512-HyBgPtY1hyNTk8683nt7F29jh3lVdS/zul9vS0NgKeCSoYL3GRM3nLoTPynoHUxyVP/tWYOE3ymvnk92qYwL4Q==", "cpu": [ "arm64" ], @@ -9977,9 +9911,9 @@ ] }, "node_modules/@nx/nx-linux-x64-gnu": { - "version": "22.6.5", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-gnu/-/nx-linux-x64-gnu-22.6.5.tgz", - "integrity": "sha512-FlotSyqNnaXSn0K+yWw+hRdYBwusABrPgKLyixfJIYRzsy+xPKN6pON6vZfqGwzuWF/9mEGReRz+iM8PiW0XSg==", + "version": "22.7.1", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-gnu/-/nx-linux-x64-gnu-22.7.1.tgz", + "integrity": "sha512-bQBgRiEsanNvKcDOjVAUPjvcp0iDLofYYUL2af2iuCDxreLOej+J6MeA5bWTLNly5ly1d4voKGTqa+OsouVyLg==", "cpu": [ "x64" ], @@ -9991,9 +9925,9 @@ ] }, "node_modules/@nx/nx-linux-x64-musl": { - "version": "22.6.5", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-musl/-/nx-linux-x64-musl-22.6.5.tgz", - "integrity": "sha512-RVOe2qcwhoIx6mxQURPjUfAW5SEOmT2gdhewvdcvX9ICq1hj5B2VarmkhTg0qroO7xiyqOqwq26mCzoV2I3NgQ==", + "version": "22.7.1", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-musl/-/nx-linux-x64-musl-22.7.1.tgz", + "integrity": "sha512-gcco2GjcAztF/fRcAgFxtWxrWDnQdNmPaAN9FTt1+qQ9RUSLvdL8bQxKx4Kd9N9T+gXPlrWhMkBkKbbV09+X1Q==", "cpu": [ "x64" ], @@ -10005,9 +9939,9 @@ ] }, "node_modules/@nx/nx-win32-arm64-msvc": { - "version": "22.6.5", - "resolved": "https://registry.npmjs.org/@nx/nx-win32-arm64-msvc/-/nx-win32-arm64-msvc-22.6.5.tgz", - "integrity": "sha512-ZqurqI8VuYnsr2Kn4K4t+Gx6j/BZdf6qz/6Tv4A7XQQ6oNYVQgTqoNEFj+CCkVaIe6aIdCWpousFLqs+ZgBqYQ==", + "version": "22.7.1", + "resolved": "https://registry.npmjs.org/@nx/nx-win32-arm64-msvc/-/nx-win32-arm64-msvc-22.7.1.tgz", + "integrity": "sha512-IT9oEn0YQ83iPH7666aoPyTRsUzBIBJdBLMXeLX4I60fHPXWhUSGpfiLtIsgU2OfeOVb9hU9idwNh1wc4u9rWQ==", "cpu": [ "arm64" ], @@ -10019,9 +9953,9 @@ ] }, "node_modules/@nx/nx-win32-x64-msvc": { - "version": "22.6.5", - "resolved": "https://registry.npmjs.org/@nx/nx-win32-x64-msvc/-/nx-win32-x64-msvc-22.6.5.tgz", - "integrity": "sha512-i2QFBJIuaYg9BHxrrnBV4O7W9rVL2k0pSIdk/rRp3EYJEU93iUng+qbZiY9wh1xvmXuUCE2G7TRd+8/SG/RFKg==", + "version": "22.7.1", + "resolved": "https://registry.npmjs.org/@nx/nx-win32-x64-msvc/-/nx-win32-x64-msvc-22.7.1.tgz", + "integrity": "sha512-P2zeSKXVH2Eiwsb8UfP2rMMS7//cHWpiO4M9zt6q0c4lI/hN1vXBciRKVWruGk9ZrWLHuhaMAhG94+MJtzKuRQ==", "cpu": [ "x64" ], @@ -10033,16 +9967,16 @@ ] }, "node_modules/@nx/rspack": { - "version": "22.6.5", - "resolved": "https://registry.npmjs.org/@nx/rspack/-/rspack-22.6.5.tgz", - "integrity": "sha512-ugjdD7OY4Cy7AcSlEJcfcfDWxev5PnVagb4FEEEutneITLz8hrBmQ+uY5cJg07Vsx6eauskpirkwRtXAQeHgFQ==", + "version": "22.7.1", + "resolved": "https://registry.npmjs.org/@nx/rspack/-/rspack-22.7.1.tgz", + "integrity": "sha512-zizR6BWvH9klo9QTkWNRwTO/Rhk2RxPZOZNnPt/bV9adDWSvY+8CYojxtkdXAfiwj65WenvL9WYJUhKu137W4g==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.6.5", - "@nx/js": "22.6.5", - "@nx/module-federation": "22.6.5", - "@nx/web": "22.6.5", + "@nx/devkit": "22.7.1", + "@nx/js": "22.7.1", + "@nx/module-federation": "22.7.1", + "@nx/web": "22.7.1", "@phenomnomnominal/tsquery": "~6.1.4", "@rspack/core": "1.6.8", "@rspack/dev-server": "^1.1.4", @@ -10072,7 +10006,7 @@ "webpack-node-externals": "^3.0.0" }, "peerDependencies": { - "@module-federation/enhanced": "^2.1.0", + "@module-federation/enhanced": "^2.3.3", "@module-federation/node": "^2.7.21" } }, @@ -10202,9 +10136,9 @@ } }, "node_modules/@nx/rspack/node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", "dev": true, "license": "MIT", "dependencies": { @@ -10216,7 +10150,7 @@ "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", - "qs": "~6.14.0", + "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" @@ -10332,6 +10266,22 @@ "url": "https://opencollective.com/express" } }, + "node_modules/@nx/rspack/node_modules/express/node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/@nx/rspack/node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -10463,22 +10413,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@nx/rspack/node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/@nx/rspack/node_modules/raw-body": { "version": "2.5.3", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", @@ -10558,49 +10492,83 @@ } }, "node_modules/@nx/storybook": { - "version": "22.6.5", - "resolved": "https://registry.npmjs.org/@nx/storybook/-/storybook-22.6.5.tgz", - "integrity": "sha512-h0O8t7ir5hkqJhvT/g0E9KiILj3y9thZ7pOlB/Krc1G2GzYynSZJPElv//pibPhxtZl0Yk4AldkX9LqMkWtfFA==", + "version": "22.7.1", + "resolved": "https://registry.npmjs.org/@nx/storybook/-/storybook-22.7.1.tgz", + "integrity": "sha512-bjYGB08/lBNNCvgIZijdACQzcb8asuRNlkU9j/D9mGk2wIWv7Ub+Sj3Q8HGnDvpPdSFgbbo0vGw1xGrX4votBw==", "dev": true, "license": "MIT", "dependencies": { - "@nx/cypress": "22.6.5", - "@nx/devkit": "22.6.5", - "@nx/eslint": "22.6.5", - "@nx/js": "22.6.5", + "@nx/cypress": "22.7.1", + "@nx/devkit": "22.7.1", + "@nx/eslint": "22.7.1", + "@nx/js": "22.7.1", "@phenomnomnominal/tsquery": "~6.1.4", "semver": "^7.6.3", "tslib": "^2.3.0" }, "peerDependencies": { + "@nx/web": "22.7.1", "storybook": ">=7.0.0 <11.0.0" + }, + "peerDependenciesMeta": { + "@nx/web": { + "optional": true + } } }, "node_modules/@nx/web": { - "version": "22.6.5", - "resolved": "https://registry.npmjs.org/@nx/web/-/web-22.6.5.tgz", - "integrity": "sha512-LjKPLWbgEI9FDIsMGqbW0tisVJfhme0EBi1kZfTi4cIu9Pna5nYkNBefD/d/DuK0ZrRqdONNjhRkCO3TcVbtIQ==", + "version": "22.7.1", + "resolved": "https://registry.npmjs.org/@nx/web/-/web-22.7.1.tgz", + "integrity": "sha512-l3rwX1Oi307YAFmfZG6a8elZa/Faj2LvI6PuX7uaYsiSV7GcnFIId1DRCMfhGC/L63uqfcuy34X23+8jrXX9hw==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.6.5", - "@nx/js": "22.6.5", + "@nx/devkit": "22.7.1", + "@nx/js": "22.7.1", "detect-port": "^1.5.1", "http-server": "^14.1.0", "picocolors": "^1.1.0", "tslib": "^2.3.0" + }, + "peerDependencies": { + "@nx/cypress": "22.7.1", + "@nx/eslint": "22.7.1", + "@nx/jest": "22.7.1", + "@nx/playwright": "22.7.1", + "@nx/vite": "22.7.1", + "@nx/webpack": "22.7.1" + }, + "peerDependenciesMeta": { + "@nx/cypress": { + "optional": true + }, + "@nx/eslint": { + "optional": true + }, + "@nx/jest": { + "optional": true + }, + "@nx/playwright": { + "optional": true + }, + "@nx/vite": { + "optional": true + }, + "@nx/webpack": { + "optional": true + } } }, "node_modules/@nx/webpack": { - "version": "22.6.5", - "resolved": "https://registry.npmjs.org/@nx/webpack/-/webpack-22.6.5.tgz", - "integrity": "sha512-LN75xxd/6U/r8vI3nzs/N5sj22nrJdBhTfDPlYlhKz2caCCWImSvQSXmprU46xNbXuYAY0DmRcZ5fkeqjHegtw==", + "version": "22.7.1", + "resolved": "https://registry.npmjs.org/@nx/webpack/-/webpack-22.7.1.tgz", + "integrity": "sha512-XMZPhLXN9d+Q1Axmc8nFSbUlxNfgM5Tvl1pdb+K4WPpQE9Q+hF+E2vNfQa1hgL0Hu6bmScSJAGmrllZ9dfQAjA==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.23.2", - "@nx/devkit": "22.6.5", - "@nx/js": "22.6.5", + "@nx/devkit": "22.7.1", + "@nx/js": "22.7.1", "@phenomnomnominal/tsquery": "~6.1.4", "ajv": "^8.12.0", "autoprefixer": "^10.4.9", @@ -10812,17 +10780,17 @@ } }, "node_modules/@nx/workspace": { - "version": "22.6.5", - "resolved": "https://registry.npmjs.org/@nx/workspace/-/workspace-22.6.5.tgz", - "integrity": "sha512-/CZtv1ESSfZ1MVqSlCsmnBWysU1z5VdNlwANlqL6BV2X6RUHKDPVj4YuNPvCK+0LsqyzfJdUt3pcnBYxnT5TIg==", + "version": "22.7.1", + "resolved": "https://registry.npmjs.org/@nx/workspace/-/workspace-22.7.1.tgz", + "integrity": "sha512-wnBMgeogdGaRdxDDzZspSt1U87PMeYJhz1ygr42L9Lot9E5nCf17E85iyHl3YxcMSNslHxnHyTkq7MyQ8hHjdQ==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.6.5", + "@nx/devkit": "22.7.1", "@zkochan/js-yaml": "0.0.7", "chalk": "^4.1.0", "enquirer": "~2.3.6", - "nx": "22.6.5", + "nx": "22.7.1", "picomatch": "4.0.4", "semver": "^7.6.3", "tslib": "^2.3.0", @@ -12809,9 +12777,9 @@ } }, "node_modules/@rspack/dev-server/node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", "dev": true, "license": "MIT", "dependencies": { @@ -12823,7 +12791,7 @@ "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", - "qs": "~6.14.0", + "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" @@ -12928,6 +12896,22 @@ "url": "https://opencollective.com/express" } }, + "node_modules/@rspack/dev-server/node_modules/express/node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/@rspack/dev-server/node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -13107,22 +13091,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/@rspack/dev-server/node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/@rspack/dev-server/node_modules/raw-body": { "version": "2.5.3", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", @@ -16285,44 +16253,6 @@ "dev": true, "license": "BSD-2-Clause" }, - "node_modules/@yarnpkg/parsers": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@yarnpkg/parsers/-/parsers-3.0.2.tgz", - "integrity": "sha512-/HcYgtUSiJiot/XWGLOlGxPYUG65+/31V8oqk17vZLW1xlCoR4PampyePljOxY2n8/3jz9+tIFzICsyGujJZoA==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "js-yaml": "^3.10.0", - "tslib": "^2.4.0" - }, - "engines": { - "node": ">=18.12.0" - } - }, - "node_modules/@yarnpkg/parsers/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/@yarnpkg/parsers/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/@zkochan/js-yaml": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/@zkochan/js-yaml/-/js-yaml-0.0.7.tgz", @@ -19042,48 +18972,6 @@ "node-fetch": "^2.7.0" } }, - "node_modules/cross-fetch/node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/cross-fetch/node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, - "node_modules/cross-fetch/node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/cross-fetch/node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -19273,9 +19161,9 @@ } }, "node_modules/css-minimizer-webpack-plugin/node_modules/postcss": { - "version": "8.5.10", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", - "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "version": "8.5.13", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", + "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==", "dev": true, "funding": [ { @@ -20605,18 +20493,6 @@ "lodash-es": "^4.17.21" } }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 12" - } - }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -22595,32 +22471,6 @@ } } }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, - "engines": { - "node": "^12.20 || >= 14.13" - } - }, "node_modules/fetch-mock-cache": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/fetch-mock-cache/-/fetch-mock-cache-2.3.1.tgz", @@ -23245,21 +23095,6 @@ "node": ">= 0.6" } }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "fetch-blob": "^3.1.2" - }, - "engines": { - "node": ">=12.20.0" - } - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -23292,40 +23127,6 @@ "node": ">= 0.8" } }, - "node_modules/front-matter": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/front-matter/-/front-matter-4.0.2.tgz", - "integrity": "sha512-I8ZuJ/qG92NWX8i5x1Y8qyj3vizhXS31OxjKDu3LKP+7/qBgfIKValiZIEwoVoJKUHlhWtYrktkxV1XsX+pPlg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-yaml": "^3.13.1" - } - }, - "node_modules/front-matter/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/front-matter/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -23464,48 +23265,6 @@ "node": ">= 6" } }, - "node_modules/gaxios/node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/gaxios/node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, - "node_modules/gaxios/node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/gaxios/node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/gcp-metadata": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-4.3.1.tgz", @@ -30916,29 +30675,6 @@ "license": "MIT", "optional": true }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=10.5.0" - } - }, "node_modules/node-exports-info": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", @@ -30969,24 +30705,23 @@ } }, "node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "dev": true, + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "license": "MIT", - "optional": true, - "peer": true, "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" + "whatwg-url": "^5.0.0" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": "4.x || >=6.0.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } } }, "node_modules/node-fetch-native": { @@ -30996,6 +30731,28 @@ "devOptional": true, "license": "MIT" }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-forge": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", @@ -31269,65 +31026,139 @@ "license": "MIT" }, "node_modules/nx": { - "version": "22.6.5", - "resolved": "https://registry.npmjs.org/nx/-/nx-22.6.5.tgz", - "integrity": "sha512-VRKhDAt684dXNSz9MNjE7MekkCfQF41P2PSx5jEWQjDEP1Z4jFZbyeygWs5ZyOroG7/n0MoWAJTe6ftvIcBOAg==", + "version": "22.7.1", + "resolved": "https://registry.npmjs.org/nx/-/nx-22.7.1.tgz", + "integrity": "sha512-SadJUQY57MiwRIetm9rhZhdpFeOe1Csib2Vg9C423Pw/h0fZE14qUo6+OBby9vLh5QCkRfRZ0WaHkeO5q6yNtA==", "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { + "@emnapi/core": "1.4.5", + "@emnapi/runtime": "1.4.5", + "@emnapi/wasi-threads": "1.0.4", + "@jest/diff-sequences": "30.0.1", "@napi-rs/wasm-runtime": "0.2.4", - "@yarnpkg/lockfile": "^1.1.0", - "@yarnpkg/parsers": "3.0.2", + "@tybys/wasm-util": "0.9.0", + "@yarnpkg/lockfile": "1.1.0", "@zkochan/js-yaml": "0.0.7", + "ansi-colors": "4.1.3", + "ansi-regex": "5.0.1", + "ansi-styles": "4.3.0", + "argparse": "2.0.1", + "asynckit": "0.4.0", "axios": "1.15.0", + "balanced-match": "4.0.3", + "base64-js": "1.5.1", + "bl": "4.1.0", + "brace-expansion": "5.0.2", + "buffer": "5.7.1", + "call-bind-apply-helpers": "1.0.2", + "chalk": "4.1.2", "cli-cursor": "3.1.0", "cli-spinners": "2.6.1", - "cliui": "^8.0.1", - "dotenv": "~16.4.5", - "dotenv-expand": "~11.0.6", + "cliui": "8.0.1", + "clone": "1.0.4", + "color-convert": "2.0.1", + "color-name": "1.1.4", + "combined-stream": "1.0.8", + "defaults": "1.0.4", + "define-lazy-prop": "2.0.0", + "delayed-stream": "1.0.0", + "dotenv": "16.4.7", + "dotenv-expand": "12.0.3", + "dunder-proto": "1.0.1", "ejs": "5.0.1", - "enquirer": "~2.3.6", + "emoji-regex": "8.0.0", + "end-of-stream": "1.4.5", + "enquirer": "2.3.6", + "es-define-property": "1.0.1", + "es-errors": "1.3.0", + "es-object-atoms": "1.1.1", + "es-set-tostringtag": "2.1.0", + "escalade": "3.2.0", + "escape-string-regexp": "1.0.5", "figures": "3.2.0", - "flat": "^5.0.2", - "front-matter": "^4.0.2", - "ignore": "^7.0.5", - "jest-diff": "^30.0.2", + "flat": "5.0.2", + "follow-redirects": "1.15.11", + "form-data": "4.0.5", + "fs-constants": "1.0.0", + "function-bind": "1.1.2", + "get-caller-file": "2.0.5", + "get-intrinsic": "1.3.0", + "get-proto": "1.0.1", + "gopd": "1.2.0", + "has-flag": "4.0.0", + "has-symbols": "1.1.0", + "has-tostringtag": "1.0.2", + "hasown": "2.0.2", + "ieee754": "1.2.1", + "ignore": "7.0.5", + "inherits": "2.0.4", + "is-docker": "2.2.1", + "is-fullwidth-code-point": "3.0.0", + "is-interactive": "1.0.0", + "is-unicode-supported": "0.1.0", + "is-wsl": "2.2.0", + "json5": "2.2.3", "jsonc-parser": "3.2.0", "lines-and-columns": "2.0.3", + "log-symbols": "4.1.0", + "math-intrinsics": "1.1.0", + "mime-db": "1.52.0", + "mime-types": "2.1.35", + "mimic-fn": "2.1.0", "minimatch": "10.2.4", - "npm-run-path": "^4.0.1", - "open": "^8.4.0", + "minimist": "1.2.8", + "npm-run-path": "4.0.1", + "once": "1.4.0", + "onetime": "5.1.2", + "open": "8.4.2", "ora": "5.3.0", - "picocolors": "^1.1.0", + "path-key": "3.1.1", + "picocolors": "1.1.1", + "proxy-from-env": "2.1.0", + "readable-stream": "3.6.2", + "require-directory": "2.1.1", "resolve.exports": "2.0.3", - "semver": "^7.6.3", + "restore-cursor": "3.1.0", + "safe-buffer": "5.2.1", + "semver": "7.7.4", + "signal-exit": "3.0.7", "smol-toml": "1.6.1", - "string-width": "^4.2.3", - "tar-stream": "~2.2.0", - "tmp": "~0.2.1", - "tree-kill": "^1.2.2", - "tsconfig-paths": "^4.1.2", - "tslib": "^2.3.0", - "yaml": "^2.6.0", - "yargs": "^17.6.2", + "string_decoder": "1.3.0", + "string-width": "4.2.3", + "strip-ansi": "6.0.1", + "strip-bom": "3.0.0", + "supports-color": "7.2.0", + "tar-stream": "2.2.0", + "tmp": "0.2.4", + "tree-kill": "1.2.2", + "tsconfig-paths": "4.2.0", + "tslib": "2.8.1", + "util-deprecate": "1.0.2", + "wcwidth": "1.0.1", + "wrap-ansi": "7.0.0", + "wrappy": "1.0.2", + "y18n": "5.0.8", + "yaml": "2.8.0", + "yargs": "17.7.2", "yargs-parser": "21.1.1" }, "bin": { - "nx": "bin/nx.js", - "nx-cloud": "bin/nx-cloud.js" + "nx": "dist/bin/nx.js", + "nx-cloud": "dist/bin/nx-cloud.js" }, "optionalDependencies": { - "@nx/nx-darwin-arm64": "22.6.5", - "@nx/nx-darwin-x64": "22.6.5", - "@nx/nx-freebsd-x64": "22.6.5", - "@nx/nx-linux-arm-gnueabihf": "22.6.5", - "@nx/nx-linux-arm64-gnu": "22.6.5", - "@nx/nx-linux-arm64-musl": "22.6.5", - "@nx/nx-linux-x64-gnu": "22.6.5", - "@nx/nx-linux-x64-musl": "22.6.5", - "@nx/nx-win32-arm64-msvc": "22.6.5", - "@nx/nx-win32-x64-msvc": "22.6.5" + "@nx/nx-darwin-arm64": "22.7.1", + "@nx/nx-darwin-x64": "22.7.1", + "@nx/nx-freebsd-x64": "22.7.1", + "@nx/nx-linux-arm-gnueabihf": "22.7.1", + "@nx/nx-linux-arm64-gnu": "22.7.1", + "@nx/nx-linux-arm64-musl": "22.7.1", + "@nx/nx-linux-x64-gnu": "22.7.1", + "@nx/nx-linux-x64-musl": "22.7.1", + "@nx/nx-win32-arm64-msvc": "22.7.1", + "@nx/nx-win32-x64-msvc": "22.7.1" }, "peerDependencies": { "@swc-node/register": "^1.11.1", @@ -31342,27 +31173,78 @@ } } }, + "node_modules/nx/node_modules/@emnapi/core": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.5.tgz", + "integrity": "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@emnapi/wasi-threads": "1.0.4", + "tslib": "^2.4.0" + } + }, + "node_modules/nx/node_modules/@emnapi/runtime": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.5.tgz", + "integrity": "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/nx/node_modules/@emnapi/wasi-threads": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.4.tgz", + "integrity": "sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/nx/node_modules/@jest/diff-sequences": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", + "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/nx/node_modules/@tybys/wasm-util": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", + "integrity": "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/nx/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz", + "integrity": "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==", "dev": true, "license": "MIT", "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/nx/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", + "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/nx/node_modules/cli-cursor": { @@ -31401,22 +31283,6 @@ "url": "https://dotenvx.com" } }, - "node_modules/nx/node_modules/dotenv-expand": { - "version": "11.0.7", - "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", - "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "dotenv": "^16.4.5" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, "node_modules/nx/node_modules/ejs": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ejs/-/ejs-5.0.1.tgz", @@ -31437,6 +31303,16 @@ "dev": true, "license": "MIT" }, + "node_modules/nx/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/nx/node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -31533,6 +31409,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/nx/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nx/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/nx/node_modules/minimatch": { "version": "10.2.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", @@ -31651,6 +31550,37 @@ "node": ">=6" } }, + "node_modules/nx/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/nx/node_modules/yaml": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", + "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, "node_modules/nx/node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -37404,9 +37334,9 @@ "peer": true }, "node_modules/tmp": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", - "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.4.tgz", + "integrity": "sha512-UdiSoX6ypifLmrfQ/XfiawN6hkjSBpCjhKxxZcWlUUmoXLaCKQU0bx4HF/tdDK2uzRuchf1txGvrWBzYREssoQ==", "dev": true, "license": "MIT", "engines": { @@ -39015,18 +38945,6 @@ "license": "MIT", "optional": true }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 8" - } - }, "node_modules/webidl-conversions": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", @@ -40336,8 +40254,9 @@ "version": "2.8.3", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", - "devOptional": true, "license": "ISC", + "optional": true, + "peer": true, "bin": { "yaml": "bin.mjs" }, diff --git a/package.json b/package.json index 54c1f048f..0d7c961cc 100644 --- a/package.json +++ b/package.json @@ -158,16 +158,16 @@ "@eslint/js": "9.35.0", "@nestjs/schematics": "11.1.0", "@nestjs/testing": "11.1.19", - "@nx/angular": "22.6.5", - "@nx/eslint-plugin": "22.6.5", - "@nx/jest": "22.6.5", - "@nx/js": "22.6.5", - "@nx/module-federation": "22.6.5", - "@nx/nest": "22.6.5", - "@nx/node": "22.6.5", - "@nx/storybook": "22.6.5", - "@nx/web": "22.6.5", - "@nx/workspace": "22.6.5", + "@nx/angular": "22.7.1", + "@nx/eslint-plugin": "22.7.1", + "@nx/jest": "22.7.1", + "@nx/js": "22.7.1", + "@nx/module-federation": "22.7.1", + "@nx/nest": "22.7.1", + "@nx/node": "22.7.1", + "@nx/storybook": "22.7.1", + "@nx/web": "22.7.1", + "@nx/workspace": "22.7.1", "@schematics/angular": "21.2.6", "@storybook/addon-docs": "10.1.10", "@storybook/addon-themes": "10.1.10", @@ -194,7 +194,7 @@ "jest": "30.2.0", "jest-environment-jsdom": "30.2.0", "jest-preset-angular": "16.0.0", - "nx": "22.6.5", + "nx": "22.7.1", "prettier": "3.8.3", "prettier-plugin-organize-attributes": "1.0.0", "prisma": "7.7.0", From 073bb3b5c02077b669b4d197b1473a13cdc796cc Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 7 May 2026 17:57:18 +0300 Subject: [PATCH 040/132] Task/refresh cryptocurrencies list (20260503) (#6838) * Update cryptocurrencies.json * Update changelog --- CHANGELOG.md | 1 + .../cryptocurrencies/cryptocurrencies.json | 132 ++++++++++++++---- 2 files changed, 109 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27a02a0e5..246e5e289 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Migrated various components from `NgClass` to class bindings +- Refreshed the cryptocurrencies list - Upgraded `Nx` from version `22.6.5` to `22.7.1` ### Fixed diff --git a/apps/api/src/assets/cryptocurrencies/cryptocurrencies.json b/apps/api/src/assets/cryptocurrencies/cryptocurrencies.json index 7ca2d568f..ea88dd4c1 100644 --- a/apps/api/src/assets/cryptocurrencies/cryptocurrencies.json +++ b/apps/api/src/assets/cryptocurrencies/cryptocurrencies.json @@ -193,6 +193,7 @@ "AARK": "Aark", "AART": "ALL.ART", "AAST": "AASToken", + "AASTEROID": "Alien Asteroid", "AAT": "Agricultural Trade Chain", "AAVAWBTC": "Aave aWBTC", "AAVE": "Aave", @@ -280,7 +281,7 @@ "ACTA": "Acta Finance", "ACTIN": "Actinium", "ACTN": "Action Coin", - "ACU": "ACU Platform", + "ACU": "Acurast Token", "ACX": "Across Protocol", "ACXT": "ACDX Exchange Token", "ACYC": "All Coins Yield Capital", @@ -389,7 +390,7 @@ "AGATA": "Agatech", "AGATOKEN": "AGA Token", "AGB": "Apes Go Bananas", - "AGC": "Argocoin", + "AGC": "Alien Green Cat", "AGEN": "Agent Krasnov", "AGENT": "AgentLayer", "AGENTFUN": "AgentFun.AI", @@ -417,6 +418,7 @@ "AGOV": "Answer Governance", "AGPC": "AGPC", "AGRI": "AgriDex Token", + "AGRICULTURALUNIONS": "Agricultural Unions", "AGRO": "Bit Agro", "AGRS": "Agoras Token", "AGS": "Aegis", @@ -605,6 +607,7 @@ "ALBART": "Albärt", "ALBE": "ALBETROS", "ALBEDO": "ALBEDO", + "ALBON": "Albemarle (Ondo Tokenized)", "ALBT": "AllianceBlock", "ALC": "Arab League Coin", "ALCAZAR": "Alcazar", @@ -700,6 +703,7 @@ "ALTMAN": "SAM", "ALTOCAR": "AltoCar", "ALTR": "Altranium", + "ALTSZN": "ALTSEASON", "ALTT": "Altcoinist", "ALU": "Altura", "ALUSD": "Alchemix USD", @@ -994,6 +998,7 @@ "ARG": "Argentine Football Association Fan Token", "ARGENTUM": "Argentum", "ARGO": "ArGoApp", + "ARGOCOIN": "Argocoin", "ARGON": "Argon", "ARGUS": "ArgusCoin", "ARI": "AriCoin", @@ -1123,10 +1128,11 @@ "ASTA": "ASTA", "ASTER": "Aster", "ASTERINU": "Aster INU", - "ASTEROID": "ASTEROID", + "ASTEROID": "Asteroid Shiba", "ASTEROIDBOT": "Asteroid Bot", "ASTEROIDCOIN": "ASTEROID", "ASTEROIDETH": "Asteroid", + "ASTEROIDFIT": "ASTEROID", "ASTHERUSUSDF": "Astherus USDF", "ASTO": "Altered State Token", "ASTON": "Aston", @@ -1396,7 +1402,6 @@ "BABI": "Babylons", "BABL": "Babylon Finance", "BABY": "Babylon", - "BABY4": "Baby 4", "BABYANDY": "Baby Andy", "BABYASTER": "Baby Aster", "BABYB": "Baby Bali", @@ -1778,7 +1783,7 @@ "BDID": "BDID", "BDIN": "BendDAO BDIN", "BDL": "Bitdeal", - "BDOG": "Bulldog Token", + "BDOG": "BurnDog", "BDOGITO": "BullDogito", "BDOT": "Binance Wrapped DOT", "BDP": "Big Data Protocol", @@ -1846,6 +1851,7 @@ "BELA": "Bela", "BELG": "Belgian Malinois", "BELIEVE": "Believe", + "BELKA": "The Dancing Squirrel", "BELL": "Bellscoin", "BELLE": "Isabelle", "BELLS": "Bellscoin", @@ -1882,8 +1888,9 @@ "BERN": "BERNcash", "BERNIE": "BERNIE SENDERS", "BERRIE": "Berrie Token", - "BERRY": "Berry", + "BERRY": "Strawberry AI", "BERRYS": "BerrySwap", + "BERRYSTORE": "Berry", "BERT": "Bertram The Pomeranian", "BES": "battle esports coin", "BESA": "Besa Gaming", @@ -1935,6 +1942,7 @@ "BGB": "Bitget token", "BGBG": "BigMouthFrog", "BGBP": "Binance GBP Stable Coin", + "BGBTC": "Bitget Wrapped BTC", "BGBV1": "Bitget Token v1", "BGC": "Bee Token", "BGCI": "Bloomberg Galaxy Crypto Index", @@ -1989,6 +1997,7 @@ "BIDI": "Bidipass", "BIDP": "BID Protocol", "BIDR": "Binance IDR Stable Coin", + "BIDUON": "Baidu (Ondo Tokenized)", "BIDZ": "BIDZ Coin", "BIDZV1": "BIDZ Coin v1", "BIFI": "Beefy.Finance", @@ -2133,6 +2142,7 @@ "BITTO": "BITTO", "BITTON": "Bitton", "BITTY": "The Bitcoin Mascot", + "BITUBU": "UBU", "BITUPTOKEN": "BitUP Token", "BITUSD": "bitUSD", "BITV": "Bitvolt", @@ -2196,6 +2206,7 @@ "BLC": "BlakeCoin", "BLCT": "Bloomzed Loyalty Club Ticket", "BLD": "Agoric", + "BLEND": "Fluent", "BLENDR": "Blendr Network", "BLEPE": "Blepe", "BLERF": "BLERF", @@ -2218,7 +2229,7 @@ "BLKC": "BlackHat Coin", "BLKD": "Blinked", "BLKS": "Blockshipping", - "BLM": "Blombard", + "BLM": "BLM coin", "BLN": "Bulleon", "BLNM": "Bolenum", "BLOB": "B.O.B the Blob", @@ -2247,6 +2258,7 @@ "BLOCX": "BLOCX.", "BLOGGE": "Bloggercube", "BLOK": "Bloktopia", + "BLOMBARD": "Blombard", "BLOO": "bloo foster coin", "BLOOCYS": "BlooCYS", "BLOODY": "Bloody Token", @@ -2880,6 +2892,7 @@ "BTFA": "Banana Task Force Ape", "BTG": "Bitcoin Gold", "BTGON": "B2Gold (Ondo Tokenized)", + "BTGOON": "BitGo Holdings (Ondo Tokenized)", "BTH": "Bithereum", "BTK": "Bostoken", "BTL": "Bitlocus", @@ -2973,11 +2986,12 @@ "BUL": "bul", "BULDAK": "Buldak", "BULEI": "Bulei", - "BULL": "Tron Bull", + "BULL": "Bull", "BULLA": "BULLA", "BULLBEAR": "BullBear AI", "BULLC": "BuySell", "BULLDOG": "BullDog Coin", + "BULLDOGTOKEN": "Bulldog Token", "BULLF": "BULL FINANCE", "BULLGOD": "Bull God", "BULLI": "Bullish On Ethereum", @@ -3392,7 +3406,6 @@ "CDOGE": "cyberdoge", "CDPT": "Creditor Data Platform", "CDRX": "CDRX", - "CDT": "CheckDot", "CDX": "CDX Network", "CDY": "Bitcoin Candy", "CDragon": "Clumsy Dragon", @@ -3513,6 +3526,7 @@ "CHC": "ChainCoin", "CHD": "CharityDAO", "CHECK": "Checkmate", + "CHECKDOT": "CheckDot", "CHECKR": "CheckerChain", "CHECOIN": "CheCoin", "CHED": "Giggleched", @@ -3573,6 +3587,7 @@ "CHINU": "Chubby Inu", "CHIP": "Chip", "CHIPI": "chipi", + "CHIPP": "Chip", "CHIPPY": "Chippy", "CHIPS": "CHIPS", "CHIRP": "Chirp Token", @@ -4350,6 +4365,7 @@ "CVX": "Convex Finance", "CVXCRV": "Convex CRV", "CVXFXS": "Convex FXS", + "CVXON": "Chevron (Ondo Tokenized)", "CVXX": "Chevron xStock", "CW": "CardWallet", "CWA": "Chris World Asset", @@ -4741,6 +4757,7 @@ "DEPAY": "DePay", "DEPIN": "DEPIN", "DEPINU": "Depression Inu", + "DEPLOYR": "Deployr", "DEPO": "Depo", "DEPTH": "Depth Token", "DEQ": "Dequant", @@ -5363,6 +5380,7 @@ "DUA": "Brillion", "DUAL": "DUAL", "DUALDAOTOKEN": "Dual Finance", + "DUALV1": "BLOCKv", "DUB": "DubCoin", "DUBAICAT": "Dubai Cat", "DUBBZ": "Dubbz", @@ -5548,6 +5566,7 @@ "ECHO": "Echo", "ECHOBOT": "ECHO BOT", "ECHOD": "EchoDEX", + "ECHON": "iShares MSCI Chile ETF (Ondo Tokenized)", "ECHT": "e-Chat", "ECI": "Euro Cup Inu", "ECKODAO": "eckoDAO", @@ -6110,6 +6129,7 @@ "EVERGROW": "EverGrowCoin", "EVERLIFE": "EverLife.AI", "EVERMOON": "EverMoon", + "EVERRISE": "EverRise", "EVERV": "EverValue Coin", "EVERY": "Everyworld", "EVIL": "EvilCoin", @@ -6160,6 +6180,7 @@ "EXN": "Exeno", "EXNT": "EXNT", "EXO": "Exosis", + "EXODON": "Exodus Movement (Ondo Tokenized)", "EXOS": "Exobots", "EXP": "Expanse", "EXPAND": "Gems", @@ -6213,6 +6234,7 @@ "FACTR": "Defactor", "FACTRPAY": "FactR", "FACY": "ArAIstotle Fact Checker", + "FADEWALLET": "FadeWallet Token", "FADO": "FADO Go", "FAFO": "FAFO", "FAFOSOL": "Fafo", @@ -6312,6 +6334,7 @@ "FCT": "FirmaChain", "FCTC": "FaucetCoin", "FCTR": "FactorDAO", + "FCXON": "Freeport-McMoRan (Ondo Tokenized)", "FDC": "FDrive Coin", "FDGC": "FINTECH DIGITAL GOLD COIN", "FDLS": "FIDELIS", @@ -6364,6 +6387,7 @@ "FFCT": "FortFC", "FFM": "Files.fm Library", "FFN": "Fairy Forest", + "FFOGON": "Franklin Focused Growth ETF (Ondo Tokenized)", "FFTP": "FIGHT FOR THE PEOPLE", "FFUEL": "getFIFO", "FFYI": "Fiscus FYI", @@ -6535,6 +6559,7 @@ "FLOOR": "FloorDAO", "FLOP": "Big Floppa", "FLOPPA": "Floppa Cat", + "FLOR": "FLORK", "FLORK": "FLORK BNB", "FLORKY": "Florky", "FLOSHIDO": "FLOSHIDO INU", @@ -6549,6 +6574,7 @@ "FLOWP": "Flow Protocol", "FLOYX": "Floyx", "FLP": "Gameflip", + "FLQLON": "Franklin US Large Cap Multifactor Index ETF (Ondo Tokenized)", "FLR": "Flare", "FLRBRG": "Floor Cheese Burger", "FLRS": "Flourish Coin", @@ -6802,6 +6828,7 @@ "FSN": "Fusion", "FSNV1": "Fusion v1", "FSO": "FSociety", + "FSOLON": "Fidelity Solana Fund (Ondo Tokenized)", "FST": "FreeStyle Token", "FSTC": "FastCoin", "FSTR": "Fourth Star", @@ -7131,6 +7158,7 @@ "GENS": "Genshiro", "GENSLR": "Good Gensler", "GENSTAKE": "Genstake", + "GENSYN": "Gensyn", "GENT": "Gentleman", "GENX": "Genx Token", "GENXNET": "Genesis Network", @@ -7313,6 +7341,7 @@ "GLR": "Glory Finance", "GLS": "Glacier", "GLT": "GlobalToken", + "GLTRON": "abrdn Physical Precious Metals Basket Shares ETF (Ondo Tokenized)", "GLUE": "Glue", "GLX": "GalaxyCoin", "GLYPH": "GlyphCoin", @@ -7569,10 +7598,11 @@ "GRIDZ": "GridZone.io", "GRIFFAIN": "GRIFFAIN", "GRIFT": "ORBIT", - "GRIM": "GRIMREAPER", + "GRIM": "GrimHustle", "GRIMACE": "Grimace", "GRIMEVO": "Grim EVO", "GRIMEX": "SpaceGrime", + "GRIMREAPER": "GRIMREAPER", "GRIN": "Grin", "GRIND": "Self Improving", "GRIPPY": "GRIPPY", @@ -7955,6 +7985,7 @@ "HEWE": "Health & Wealth", "HEX": "HEX", "HEXC": "HexCoin", + "HEYFLORK": "HeyFlork", "HEZ": "Hermez Network Token", "HF": "Have Fun", "HFI": "Holder Finance", @@ -8348,11 +8379,12 @@ "IBG": "iBG Token", "IBGT": "Infrared BGT", "IBIT": "InfinityBit Token", + "IBITON": "iShares Bitcoin Trust (Ondo Tokenized)", "IBMX": "International Business Machines xStock", "IBNB": "iBNB", "IBP": "Innovation Blockchain Payment", "IBS": "Irbis Network", - "IC": "Ignition", + "IC": "Icy", "ICA": "Icarus Network", "ICAP": "ICAP Token", "ICASH": "ICASH", @@ -8442,6 +8474,7 @@ "IGGT": "The Invincible Game Token", "IGI": "Igi", "IGNIS": "Ignis", + "IGNITION": "Ignition", "IGT": "Infinitar", "IGTT": "IGT", "IGU": "IguVerse", @@ -8496,7 +8529,6 @@ "IMS": "Independent Money System", "IMST": "Imsmart", "IMT": "Immortal Token", - "IMU": "Immunefi", "IMUSIFY": "imusify", "IMVR": "ImmVRse", "IMX": "Immutable X", @@ -8509,11 +8541,13 @@ "INCEPT": "Incept", "INCNT": "Incent", "INCO": "InfinitiCoin", + "INCOME": "Universal High Income", "INCORGNITO": "Incorgnito", "INCP": "InceptionCoin", "INCREMENTUM": "Incrementum", "INCX": "INCX Coin", "IND": "Indorse", + "INDAON": "iShares MSCI India ETF (Ondo Tokenized)", "INDAY": "Independence Day", "INDEPENDENCEDAY": "Independence Day", "INDEX": "Index Cooperative", @@ -8621,6 +8655,7 @@ "IONC": "IONChain", "IONOMY": "Ionomy", "IONP": "Ion Power Token", + "IONQON": "IonQ (Ondo Tokenized)", "IONX": "Charged Particles", "IONZ": "IONZ", "IOP": "Internet of People", @@ -8704,6 +8739,7 @@ "ITALOCOIN": "Italocoin", "ITAM": "ITAM Games", "ITAMCUBE": "CUBE", + "ITAON": "iShares US Aerospace and Defense ETF (Ondo Tokenized)", "ITC": "IoT Chain", "ITE": "Idle Tribe Era", "ITEM": "ITEMVERSE", @@ -9377,6 +9413,7 @@ "KONET": "KONET", "KONG": "KONG", "KONO": "Konomi Network", + "KOON": "Coca-Cola (Ondo Tokenized)", "KORA": "Kortana", "KORC": "King of Referral Coin", "KORE": "KORE Vault", @@ -9490,6 +9527,7 @@ "KWAI": "KWAI", "KWATT": "4New", "KWD": "KIWI DEFI", + "KWEBON": "KraneShares CSI China Internet ETF (Ondo Tokenized)", "KWEEN": "KWEEN", "KWENTA": "Kwenta", "KWH": "KWHCoin", @@ -9877,6 +9915,7 @@ "LMR": "Lumerin", "LMT": "LIMITUS", "LMTOKEN": "LM Token", + "LMTON": "Lockheed (Ondo Tokenized)", "LMTS": "Limitless Official Token", "LMWR": "LimeWire Token", "LMXC": "LimonX", @@ -10118,6 +10157,7 @@ "LUNES": "Lunes", "LUNG": "LunaGens", "LUNR": "Lunr Token", + "LUNRON": "Intuitive Machines (Ondo Tokenized)", "LUPIN": "LUPIN", "LUR": "Lumera", "LUS": "Luna Rush", @@ -10295,7 +10335,8 @@ "MANUSAI": "Manus AI Agent", "MANYU": "Manyu", "MANYUDOG": "MANYU", - "MAO": "Mao", + "MAO": "MAO", + "MAOMEME": "Mao", "MAOW": "MAOW", "MAP": "MAP Protocol", "MAPC": "MapCoin", @@ -11573,6 +11614,7 @@ "NBAR": "NOBAR", "NBC": "Niobium", "NBD": "Never Back Down", + "NBISON": "Nebius Group (Ondo Tokenized)", "NBIT": "NetBit", "NBL": "Nobility", "NBLU": "NuriTopia", @@ -11649,6 +11691,7 @@ "NEKOS": "Nekocoin", "NEKTAR": "Nektar Token", "NEMO": "NEMO", + "NEMON": "Newmont (Ondo Tokenized)", "NEMS": "The Nemesis", "NEO": "NEO", "NEOG": "NEO Gold", @@ -11938,6 +11981,7 @@ "NOVA": "Nova Finance", "NOVAAI": "Nova AI", "NOW": "NOW Token", + "NOWON": "ServiceNow (Ondo Tokenized)", "NOX": "NITRO", "NOXB": "Noxbox", "NPAS": "New Paradigm Assets Solution", @@ -12218,6 +12262,7 @@ "OHNOGG": "OHNHO (ohno.gg)", "OHO": "OHO", "OICOIN": "Osmium Investment Coin", + "OIHON": "VanEck Oil Services ETF (Ondo Tokenized)", "OIIAOIIA": "spinning cat", "OIK": "Space Nation", "OIL": "Oiler", @@ -12314,8 +12359,9 @@ "ONION": "DeepOnion", "ONIT": "ONBUFF", "ONIX": "Onix", - "ONL": "On.Live", + "ONL": "OneLink", "ONLINE": "Onlinebase", + "ONLIVE": "On.Live", "ONLY": "OnlyCam", "ONLYCUMIES": "OnlyCumies", "ONLYFANSCOINS": "$OFC Coin", @@ -12375,6 +12421,7 @@ "OPES": "Opes", "OPET": "ÕpetFoundation", "OPEX": "Optherium Token", + "OPG": "OpenGradient", "OPHX": "Operation Phoenix", "OPINU": "Optimus Inu", "OPIUM": "Opium", @@ -12675,6 +12722,7 @@ "PATRIOT": "Patriot", "PATTON": "Patton", "PAUL": "Elephant Penguin", + "PAVEON": "Global X US Infrastructure Development ETF (Ondo Tokenized)", "PAVIA": "Pavia", "PAVO": "Pavocoin", "PAW": "PAWSWAP", @@ -13397,6 +13445,7 @@ "PPI": "Primpy", "PPIZZA": "P Pizza", "PPL": "Pink Panther Lovers", + "PPLTON": "abrdn Physical Platinum Shares ETF (Ondo Tokenized)", "PPM": "Punk Panda Messenger", "PPN": "Puppies Network", "PPOVR": "POVR", @@ -13443,6 +13492,7 @@ "PRIMATE": "Primate", "PRIME": "Echelon Prime", "PRIMECHAIN": "PrimeChain", + "PRIMECOIN": "PrimeCoin", "PRIMEETH": "Prime Staked ETH", "PRIMEX": "Primex Finance", "PRIN": "Print The Pepe", @@ -13792,6 +13842,7 @@ "QUBE": "Qube", "QUBIC": "Qubic", "QUBITICA": "Qubitica", + "QUBTON": "Quantum Computing (Ondo Tokenized)", "QUBY": "Quby", "QUDEFI": "Qudefi", "QUE": "Queen Of Memes", @@ -13966,6 +14017,7 @@ "RDR": "Rise of Defenders", "RDS": "Reger Diamond", "RDT": "Ridotto", + "RDWON": "Redwire (Ondo Tokenized)", "RDX": "Redux Protocol", "REA": "Realisto", "REACH": "/Reach", @@ -14028,6 +14080,7 @@ "REFI": "Realfinance Network", "REFLECT": "REFLECT", "REFLECTO": "Reflecto", + "REFLECTOUSD": "Reflecto USD", "REFTOKEN": "RefToken", "REFUND": "Refund", "REG": "RealToken Ecosystem Governance", @@ -14037,6 +14090,7 @@ "REGEN": "Regen Network", "REGENT": "REGENT COIN", "REGI": "Resistance Girl", + "REGNON": "Regeneron Pharmaceuticals (Ondo Tokenized)", "REGRET": "Regret", "REHA": "Resistance Hamster", "REHAB": "NFT Rehab", @@ -14202,7 +14256,7 @@ "RIPT": "RiptideCoin", "RIPTO": "RiptoBuX", "RIS": "Riser", - "RISE": "EverRise", + "RISE": "Rise NASA", "RISECOIN": "Rise coin", "RISEP": "Rise Protocol", "RISEVISION": "Rise", @@ -14227,6 +14281,7 @@ "RKC": "Royal Kingdom Coin", "RKEY": "RKEY", "RKI": "RAKHI", + "RKLBON": "Rocket Lab (Ondo Tokenized)", "RKN": "RAKON", "RKR": "REAKTOR", "RKT": "Rock Token", @@ -14426,6 +14481,7 @@ "RUGPROOF": "Launchpad", "RUGPULL": "Captain Rug Pull", "RUGZ": "pulltherug.finance", + "RUJI": "Rujira", "RULER": "Ruler Protocol", "RUM": "RUM Pirates of The Arrland Token", "RUN": "Speedrun", @@ -14440,7 +14496,7 @@ "RUP": "Rupee", "RUPX": "Rupaya", "RURI": "Ruri - Truth Terminal's Crush", - "RUSD": "Reflecto USD", + "RUSD": "Royal Dollar", "RUSH": "RUSH COIN", "RUSHCMC": "RUSHCMC", "RUSSELL": "Russell", @@ -14659,6 +14715,7 @@ "SBSC": "Subscriptio", "SBT": "SOLBIT", "SBTC": "Super Bitcoin", + "SBUXON": "Starbucks (Ondo Tokenized)", "SC": "Siacoin", "SC20": "Shine Chain", "SCA": "Scallop", @@ -14675,6 +14732,7 @@ "SCASH": "SpaceCash", "SCAT": "Sad Cat Token", "SCC": "StockChain Coin", + "SCCOON": "Southern Copper (Ondo Tokenized)", "SCCP": "S.C. Corinthians Fan Token", "SCDS": "Shrine Cloud Storage Network", "SCF": "Smoking Chicken Fish", @@ -15054,6 +15112,7 @@ "SHX": "Stronghold Token", "SHXV1": "Stronghold Token v1", "SHY": "Shytoshi Kusama", + "SHYON": "iShares 1-3 Year Treasury Bond ETF (Ondo Tokenized)", "SHYTCOIN": "ShytCoin", "SI": "Siren", "SI14": "Si14", @@ -15248,6 +15307,7 @@ "SLUGDENG": "SLUG DENG", "SLUMBO": "SLUMBO", "SLVLUSD": "Staked Level USD", + "SLVN": "SLVNToken", "SLVON": "iShares Silver Trust (Ondo Tokenized)", "SLVX": "eToro Silver", "SLX": "SLIMEX", @@ -15339,6 +15399,7 @@ "SNC": "SunContract", "SNCT": "SnakeCity", "SND": "Sandcoin", + "SNDKON": "SanDisk (Ondo Tokenized)", "SNE": "StrongNode", "SNEED": "Sneed", "SNEK": "Snek", @@ -15632,10 +15693,11 @@ "SPIDERMAN": "SPIDERMAN BITCOIN", "SPIDEY": "Spidey", "SPIK": "Spike", - "SPIKE": "Spiking", + "SPIKE": "SPIKE", "SPIKE1984": "Spike 1984", "SPIKECOIN": "SPIKE", "SPIKEFURIE": "SPIKE", + "SPIKING": "Spiking", "SPILLWAYS": "SpillWays", "SPIN": "SPIN Protocol", "SPINT": "Spintria", @@ -15934,6 +15996,7 @@ "STRA": "STRAY", "STRAKS": "Straks", "STRAT": "Strategic Hub for Innovation in Blockchain", + "STRAWBE": "Strawberry In Bloom", "STRAX": "Stratis", "STRAY": "Stray Dog", "STRAYDOG": "Stray Dog", @@ -15981,6 +16044,7 @@ "STV": "Sativa Coin", "STWEMIX": "Staked WEMIX", "STX": "Stacks", + "STXON": "Seagate (Ondo Tokenized)", "STYL": "Stylike Governance", "STYLE": "Style", "STZ": "99Starz", @@ -16801,6 +16865,7 @@ "TRADECHAIN": "Trade Chain", "TRADETIDE": "Trade Tide Token", "TRADEX": "TradeX AI", + "TRADIX": "Tradix", "TRADOOR": "Tradoor", "TRAI": "Trackgood AI", "TRAID": "Traid", @@ -16881,6 +16946,7 @@ "TROLLRUN": "TROLL", "TROLLS": "trolls in a memes world", "TRONBETLIVE": "TRONbetLive", + "TRONBULL": "Tron Bull", "TRONDOG": "TronDog", "TRONI": "Tron Inu", "TRONP": "Donald Tronp", @@ -17015,6 +17081,7 @@ "TTM": "Tradetomato", "TTN": "TTN", "TTNT": "TITA Project", + "TTPA": "TRUMPTOPIA", "TTT": "TRUMPETTOKEN", "TTTU": "T-Project", "TTU": "TaTaTu", @@ -17077,7 +17144,7 @@ "TWP": "TrumpWifPanda", "TWT": "Trust Wallet Token", "TWURTLE": "twurtle the turtle", - "TX": "Tradix", + "TX": "tx", "TX20": "Trex20", "TXA": "TXA", "TXAG": "tSILVER", @@ -17160,6 +17227,7 @@ "UDT": "Unlock Protocol", "UE": "UE Coin", "UEC": "United Emirates Coin", + "UECON": "Uranium Energy (Ondo Tokenized)", "UEDC": "United Emirate Decentralized Coin", "UENC": "UniversalEnergyChain", "UET": "Useless Ethereum Token", @@ -17239,6 +17307,7 @@ "UNF": "Unfed Coin", "UNFI": "Unifi Protocol DAO", "UNFK": "UNFK", + "UNGON": "US Natural Gas Fund (Ondo Tokenized)", "UNHX": "UnitedHealth xStock", "UNI": "Uniswap Protocol Token", "UNIART": "UNIART", @@ -17276,6 +17345,7 @@ "UNITE": "Unite", "UNITED": "UnitedCoins", "UNITEDTRADERS": "United Traders Token", + "UNITOKEN": "Uni Token", "UNITPROV2": "Unit Protocol New", "UNITRADE": "UniTrade", "UNITREEAI": "Unitree G1 AI", @@ -17292,17 +17362,18 @@ "UNO": "UnoRe", "UNOB": "Unobtanium", "UNP": "UNIPOLY", + "UNPON": "Union Pacific Corporation (Ondo Tokenized)", "UNQ": "UNQ", "UNQT": "Unique Utility Token", "UNR": "Unirealchain", "UNRC": "UniversalRoyalCoin", "UNS": "UNS TOKEN", "UNSHETH": "unshETH Ether", - "UNT": "Uni Token", + "UNT": "UnityWallet Token", "UNW": "UniWorld", "UOP": "Utopia Genesis Foundation", "UOS": "UOS", - "UP": "UpToken", + "UP": "Superform", "UPC": "UPCX", "UPCG": "Upcomings", "UPCO2": "Universal Carbon", @@ -17317,6 +17388,7 @@ "UPRO": "ULTRAPRO", "UPS": "UPFI Network", "UPT": "UPROCK", + "UPTOKEN": "UpToken", "UPTOP": "UPTOP", "UPTOS": "UPTOS", "UPUNK": "Unicly CryptoPunks Collection", @@ -17327,6 +17399,7 @@ "URAC": "Uranus", "URALS": "Urals Coin", "URANUS": "Uranus", + "URAON": "Global X Uranium ETF (Ondo Tokenized)", "URFA": "Urfaspor Token", "URMOM": "urmom", "URO": "Urolithin A", @@ -17575,7 +17648,7 @@ "VEC2": "VectorCoin 2.0", "VECT": "Vectorium", "VECTOR": "VectorChat.ai", - "VEE": "BLOCKv", + "VEE": "Vee Token", "VEED": "VEED", "VEEN": "LIVEEN", "VEETOKEN": "Vee Token", @@ -17638,6 +17711,7 @@ "VEXT": "Veloce", "VFIL": "Venus Filecoin", "VFOX": "VFOX", + "VFSON": "VinFast Auto (Ondo Tokenized)", "VFT": "Value Finance", "VFX": "ViFoxCoin", "VFY": "zkVerify", @@ -17758,6 +17832,7 @@ "VNM": "Venom", "VNN": "VINU Network", "VNO": "Veno Finance", + "VNQON": "Vanguard Real Estate ETF (Ondo Tokenized)", "VNST": "VNST Stablecoin", "VNT": "VNT Chain", "VNTR": "Venture Mind AI", @@ -17831,6 +17906,7 @@ "VRSW": "VirtuSwap", "VRT": "Venus Reward Token", "VRTX": "Vertex Protocol", + "VRTXON": "Vertex Pharmaceuticals (Ondo Tokenized)", "VRTY": "Verity", "VRX": "Verox", "VS": "veSync", @@ -17844,6 +17920,7 @@ "VSOL": "VSolidus", "VSP": "Vesper Finance", "VSTA": "Vesta Finance", + "VSTON": "Vistra (Ondo Tokenized)", "VSTR": "Vestra DAO", "VSUI": "Volo Staked SUI", "VSX": "Versus-X", @@ -18038,6 +18115,7 @@ "WCUSD": "Wrapped Celo Dollar", "WDAI": "Dai (Wormhole)", "WDC": "WorldCoin", + "WDCON": "Western Digital (Ondo Tokenized)", "WDOG": "Winterdog", "WDOGE": "Wrapped Dogecoin", "WDOT": "WDOT", @@ -18273,6 +18351,7 @@ "WMM": "Weird Medieval Memes", "WMN": "WebMind Network", "WMNT": "Wrapped Mantle", + "WMON": "Waste Management (Ondo Tokenized)", "WMOXY": "Moxy", "WMT": "World Mobile Token v1", "WMTON": "Walmart (Ondo Tokenized)", @@ -18304,9 +18383,10 @@ "WOID": "WORLD ID", "WOJ": "Wojak Finance", "WOJA": "Wojak", - "WOJAK": "Wojak", + "WOJAK": "wojak", "WOJAK2": "Wojak 2.0 Coin", "WOJAKC": "Wojak Coin", + "WOJAKIO": "Wojak", "WOKB": "Wrapped OKB", "WOKIE": "Wokie Plumpkin by Virtuals", "WOKT": "Wrapped OKT", @@ -18462,6 +18542,7 @@ "WXRP": "Wrapped XRP", "WXT": "WXT", "WYAC": "Woman Yelling At Cat", + "WYDE": "WYDE: End Hunger", "WYN": "Wynn", "WYNN": "Anita Max Wynn", "WYS": "Wysker", @@ -18683,6 +18764,7 @@ "XMN": "xMoney", "XMO": "Monero Original", "XMON": "XMON", + "XMONEY": "X Money", "XMOON": "r/CryptoCurrency Moons v1", "XMP": "Mapt.Coin", "XMR": "Monero", @@ -18741,7 +18823,7 @@ "XPL": "Plasma", "XPLA": "XPLA", "XPLL": "ParallelChain", - "XPM": "PrimeCoin", + "XPM": "XPMarket Token", "XPN": "PANTHEON X", "XPND": "Time Raiders", "XPNET": "XP Network", @@ -18860,6 +18942,7 @@ "XVS": "Venus", "XWC": "WhiteCoin", "XWG": "X World Games", + "XWGT": "Wodo Gaming Token", "XWIN": "xWIN Finance", "XWP": "Swap", "XWT": "World Trade Funds", @@ -19102,7 +19185,8 @@ "ZEBU": "ZEBU", "ZEC": "ZCash", "ZECD": "ZCashDarkCoin", - "ZED": "ZedCoins", + "ZED": "ZED Token", + "ZEDCOIN": "ZedCoin", "ZEDD": "ZedDex", "ZEDTOKEN": "Zed Token", "ZEDX": "ZEDX Сoin", From 80de33fdcb0a9b824a1bd85b729191b649237e1e Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 7 May 2026 18:12:03 +0300 Subject: [PATCH 041/132] Task/upgrade @ionic/angular to version 8.8.5 (#6829) * Upgrade @ionic/angular to version 8.8.5 * Update changelog --- CHANGELOG.md | 1 + package-lock.json | 16 ++++++++-------- package.json | 2 +- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 246e5e289..91999d3dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Migrated various components from `NgClass` to class bindings - Refreshed the cryptocurrencies list +- Upgraded `@ionic/angular` from version `8.8.1` to `8.8.5` - Upgraded `Nx` from version `22.6.5` to `22.7.1` ### Fixed diff --git a/package-lock.json b/package-lock.json index e730f44f0..07e6116d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,7 @@ "@codewithdan/observable-store": "2.2.15", "@date-fns/utc": "2.1.1", "@internationalized/number": "3.6.5", - "@ionic/angular": "8.8.1", + "@ionic/angular": "8.8.5", "@keyv/redis": "4.4.0", "@nestjs/bull": "11.0.4", "@nestjs/cache-manager": "3.1.0", @@ -5005,12 +5005,12 @@ } }, "node_modules/@ionic/angular": { - "version": "8.8.1", - "resolved": "https://registry.npmjs.org/@ionic/angular/-/angular-8.8.1.tgz", - "integrity": "sha512-Jp7LbouSHAnR00Dsa8qE1CSOZNqAfBCO0XKXScJNz8NKVoZe5fPGy/CboehGtAQ1xgzh2eDa15zMmyetXjAkYA==", + "version": "8.8.5", + "resolved": "https://registry.npmjs.org/@ionic/angular/-/angular-8.8.5.tgz", + "integrity": "sha512-wUYKPhLzyrlRIoqM5lk0wCx2CCebxHdQBMXZdBQBvE69XlqEmkoxbbPW0YpYJvY5wkhZaDa2lzLIHo/lfijqqQ==", "license": "MIT", "dependencies": { - "@ionic/core": "8.8.1", + "@ionic/core": "8.8.5", "ionicons": "^8.0.13", "jsonc-parser": "^3.0.0", "tslib": "^2.3.0" @@ -5024,9 +5024,9 @@ } }, "node_modules/@ionic/core": { - "version": "8.8.1", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.8.1.tgz", - "integrity": "sha512-ksOUHyOEqoyUIVWcwCNSFZVGwNfP1DKrUVeN/Cdk/Xl9Rdd/5MLHGsrOQpWQfoCf3Csdnw+KHHPrXz/2fzMkMA==", + "version": "8.8.5", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.8.5.tgz", + "integrity": "sha512-gVaEeLVNn6QGTmxmiC5J82k0WYEqDSuP6Hxmf4zHsBQptlIDmVOmbqzz8BIevQgb8q/s7ykwE0NGyqRu4BVsqg==", "license": "MIT", "dependencies": { "@stencil/core": "4.43.0", diff --git a/package.json b/package.json index 0d7c961cc..2d7b56031 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "@codewithdan/observable-store": "2.2.15", "@date-fns/utc": "2.1.1", "@internationalized/number": "3.6.5", - "@ionic/angular": "8.8.1", + "@ionic/angular": "8.8.5", "@keyv/redis": "4.4.0", "@nestjs/bull": "11.0.4", "@nestjs/cache-manager": "3.1.0", From c2dc2163054a6b91fd562bc1051133eca7b5d5d2 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 8 May 2026 07:35:23 +0300 Subject: [PATCH 042/132] Task/upgrade fuse.js to version 7.3.0 (#6819) * Upgrade fuse.js to version 7.3.0 * Update changelog --- CHANGELOG.md | 1 + package-lock.json | 12 ++++++++---- package.json | 2 +- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91999d3dd..d679654f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Migrated various components from `NgClass` to class bindings - Refreshed the cryptocurrencies list - Upgraded `@ionic/angular` from version `8.8.1` to `8.8.5` +- Upgraded `fuse.js` from version `7.1.0` to `7.3.0` - Upgraded `Nx` from version `22.6.5` to `22.7.1` ### Fixed diff --git a/package-lock.json b/package-lock.json index 07e6116d1..ed0bc794e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -68,7 +68,7 @@ "dotenv-expand": "12.0.3", "envalid": "8.1.1", "fast-redact": "3.5.0", - "fuse.js": "7.1.0", + "fuse.js": "7.3.0", "google-spreadsheet": "3.2.0", "helmet": "7.0.0", "http-status-codes": "2.3.0", @@ -23216,12 +23216,16 @@ } }, "node_modules/fuse.js": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz", - "integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.3.0.tgz", + "integrity": "sha512-plz8RVjfcDedTGfVngWH1jmJvBvAwi1v2jecfDerbEnMcmOYUEEwKFTHbNoCiYyzaK2Ws8lABkTCcRSqCY1q4w==", "license": "Apache-2.0", "engines": { "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/krisk" } }, "node_modules/gaxios": { diff --git a/package.json b/package.json index 2d7b56031..c347eeed7 100644 --- a/package.json +++ b/package.json @@ -113,7 +113,7 @@ "dotenv-expand": "12.0.3", "envalid": "8.1.1", "fast-redact": "3.5.0", - "fuse.js": "7.1.0", + "fuse.js": "7.3.0", "google-spreadsheet": "3.2.0", "helmet": "7.0.0", "http-status-codes": "2.3.0", From c7806038d409691d11efb4ded2f0baf4689dca79 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 9 May 2026 08:37:14 +0300 Subject: [PATCH 043/132] Task/upgrade countries-and-timezones to version 3.9.0 (#6817) * Upgrade countries-and-timezones to version 3.9.0 * Update changelog --- CHANGELOG.md | 1 + package-lock.json | 8 ++++---- package.json | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d679654f5..1f80a4399 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Migrated various components from `NgClass` to class bindings - Refreshed the cryptocurrencies list - Upgraded `@ionic/angular` from version `8.8.1` to `8.8.5` +- Upgraded `countries-and-timezones` from version `3.8.0` to `3.9.0` - Upgraded `fuse.js` from version `7.1.0` to `7.3.0` - Upgraded `Nx` from version `22.6.5` to `22.7.1` diff --git a/package-lock.json b/package-lock.json index ed0bc794e..0f1bd2dc7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,7 +60,7 @@ "class-validator": "0.15.1", "color": "5.0.3", "cookie-parser": "1.4.7", - "countries-and-timezones": "3.8.0", + "countries-and-timezones": "3.9.0", "countries-list": "3.3.0", "countup.js": "2.10.0", "date-fns": "4.1.0", @@ -18906,9 +18906,9 @@ } }, "node_modules/countries-and-timezones": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/countries-and-timezones/-/countries-and-timezones-3.8.0.tgz", - "integrity": "sha512-+Ze9h5f4dQpUwbzTm0DEkiPiZyim9VHV4/mSnT4zNYJnrnfwsKjAZPtnp7J5VzejCDgySs+2SSc6MDdCnD43GA==", + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/countries-and-timezones/-/countries-and-timezones-3.9.0.tgz", + "integrity": "sha512-3upkVtUfZgdp5xOQI/Deg1ye73ae6bygJMO4UvtUi6wLjwcj7cBqx2PzSZ1VL8i5iveBbjhsQp2Judx7kPKivA==", "license": "MIT", "engines": { "node": ">=8.x", diff --git a/package.json b/package.json index c347eeed7..efd32c985 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,7 @@ "class-validator": "0.15.1", "color": "5.0.3", "cookie-parser": "1.4.7", - "countries-and-timezones": "3.8.0", + "countries-and-timezones": "3.9.0", "countries-list": "3.3.0", "countup.js": "2.10.0", "date-fns": "4.1.0", From 4ce87eab1a0de6e1d24ba795300f72b2f8ae0ac3 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 10 May 2026 11:37:15 +0300 Subject: [PATCH 044/132] Task/upgrade Bull Board to version 7.0.0 (#6786) * Upgrade bull-board to version 7.0.0 * Update changelog --- CHANGELOG.md | 1 + package-lock.json | 61 +++++++++++++++++++++++++++++++---------------- package.json | 6 ++--- 3 files changed, 44 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f80a4399..1f7d95cb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Migrated various components from `NgClass` to class bindings - Refreshed the cryptocurrencies list - Upgraded `@ionic/angular` from version `8.8.1` to `8.8.5` +- Upgraded `bull-board` from version `6.20.3` to `7.0.0` - Upgraded `countries-and-timezones` from version `3.8.0` to `3.9.0` - Upgraded `fuse.js` from version `7.1.0` to `7.3.0` - Upgraded `Nx` from version `22.6.5` to `22.7.1` diff --git a/package-lock.json b/package-lock.json index 0f1bd2dc7..d061349b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,9 +21,9 @@ "@angular/platform-browser-dynamic": "21.2.7", "@angular/router": "21.2.7", "@angular/service-worker": "21.2.7", - "@bull-board/api": "6.20.3", - "@bull-board/express": "6.20.3", - "@bull-board/nestjs": "6.20.3", + "@bull-board/api": "7.0.0", + "@bull-board/express": "7.0.0", + "@bull-board/nestjs": "7.0.0", "@codewithdan/observable-store": "2.2.15", "@date-fns/utc": "2.1.1", "@internationalized/number": "3.6.5", @@ -3547,36 +3547,48 @@ "license": "(Apache-2.0 AND BSD-3-Clause)" }, "node_modules/@bull-board/api": { - "version": "6.20.3", - "resolved": "https://registry.npmjs.org/@bull-board/api/-/api-6.20.3.tgz", - "integrity": "sha512-cDrsJJsmF4DbbY8/5oHxO4qFtyFjxexsWQKHowsud/8H4mtZN7MZg4fCmNzfaxc9Ov7V6r9Y9F5G2Mq6t7ZEJg==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@bull-board/api/-/api-7.0.0.tgz", + "integrity": "sha512-ISNspLHVmUWUSq/eLw+wd1FuBBUnqpLbYP2xUNmehpfKhS+NoZWMbBvqjUYVeE/HLfUkRcR1edzMKpl5n9zlSw==", "license": "MIT", "dependencies": { "redis-info": "^3.1.0" }, "peerDependencies": { - "@bull-board/ui": "6.20.3" + "@bull-board/ui": "7.0.0" } }, "node_modules/@bull-board/express": { - "version": "6.20.3", - "resolved": "https://registry.npmjs.org/@bull-board/express/-/express-6.20.3.tgz", - "integrity": "sha512-S6BGeSf/PLwjx5W1IrKxoV8G6iiMmLqT/pldZ6BiC1IDldedisTtAdL1z117swXPv1H7/3hy0vr03dUr8bUCPg==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@bull-board/express/-/express-7.0.0.tgz", + "integrity": "sha512-3Tc/EyU5PQMTcTzcafFSrmRDiEbJBEU/EaVQ5OVYcuJ7DZCp5Pkvm0/2VtaCe2uywdtwn0ZaynlSIpB27FKX6A==", "license": "MIT", "dependencies": { - "@bull-board/api": "6.20.3", - "@bull-board/ui": "6.20.3", - "ejs": "^3.1.10", + "@bull-board/api": "7.0.0", + "@bull-board/ui": "7.0.0", + "ejs": "^5.0.2", "express": "^5.2.1" } }, + "node_modules/@bull-board/express/node_modules/ejs": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-5.0.2.tgz", + "integrity": "sha512-IpbUaI/CAW86l3f+T8zN0iggSc0LmMZLcIW5eRVStLVNCoTXkE0YlncbbH50fp8Cl6zHIky0sW2uUbhBqGw0Jw==", + "license": "Apache-2.0", + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.12.18" + } + }, "node_modules/@bull-board/nestjs": { - "version": "6.20.3", - "resolved": "https://registry.npmjs.org/@bull-board/nestjs/-/nestjs-6.20.3.tgz", - "integrity": "sha512-VFi96Z2M8k3G26H1ivzQnpjKszxh90vrUm78VtMZH/sh8wjm88mJFDXcOgFutOaddx7cc9VNXlKsTTcu6okPFQ==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@bull-board/nestjs/-/nestjs-7.0.0.tgz", + "integrity": "sha512-ypXm0eJHIMQzjN+3fjf84cVxugBg/K4Bpo0eYcV4u/AsteR/dnr6e7F79ICRgg1WWoczqmSMl0JhlmykpyhAMg==", "license": "MIT", "peerDependencies": { - "@bull-board/api": "^6.20.3", + "@bull-board/api": "^7.0.0", "@nestjs/bull-shared": "^10.0.0 || ^11.0.0", "@nestjs/common": "^9.0.0 || ^10.0.0 || ^11.0.0", "@nestjs/core": "^9.0.0 || ^10.0.0 || ^11.0.0", @@ -3585,12 +3597,12 @@ } }, "node_modules/@bull-board/ui": { - "version": "6.20.3", - "resolved": "https://registry.npmjs.org/@bull-board/ui/-/ui-6.20.3.tgz", - "integrity": "sha512-oANyYoW0X+xd0j/09DRyh3u7Q3wqBtXiLEWyZUJIi/Bjp/hINwiw20RwWuRcaFkqkFylEJL9l+pjmeSA9X5L2A==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@bull-board/ui/-/ui-7.0.0.tgz", + "integrity": "sha512-AnKeklpDn0iMFgu4ukDU6uTNmw4oudl07G4k2Fh95SknKDrXSiWRV0N1TGUawMqyfG1Yi5P/W/8d7raBq/Uw6w==", "license": "MIT", "dependencies": { - "@bull-board/api": "6.20.3" + "@bull-board/api": "7.0.0" } }, "node_modules/@cacheable/utils": { @@ -16843,6 +16855,7 @@ "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, "license": "MIT" }, "node_modules/async-function": { @@ -17160,6 +17173,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, "license": "MIT" }, "node_modules/base64-js": { @@ -21132,6 +21146,7 @@ "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "jake": "^10.8.5" @@ -22542,6 +22557,7 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "minimatch": "^5.0.1" @@ -22551,6 +22567,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -22560,6 +22577,7 @@ "version": "5.1.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -25476,6 +25494,7 @@ "version": "10.9.4", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "async": "^3.2.6", diff --git a/package.json b/package.json index efd32c985..9ed027e9c 100644 --- a/package.json +++ b/package.json @@ -66,9 +66,9 @@ "@angular/platform-browser-dynamic": "21.2.7", "@angular/router": "21.2.7", "@angular/service-worker": "21.2.7", - "@bull-board/api": "6.20.3", - "@bull-board/express": "6.20.3", - "@bull-board/nestjs": "6.20.3", + "@bull-board/api": "7.0.0", + "@bull-board/express": "7.0.0", + "@bull-board/nestjs": "7.0.0", "@codewithdan/observable-store": "2.2.15", "@date-fns/utc": "2.1.1", "@internationalized/number": "3.6.5", From 4da5c3cac9a2b3b9870e303614130949db238376 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Mon, 11 May 2026 08:04:49 +0300 Subject: [PATCH 045/132] Task/upgrade ai to version 6.0.174 (#6828) * Upgrade ai * @openrouter/ai-sdk-provider to version 2.9.0 * ai to version 6.0.174 * Add AI service health check and improve layout * Update changelog --- CHANGELOG.md | 2 + apps/api/src/app/endpoints/ai/ai.module.ts | 1 + apps/api/src/app/endpoints/ai/ai.service.ts | 13 +- apps/api/src/app/health/health.controller.ts | 31 ++- apps/api/src/app/health/health.module.ts | 2 + .../src/app/pages/api/api-page.component.ts | 65 ++++- apps/client/src/app/pages/api/api-page.html | 244 ++++++++++++------ .../app/pages/api/interfaces/interfaces.ts | 5 + libs/common/src/lib/interfaces/index.ts | 2 + .../ai-service-health-response.interface.ts | 3 + package-lock.json | 202 ++++----------- package.json | 4 +- 12 files changed, 335 insertions(+), 239 deletions(-) create mode 100644 apps/client/src/app/pages/api/interfaces/interfaces.ts create mode 100644 libs/common/src/lib/interfaces/responses/ai-service-health-response.interface.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f7d95cb7..b32868336 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Migrated various components from `NgClass` to class bindings - Refreshed the cryptocurrencies list - Upgraded `@ionic/angular` from version `8.8.1` to `8.8.5` +- Upgraded `@openrouter/ai-sdk-provider` from version `0.7.2` to `2.9.0` +- Upgraded `ai` from version `4.3.16` to `6.0.174` - Upgraded `bull-board` from version `6.20.3` to `7.0.0` - Upgraded `countries-and-timezones` from version `3.8.0` to `3.9.0` - Upgraded `fuse.js` from version `7.1.0` to `7.3.0` diff --git a/apps/api/src/app/endpoints/ai/ai.module.ts b/apps/api/src/app/endpoints/ai/ai.module.ts index eab4ecf8b..5267f40c8 100644 --- a/apps/api/src/app/endpoints/ai/ai.module.ts +++ b/apps/api/src/app/endpoints/ai/ai.module.ts @@ -28,6 +28,7 @@ import { AiService } from './ai.service'; @Module({ controllers: [AiController], + exports: [AiService], imports: [ ActivitiesModule, ApiModule, diff --git a/apps/api/src/app/endpoints/ai/ai.service.ts b/apps/api/src/app/endpoints/ai/ai.service.ts index d07768d69..362f4a728 100644 --- a/apps/api/src/app/endpoints/ai/ai.service.ts +++ b/apps/api/src/app/endpoints/ai/ai.service.ts @@ -1,4 +1,5 @@ import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PROPERTY_API_KEY_OPENROUTER, @@ -36,11 +37,18 @@ export class AiService { ]; public constructor( + private readonly configurationService: ConfigurationService, private readonly portfolioService: PortfolioService, private readonly propertyService: PropertyService ) {} - public async generateText({ prompt }: { prompt: string }) { + public async generateText({ + prompt, + requestTimeout = this.configurationService.get('REQUEST_TIMEOUT') + }: { + prompt: string; + requestTimeout?: number; + }) { const openRouterApiKey = await this.propertyService.getByKey( PROPERTY_API_KEY_OPENROUTER ); @@ -55,7 +63,8 @@ export class AiService { return generateText({ prompt, - model: openRouterService.chat(openRouterModel) + model: openRouterService.chat(openRouterModel), + timeout: requestTimeout }); } diff --git a/apps/api/src/app/health/health.controller.ts b/apps/api/src/app/health/health.controller.ts index 5542ae933..35f3fa348 100644 --- a/apps/api/src/app/health/health.controller.ts +++ b/apps/api/src/app/health/health.controller.ts @@ -1,5 +1,7 @@ +import { AiService } from '@ghostfolio/api/app/endpoints/ai/ai.service'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { + AiServiceHealthResponse, DataEnhancerHealthResponse, DataProviderHealthResponse } from '@ghostfolio/common/interfaces'; @@ -9,6 +11,7 @@ import { Get, HttpException, HttpStatus, + Logger, Param, Res, UseInterceptors @@ -21,7 +24,10 @@ import { HealthService } from './health.service'; @Controller('health') export class HealthController { - public constructor(private readonly healthService: HealthService) {} + public constructor( + private readonly aiService: AiService, + private readonly healthService: HealthService + ) {} @Get() public async getHealth(@Res() response: Response) { @@ -40,6 +46,29 @@ export class HealthController { } } + @Get('ai') + public async getHealthOfAiService( + @Res() response: Response + ): Promise> { + try { + const { text } = await this.aiService.generateText({ + prompt: `Reply with the word "OK" and nothing else.` + }); + + if (text === 'OK') { + return response + .status(HttpStatus.OK) + .json({ status: getReasonPhrase(StatusCodes.OK) }); + } + } catch (error) { + Logger.error(error, 'HealthController'); + } + + return response + .status(HttpStatus.SERVICE_UNAVAILABLE) + .json({ status: getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE) }); + } + @Get('data-enhancer/:name') public async getHealthOfDataEnhancer( @Param('name') name: string, diff --git a/apps/api/src/app/health/health.module.ts b/apps/api/src/app/health/health.module.ts index b8c4d5810..c36924121 100644 --- a/apps/api/src/app/health/health.module.ts +++ b/apps/api/src/app/health/health.module.ts @@ -1,3 +1,4 @@ +import { AiModule } from '@ghostfolio/api/app/endpoints/ai/ai.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module'; @@ -12,6 +13,7 @@ import { HealthService } from './health.service'; @Module({ controllers: [HealthController], imports: [ + AiModule, DataEnhancerModule, DataProviderModule, PropertyModule, diff --git a/apps/client/src/app/pages/api/api-page.component.ts b/apps/client/src/app/pages/api/api-page.component.ts index 357a08bbd..e75a51c73 100644 --- a/apps/client/src/app/pages/api/api-page.component.ts +++ b/apps/client/src/app/pages/api/api-page.component.ts @@ -4,6 +4,7 @@ import { } from '@ghostfolio/common/config'; import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { + AiServiceHealthResponse, DataProviderGhostfolioAssetProfileResponse, DataProviderGhostfolioStatusResponse, DividendsResponse, @@ -13,27 +14,42 @@ import { } from '@ghostfolio/common/interfaces'; import { CommonModule } from '@angular/common'; -import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; +import { + HttpClient, + HttpErrorResponse, + HttpHeaders, + HttpParams +} from '@angular/common/http'; import { Component, DestroyRef, OnInit } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MatCardModule } from '@angular/material/card'; import { format, startOfYear } from 'date-fns'; -import { map, Observable } from 'rxjs'; +import { isObject } from 'lodash'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; +import { catchError, map, Observable, of, OperatorFunction } from 'rxjs'; + +import { FetchFailure, FetchResult } from './interfaces/interfaces'; @Component({ host: { class: 'page' }, - imports: [CommonModule], + imports: [CommonModule, MatCardModule, NgxSkeletonLoaderModule], selector: 'gf-api-page', styleUrls: ['./api-page.scss'], templateUrl: './api-page.html' }) export class GfApiPageComponent implements OnInit { - public assetProfile$: Observable; - public dividends$: Observable; - public historicalData$: Observable; - public isinLookupItems$: Observable; - public lookupItems$: Observable; - public quotes$: Observable; - public status$: Observable; + public aiServiceHealth$: Observable>; + public assetProfile$: Observable< + FetchResult + >; + public dividends$: Observable>; + public historicalData$: Observable< + FetchResult + >; + public isinLookupItems$: Observable>; + public lookupItems$: Observable>; + public quotes$: Observable>; + public status$: Observable>; private apiKey: string; @@ -45,6 +61,7 @@ export class GfApiPageComponent implements OnInit { public ngOnInit() { this.apiKey = prompt($localize`Please enter your Ghostfolio API key:`); + this.aiServiceHealth$ = this.fetchAiServiceHealth(); this.assetProfile$ = this.fetchAssetProfile({ symbol: 'AAPL' }); this.dividends$ = this.fetchDividends({ symbol: 'KO' }); this.historicalData$ = this.fetchHistoricalData({ symbol: 'AAPL' }); @@ -54,13 +71,33 @@ export class GfApiPageComponent implements OnInit { this.status$ = this.fetchStatus(); } + public isFetchFailure(value: unknown): value is FetchFailure { + return isObject(value) && value !== null && 'fetchError' in value; + } + + private catchFetchFailure(): OperatorFunction { + return catchError(({ error }: HttpErrorResponse) => { + const body = error as { message?: string; status?: string }; + const status = body?.status ?? 'Error'; + const fetchError = body?.message ? `${status}: ${body.message}` : status; + + return of({ fetchError }); + }) as OperatorFunction; + } + + private fetchAiServiceHealth() { + return this.http + .get('/api/v1/health/ai') + .pipe(this.catchFetchFailure(), takeUntilDestroyed(this.destroyRef)); + } + private fetchAssetProfile({ symbol }: { symbol: string }) { return this.http .get( `/api/v1/data-providers/ghostfolio/asset-profile/${symbol}`, { headers: this.getHeaders() } ) - .pipe(takeUntilDestroyed(this.destroyRef)); + .pipe(this.catchFetchFailure(), takeUntilDestroyed(this.destroyRef)); } private fetchDividends({ symbol }: { symbol: string }) { @@ -80,6 +117,7 @@ export class GfApiPageComponent implements OnInit { map(({ dividends }) => { return dividends; }), + this.catchFetchFailure(), takeUntilDestroyed(this.destroyRef) ); } @@ -101,6 +139,7 @@ export class GfApiPageComponent implements OnInit { map(({ historicalData }) => { return historicalData; }), + this.catchFetchFailure(), takeUntilDestroyed(this.destroyRef) ); } @@ -127,6 +166,7 @@ export class GfApiPageComponent implements OnInit { map(({ items }) => { return items; }), + this.catchFetchFailure(), takeUntilDestroyed(this.destroyRef) ); } @@ -143,6 +183,7 @@ export class GfApiPageComponent implements OnInit { map(({ quotes }) => { return quotes; }), + this.catchFetchFailure(), takeUntilDestroyed(this.destroyRef) ); } @@ -153,7 +194,7 @@ export class GfApiPageComponent implements OnInit { '/api/v2/data-providers/ghostfolio/status', { headers: this.getHeaders() } ) - .pipe(takeUntilDestroyed(this.destroyRef)); + .pipe(this.catchFetchFailure(), takeUntilDestroyed(this.destroyRef)); } private getHeaders() { diff --git a/apps/client/src/app/pages/api/api-page.html b/apps/client/src/app/pages/api/api-page.html index 3c43484e6..07f5ec981 100644 --- a/apps/client/src/app/pages/api/api-page.html +++ b/apps/client/src/app/pages/api/api-page.html @@ -1,77 +1,173 @@
    -
    -

    Status

    -
    {{ status$ | async | json }}
    -
    -
    -

    Asset Profile

    -
    {{ assetProfile$ | async | json }}
    -
    -
    -

    Lookup

    - @if (lookupItems$) { - @let symbols = lookupItems$ | async; -
      - @for (item of symbols; track item.symbol) { -
    • {{ item.name }} ({{ item.symbol }})
    • - } -
    - } -
    -
    -

    Lookup (ISIN)

    - @if (isinLookupItems$) { - @let symbols = isinLookupItems$ | async; -
      - @for (item of symbols; track item.symbol) { -
    • {{ item.name }} ({{ item.symbol }})
    • - } -
    - } -
    -
    -

    Quotes

    - @if (quotes$) { - @let quotes = quotes$ | async; -
      - @for (quote of quotes | keyvalue; track quote) { -
    • - {{ quote.key }}: {{ quote.value.marketPrice }} - {{ quote.value.currency }} -
    • - } -
    - } -
    -
    -

    Historical

    - @if (historicalData$) { - @let historicalData = historicalData$ | async; -
      - @for ( - historicalDataItem of historicalData | keyvalue; - track historicalDataItem - ) { -
    • - {{ historicalDataItem.key }}: - {{ historicalDataItem.value.marketPrice }} -
    • - } -
    - } -
    -
    -

    Dividends

    - @if (dividends$) { - @let dividends = dividends$ | async; -
      - @for (dividend of dividends | keyvalue; track dividend) { -
    • - {{ dividend.key }}: - {{ dividend.value.marketPrice }} -
    • - } -
    - } +
    +
    + + + AI Service Health + + + @let aiServiceHealth = aiServiceHealth$ | async; + @if (isFetchFailure(aiServiceHealth)) { + 🔴 {{ aiServiceHealth.fetchError }} + } @else if (aiServiceHealth) { + 🟢 {{ aiServiceHealth.status }} + } @else { + + } + + + + + Status + + + @let status = status$ | async; + @if (isFetchFailure(status)) { + 🔴 {{ status.fetchError }} + } @else if (status) { +
    {{ status | json }}
    + } @else { + + } +
    +
    + + + Asset Profile + + + @let assetProfile = assetProfile$ | async; + @if (isFetchFailure(assetProfile)) { + 🔴 {{ assetProfile.fetchError }} + } @else if (assetProfile) { +
    {{ assetProfile | json }}
    + } @else { + + } +
    +
    + + + Lookup + + + @let symbols = lookupItems$ | async; + @if (isFetchFailure(symbols)) { + 🔴 {{ symbols.fetchError }} + } @else if (symbols) { +
      + @for (item of symbols; track item.symbol) { +
    • {{ item.name }} ({{ item.symbol }})
    • + } +
    + } @else { + + } +
    +
    + + + Lookup (ISIN) + + + @let isinSymbols = isinLookupItems$ | async; + @if (isFetchFailure(isinSymbols)) { + 🔴 {{ isinSymbols.fetchError }} + } @else if (isinSymbols) { +
      + @for (item of isinSymbols; track item.symbol) { +
    • {{ item.name }} ({{ item.symbol }})
    • + } +
    + } @else { + + } +
    +
    + + + Quotes + + + @let quotes = quotes$ | async; + @if (isFetchFailure(quotes)) { + 🔴 {{ quotes.fetchError }} + } @else if (quotes) { +
      + @for (quote of quotes | keyvalue; track quote) { +
    • + {{ quote.key }}: {{ quote.value.marketPrice }} + {{ quote.value.currency }} +
    • + } +
    + } @else { + + } +
    +
    + + + Historical + + + @let historicalData = historicalData$ | async; + @if (isFetchFailure(historicalData)) { + 🔴 {{ historicalData.fetchError }} + } @else if (historicalData) { +
      + @for (item of historicalData | keyvalue; track item) { +
    • {{ item.key }}: {{ item.value.marketPrice }}
    • + } +
    + } @else { + + } +
    +
    + + + Dividends + + + @let dividends = dividends$ | async; + @if (isFetchFailure(dividends)) { + 🔴 {{ dividends.fetchError }} + } @else if (dividends) { +
      + @for (dividend of dividends | keyvalue; track dividend) { +
    • {{ dividend.key }}: {{ dividend.value.marketPrice }}
    • + } +
    + } @else { + + } +
    +
    +
    diff --git a/apps/client/src/app/pages/api/interfaces/interfaces.ts b/apps/client/src/app/pages/api/interfaces/interfaces.ts new file mode 100644 index 000000000..07de2b2f7 --- /dev/null +++ b/apps/client/src/app/pages/api/interfaces/interfaces.ts @@ -0,0 +1,5 @@ +export interface FetchFailure { + fetchError: string; +} + +export type FetchResult = T | FetchFailure; diff --git a/libs/common/src/lib/interfaces/index.ts b/libs/common/src/lib/interfaces/index.ts index ad747d94e..89874da60 100644 --- a/libs/common/src/lib/interfaces/index.ts +++ b/libs/common/src/lib/interfaces/index.ts @@ -44,6 +44,7 @@ import type { ActivityResponse } from './responses/activity-response.interface'; import type { AdminUserResponse } from './responses/admin-user-response.interface'; import type { AdminUsersResponse } from './responses/admin-users-response.interface'; import type { AiPromptResponse } from './responses/ai-prompt-response.interface'; +import type { AiServiceHealthResponse } from './responses/ai-service-health-response.interface'; import type { ApiKeyResponse } from './responses/api-key-response.interface'; import type { AssetResponse } from './responses/asset-response.interface'; import type { BenchmarkMarketDataDetailsResponse } from './responses/benchmark-market-data-details-response.interface'; @@ -117,6 +118,7 @@ export { AdminUserResponse, AdminUsersResponse, AiPromptResponse, + AiServiceHealthResponse, ApiKeyResponse, AssertionCredentialJSON, AssetClassSelectorOption, diff --git a/libs/common/src/lib/interfaces/responses/ai-service-health-response.interface.ts b/libs/common/src/lib/interfaces/responses/ai-service-health-response.interface.ts new file mode 100644 index 000000000..58ffafafc --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/ai-service-health-response.interface.ts @@ -0,0 +1,3 @@ +export interface AiServiceHealthResponse { + status: string; +} diff --git a/package-lock.json b/package-lock.json index d061349b4..70873b53f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,12 +40,12 @@ "@nestjs/platform-express": "11.1.19", "@nestjs/schedule": "6.1.3", "@nestjs/serve-static": "5.0.5", - "@openrouter/ai-sdk-provider": "0.7.2", + "@openrouter/ai-sdk-provider": "2.9.0", "@prisma/adapter-pg": "7.7.0", "@prisma/client": "7.7.0", "@simplewebauthn/browser": "13.2.2", "@simplewebauthn/server": "13.2.2", - "ai": "4.3.16", + "ai": "6.0.174", "alphavantage": "2.2.0", "big.js": "7.0.1", "bootstrap": "4.6.2", @@ -175,74 +175,50 @@ "dev": true, "license": "MIT" }, - "node_modules/@ai-sdk/provider": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-1.1.3.tgz", - "integrity": "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==", - "license": "Apache-2.0", - "dependencies": { - "json-schema": "^0.4.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@ai-sdk/provider-utils": { - "version": "2.2.8", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-2.2.8.tgz", - "integrity": "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA==", + "node_modules/@ai-sdk/gateway": { + "version": "3.0.109", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.109.tgz", + "integrity": "sha512-r6dOqThjODp1vOhGRJg2OCmyB/ZOQtGx1esZ2SDvwDX5XoX8dBqYaYjLg8MPXTzMGJSgOkJyCxWgUcZtAl16pw==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "1.1.3", - "nanoid": "^3.3.8", - "secure-json-parse": "^2.7.0" + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.26", + "@vercel/oidc": "3.2.0" }, "engines": { "node": ">=18" }, "peerDependencies": { - "zod": "^3.23.8" + "zod": "^3.25.76 || ^4.1.8" } }, - "node_modules/@ai-sdk/react": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-1.2.12.tgz", - "integrity": "sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g==", + "node_modules/@ai-sdk/provider": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.10.tgz", + "integrity": "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider-utils": "2.2.8", - "@ai-sdk/ui-utils": "1.2.11", - "swr": "^2.2.5", - "throttleit": "2.1.0" + "json-schema": "^0.4.0" }, "engines": { "node": ">=18" - }, - "peerDependencies": { - "react": "^18 || ^19 || ^19.0.0-rc", - "zod": "^3.23.8" - }, - "peerDependenciesMeta": { - "zod": { - "optional": true - } } }, - "node_modules/@ai-sdk/ui-utils": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@ai-sdk/ui-utils/-/ui-utils-1.2.11.tgz", - "integrity": "sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w==", + "node_modules/@ai-sdk/provider-utils": { + "version": "4.0.26", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.26.tgz", + "integrity": "sha512-CsKNLKsOpvPujRlIYvoz+Ybw+kGn7J4/fIZa/58+R7iWLLfwn6ifE2G6Yq8K9XvH/I/3bzaDAJ3NhRwEMsLBKQ==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "1.1.3", - "@ai-sdk/provider-utils": "2.2.8", - "zod-to-json-schema": "^3.24.1" + "@ai-sdk/provider": "3.0.10", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.8" }, "engines": { "node": ">=18" }, "peerDependencies": { - "zod": "^3.23.8" + "zod": "^3.25.76 || ^4.1.8" } }, "node_modules/@algolia/abtesting": { @@ -10810,20 +10786,16 @@ } }, "node_modules/@openrouter/ai-sdk-provider": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/@openrouter/ai-sdk-provider/-/ai-sdk-provider-0.7.2.tgz", - "integrity": "sha512-Fry2mV7uGGJRmP9JntTZRc8ElESIk7AJNTacLbF6Syoeb5k8d7HPGkcK9rTXDlqBb8HgU1hOKtz23HojesTmnw==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@openrouter/ai-sdk-provider/-/ai-sdk-provider-2.9.0.tgz", + "integrity": "sha512-Seva+NCa0WUQnJIUE5GzHsUv1WTIeyqwz0ELl2VtS6NP+eF+77yCXGFVOMbvoCM7QMjlnhv7931e89R+8pJdcQ==", "license": "Apache-2.0", - "dependencies": { - "@ai-sdk/provider": "1.1.3", - "@ai-sdk/provider-utils": "2.2.8" - }, "engines": { "node": ">=18" }, "peerDependencies": { - "ai": "^4.3.16", - "zod": "^3.25.34" + "ai": "^6.0.0", + "zod": "^3.25.0 || ^4.0.0" } }, "node_modules/@opentelemetry/api": { @@ -14455,12 +14427,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/diff-match-patch": { - "version": "1.0.36", - "resolved": "https://registry.npmjs.org/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz", - "integrity": "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==", - "license": "MIT" - }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -15986,6 +15952,15 @@ "d3-transition": "^3.0.1" } }, + "node_modules/@vercel/oidc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz", + "integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, "node_modules/@vitejs/plugin-basic-ssl": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.1.4.tgz", @@ -16440,29 +16415,21 @@ } }, "node_modules/ai": { - "version": "4.3.16", - "resolved": "https://registry.npmjs.org/ai/-/ai-4.3.16.tgz", - "integrity": "sha512-KUDwlThJ5tr2Vw0A1ZkbDKNME3wzWhuVfAOwIvFUzl1TPVDFAXDFTXio3p+jaKneB+dKNCvFFlolYmmgHttG1g==", + "version": "6.0.174", + "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.174.tgz", + "integrity": "sha512-bTrfLUWHWtkjzWyCY4bmyuk4Qvmj4S4NSNsXyNSVVqkmftQNtxRj7dzUoMeQDBBwlJO6fC7m2Q/lNOPqQQfAGA==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "1.1.3", - "@ai-sdk/provider-utils": "2.2.8", - "@ai-sdk/react": "1.2.12", - "@ai-sdk/ui-utils": "1.2.11", - "@opentelemetry/api": "1.9.0", - "jsondiffpatch": "0.6.0" + "@ai-sdk/gateway": "3.0.109", + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.26", + "@opentelemetry/api": "1.9.0" }, "engines": { "node": ">=18" }, "peerDependencies": { - "react": "^18 || ^19 || ^19.0.0-rc", - "zod": "^3.23.8" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - } + "zod": "^3.25.76 || ^4.1.8" } }, "node_modules/ajv": { @@ -20844,7 +20811,9 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -20922,12 +20891,6 @@ "node": ">=0.3.1" } }, - "node_modules/diff-match-patch": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", - "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==", - "license": "Apache-2.0" - }, "node_modules/dns-packet": { "version": "5.6.1", "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", @@ -22137,10 +22100,9 @@ } }, "node_modules/eventsource-parser": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", - "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", - "dev": true, + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", + "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -28868,35 +28830,6 @@ "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", "license": "MIT" }, - "node_modules/jsondiffpatch": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/jsondiffpatch/-/jsondiffpatch-0.6.0.tgz", - "integrity": "sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ==", - "license": "MIT", - "dependencies": { - "@types/diff-match-patch": "^1.0.36", - "chalk": "^5.3.0", - "diff-match-patch": "^1.0.5" - }, - "bin": { - "jsondiffpatch": "bin/jsondiffpatch.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/jsondiffpatch/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/jsonfile": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", @@ -29727,6 +29660,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "devOptional": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -33634,6 +33568,7 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "devOptional": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" @@ -35337,12 +35272,6 @@ "dev": true, "license": "MIT" }, - "node_modules/secure-json-parse": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", - "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", - "license": "BSD-3-Clause" - }, "node_modules/select": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz", @@ -36838,19 +36767,6 @@ "url": "https://github.com/sponsors/fb55" } }, - "node_modules/swr": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/swr/-/swr-2.4.1.tgz", - "integrity": "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==", - "license": "MIT", - "dependencies": { - "dequal": "^2.0.3", - "use-sync-external-store": "^1.6.0" - }, - "peerDependencies": { - "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -37254,18 +37170,6 @@ "tslib": "^2" } }, - "node_modules/throttleit": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz", - "integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/thunky": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", @@ -38654,6 +38558,7 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "dev": true, "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -40490,6 +40395,7 @@ "version": "3.25.2", "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, "license": "ISC", "peerDependencies": { "zod": "^3.25.28 || ^4" diff --git a/package.json b/package.json index 9ed027e9c..ebc8a775e 100644 --- a/package.json +++ b/package.json @@ -85,12 +85,12 @@ "@nestjs/platform-express": "11.1.19", "@nestjs/schedule": "6.1.3", "@nestjs/serve-static": "5.0.5", - "@openrouter/ai-sdk-provider": "0.7.2", + "@openrouter/ai-sdk-provider": "2.9.0", "@prisma/adapter-pg": "7.7.0", "@prisma/client": "7.7.0", "@simplewebauthn/browser": "13.2.2", "@simplewebauthn/server": "13.2.2", - "ai": "4.3.16", + "ai": "6.0.174", "alphavantage": "2.2.0", "big.js": "7.0.1", "bootstrap": "4.6.2", From f31fac76f92f89ee02dd5e469b17c2a18faf4f1e Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Tue, 12 May 2026 07:59:56 +0300 Subject: [PATCH 046/132] Task/upgrade papaparse to version 5.5.3 (#6787) * Upgrade papaparse to version 5.5.3 * Update changelog --- CHANGELOG.md | 1 + package-lock.json | 16 ++++++++-------- package.json | 4 ++-- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b32868336..4202b4868 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Upgraded `countries-and-timezones` from version `3.8.0` to `3.9.0` - Upgraded `fuse.js` from version `7.1.0` to `7.3.0` - Upgraded `Nx` from version `22.6.5` to `22.7.1` +- Upgraded `papaparse` from version `5.3.1` to `5.5.3` ### Fixed diff --git a/package-lock.json b/package-lock.json index 70873b53f..c93593880 100644 --- a/package-lock.json +++ b/package-lock.json @@ -82,7 +82,7 @@ "ngx-markdown": "21.2.0", "ngx-skeleton-loader": "12.0.0", "open-color": "1.9.1", - "papaparse": "5.3.1", + "papaparse": "5.5.3", "passport": "0.7.0", "passport-google-oauth20": "2.0.0", "passport-headerapikey": "1.2.2", @@ -136,7 +136,7 @@ "@types/jsonpath": "0.2.4", "@types/lodash": "4.17.24", "@types/node": "22.15.17", - "@types/papaparse": "5.3.7", + "@types/papaparse": "5.5.2", "@types/passport-google-oauth20": "2.0.17", "@types/passport-openidconnect": "0.1.3", "@typescript-eslint/eslint-plugin": "8.43.0", @@ -14737,9 +14737,9 @@ } }, "node_modules/@types/papaparse": { - "version": "5.3.7", - "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.7.tgz", - "integrity": "sha512-f2HKmlnPdCvS0WI33WtCs5GD7X1cxzzS/aduaxSu3I7TbhWlENjSPs6z5TaB9K0J+BH1jbmqTaM+ja5puis4wg==", + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz", + "integrity": "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==", "dev": true, "license": "MIT", "dependencies": { @@ -32070,9 +32070,9 @@ } }, "node_modules/papaparse": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.3.1.tgz", - "integrity": "sha512-Dbt2yjLJrCwH2sRqKFFJaN5XgIASO9YOFeFP8rIBRG2Ain8mqk5r1M6DkfvqEVozVcz3r3HaUGw253hA1nLIcA==", + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz", + "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==", "license": "MIT" }, "node_modules/param-case": { diff --git a/package.json b/package.json index ebc8a775e..7190ff92d 100644 --- a/package.json +++ b/package.json @@ -127,7 +127,7 @@ "ngx-markdown": "21.2.0", "ngx-skeleton-loader": "12.0.0", "open-color": "1.9.1", - "papaparse": "5.3.1", + "papaparse": "5.5.3", "passport": "0.7.0", "passport-google-oauth20": "2.0.0", "passport-headerapikey": "1.2.2", @@ -181,7 +181,7 @@ "@types/jsonpath": "0.2.4", "@types/lodash": "4.17.24", "@types/node": "22.15.17", - "@types/papaparse": "5.3.7", + "@types/papaparse": "5.5.2", "@types/passport-google-oauth20": "2.0.17", "@types/passport-openidconnect": "0.1.3", "@typescript-eslint/eslint-plugin": "8.43.0", From 236bce52ca0706ad14e466957c898e982b26fbd4 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Wed, 13 May 2026 08:26:28 +0300 Subject: [PATCH 047/132] Feature/add nestjs-best-practices skills (#6824) * Add nestjs-best-practices skills * Update changelog --- .../skills/nestjs-best-practices/AGENTS.md | 5958 +++++++++++++++++ .agents/skills/nestjs-best-practices/SKILL.md | 130 + .../nestjs-best-practices/rules/_sections.md | 56 + .../nestjs-best-practices/rules/_template.md | 28 + .../rules/api-use-dto-serialization.md | 182 + .../rules/api-use-interceptors.md | 202 + .../rules/api-use-pipes.md | 205 + .../rules/api-versioning.md | 191 + .../rules/arch-avoid-circular-deps.md | 80 + .../rules/arch-feature-modules.md | 82 + .../rules/arch-module-sharing.md | 141 + .../rules/arch-single-responsibility.md | 106 + .../rules/arch-use-events.md | 108 + .../rules/arch-use-repository-pattern.md | 97 + .../rules/db-avoid-n-plus-one.md | 139 + .../rules/db-use-migrations.md | 129 + .../rules/db-use-transactions.md | 140 + .../rules/devops-graceful-shutdown.md | 222 + .../rules/devops-use-config-module.md | 167 + .../rules/devops-use-logging.md | 232 + .../rules/di-avoid-service-locator.md | 104 + .../rules/di-interface-segregation.md | 165 + .../rules/di-liskov-substitution.md | 221 + .../rules/di-prefer-constructor-injection.md | 86 + .../rules/di-scope-awareness.md | 94 + .../rules/di-use-interfaces-tokens.md | 101 + .../rules/error-handle-async-errors.md | 125 + .../rules/error-throw-http-exceptions.md | 114 + .../rules/error-use-exception-filters.md | 140 + .../rules/micro-use-health-checks.md | 226 + .../rules/micro-use-patterns.md | 167 + .../rules/micro-use-queues.md | 252 + .../rules/perf-async-hooks.md | 109 + .../rules/perf-lazy-loading.md | 121 + .../rules/perf-optimize-database.md | 131 + .../rules/perf-use-caching.md | 128 + .../rules/security-auth-jwt.md | 146 + .../rules/security-rate-limiting.md | 125 + .../rules/security-sanitize-output.md | 139 + .../rules/security-use-guards.md | 135 + .../rules/security-validate-all-input.md | 150 + .../rules/test-e2e-supertest.md | 178 + .../rules/test-mock-external-services.md | 179 + .../rules/test-use-testing-module.md | 153 + .claude/skills/nestjs-best-practices | 1 + .prettierignore | 1 + CHANGELOG.md | 4 + skills-lock.json | 6 + 48 files changed, 12096 insertions(+) create mode 100644 .agents/skills/nestjs-best-practices/AGENTS.md create mode 100644 .agents/skills/nestjs-best-practices/SKILL.md create mode 100644 .agents/skills/nestjs-best-practices/rules/_sections.md create mode 100644 .agents/skills/nestjs-best-practices/rules/_template.md create mode 100644 .agents/skills/nestjs-best-practices/rules/api-use-dto-serialization.md create mode 100644 .agents/skills/nestjs-best-practices/rules/api-use-interceptors.md create mode 100644 .agents/skills/nestjs-best-practices/rules/api-use-pipes.md create mode 100644 .agents/skills/nestjs-best-practices/rules/api-versioning.md create mode 100644 .agents/skills/nestjs-best-practices/rules/arch-avoid-circular-deps.md create mode 100644 .agents/skills/nestjs-best-practices/rules/arch-feature-modules.md create mode 100644 .agents/skills/nestjs-best-practices/rules/arch-module-sharing.md create mode 100644 .agents/skills/nestjs-best-practices/rules/arch-single-responsibility.md create mode 100644 .agents/skills/nestjs-best-practices/rules/arch-use-events.md create mode 100644 .agents/skills/nestjs-best-practices/rules/arch-use-repository-pattern.md create mode 100644 .agents/skills/nestjs-best-practices/rules/db-avoid-n-plus-one.md create mode 100644 .agents/skills/nestjs-best-practices/rules/db-use-migrations.md create mode 100644 .agents/skills/nestjs-best-practices/rules/db-use-transactions.md create mode 100644 .agents/skills/nestjs-best-practices/rules/devops-graceful-shutdown.md create mode 100644 .agents/skills/nestjs-best-practices/rules/devops-use-config-module.md create mode 100644 .agents/skills/nestjs-best-practices/rules/devops-use-logging.md create mode 100644 .agents/skills/nestjs-best-practices/rules/di-avoid-service-locator.md create mode 100644 .agents/skills/nestjs-best-practices/rules/di-interface-segregation.md create mode 100644 .agents/skills/nestjs-best-practices/rules/di-liskov-substitution.md create mode 100644 .agents/skills/nestjs-best-practices/rules/di-prefer-constructor-injection.md create mode 100644 .agents/skills/nestjs-best-practices/rules/di-scope-awareness.md create mode 100644 .agents/skills/nestjs-best-practices/rules/di-use-interfaces-tokens.md create mode 100644 .agents/skills/nestjs-best-practices/rules/error-handle-async-errors.md create mode 100644 .agents/skills/nestjs-best-practices/rules/error-throw-http-exceptions.md create mode 100644 .agents/skills/nestjs-best-practices/rules/error-use-exception-filters.md create mode 100644 .agents/skills/nestjs-best-practices/rules/micro-use-health-checks.md create mode 100644 .agents/skills/nestjs-best-practices/rules/micro-use-patterns.md create mode 100644 .agents/skills/nestjs-best-practices/rules/micro-use-queues.md create mode 100644 .agents/skills/nestjs-best-practices/rules/perf-async-hooks.md create mode 100644 .agents/skills/nestjs-best-practices/rules/perf-lazy-loading.md create mode 100644 .agents/skills/nestjs-best-practices/rules/perf-optimize-database.md create mode 100644 .agents/skills/nestjs-best-practices/rules/perf-use-caching.md create mode 100644 .agents/skills/nestjs-best-practices/rules/security-auth-jwt.md create mode 100644 .agents/skills/nestjs-best-practices/rules/security-rate-limiting.md create mode 100644 .agents/skills/nestjs-best-practices/rules/security-sanitize-output.md create mode 100644 .agents/skills/nestjs-best-practices/rules/security-use-guards.md create mode 100644 .agents/skills/nestjs-best-practices/rules/security-validate-all-input.md create mode 100644 .agents/skills/nestjs-best-practices/rules/test-e2e-supertest.md create mode 100644 .agents/skills/nestjs-best-practices/rules/test-mock-external-services.md create mode 100644 .agents/skills/nestjs-best-practices/rules/test-use-testing-module.md create mode 120000 .claude/skills/nestjs-best-practices diff --git a/.agents/skills/nestjs-best-practices/AGENTS.md b/.agents/skills/nestjs-best-practices/AGENTS.md new file mode 100644 index 000000000..d14ec242c --- /dev/null +++ b/.agents/skills/nestjs-best-practices/AGENTS.md @@ -0,0 +1,5958 @@ +# NestJS Best Practices + +**Version 1.1.0** +NestJS Best Practices +January 2026 + +> **Note:** +> This document is mainly for agents and LLMs to follow when maintaining, +> generating, or refactoring NestJS codebases. Humans may also find it +> useful, but guidance here is optimized for automation and consistency +> by AI-assisted workflows. + +--- + +## Abstract + +Comprehensive best practices and architecture guide for NestJS applications, designed for AI agents and LLMs. Contains 40 rules across 10 categories, prioritized by impact from critical (architecture, dependency injection) to incremental (DevOps patterns). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation. + +--- + +## Table of Contents + +1. [Architecture](#1-architecture) — **CRITICAL** + - 1.1 [Avoid Circular Dependencies](#11-avoid-circular-dependencies) + - 1.2 [Organize by Feature Modules](#12-organize-by-feature-modules) + - 1.3 [Use Proper Module Sharing Patterns](#13-use-proper-module-sharing-patterns) + - 1.4 [Single Responsibility for Services](#14-single-responsibility-for-services) + - 1.5 [Use Event-Driven Architecture for Decoupling](#15-use-event-driven-architecture-for-decoupling) + - 1.6 [Use Repository Pattern for Data Access](#16-use-repository-pattern-for-data-access) +2. [Dependency Injection](#2-dependency-injection) — **CRITICAL** + - 2.1 [Avoid Service Locator Anti-Pattern](#21-avoid-service-locator-anti-pattern) + - 2.2 [Apply Interface Segregation Principle](#22-apply-interface-segregation-principle) + - 2.3 [Honor Liskov Substitution Principle](#23-honor-liskov-substitution-principle) + - 2.4 [Prefer Constructor Injection](#24-prefer-constructor-injection) + - 2.5 [Understand Provider Scopes](#25-understand-provider-scopes) + - 2.6 [Use Injection Tokens for Interfaces](#26-use-injection-tokens-for-interfaces) +3. [Error Handling](#3-error-handling) — **HIGH** + - 3.1 [Handle Async Errors Properly](#31-handle-async-errors-properly) + - 3.2 [Throw HTTP Exceptions from Services](#32-throw-http-exceptions-from-services) + - 3.3 [Use Exception Filters for Error Handling](#33-use-exception-filters-for-error-handling) +4. [Security](#4-security) — **HIGH** + - 4.1 [Implement Secure JWT Authentication](#41-implement-secure-jwt-authentication) + - 4.2 [Implement Rate Limiting](#42-implement-rate-limiting) + - 4.3 [Sanitize Output to Prevent XSS](#43-sanitize-output-to-prevent-xss) + - 4.4 [Use Guards for Authentication and Authorization](#44-use-guards-for-authentication-and-authorization) + - 4.5 [Validate All Input with DTOs and Pipes](#45-validate-all-input-with-dtos-and-pipes) +5. [Performance](#5-performance) — **HIGH** + - 5.1 [Use Async Lifecycle Hooks Correctly](#51-use-async-lifecycle-hooks-correctly) + - 5.2 [Use Lazy Loading for Large Modules](#52-use-lazy-loading-for-large-modules) + - 5.3 [Optimize Database Queries](#53-optimize-database-queries) + - 5.4 [Use Caching Strategically](#54-use-caching-strategically) +6. [Testing](#6-testing) — **MEDIUM-HIGH** + - 6.1 [Use Supertest for E2E Testing](#61-use-supertest-for-e2e-testing) + - 6.2 [Mock External Services in Tests](#62-mock-external-services-in-tests) + - 6.3 [Use Testing Module for Unit Tests](#63-use-testing-module-for-unit-tests) +7. [Database & ORM](#7-database-orm) — **MEDIUM-HIGH** + - 7.1 [Avoid N+1 Query Problems](#71-avoid-n-1-query-problems) + - 7.2 [Use Database Migrations](#72-use-database-migrations) + - 7.3 [Use Transactions for Multi-Step Operations](#73-use-transactions-for-multi-step-operations) +8. [API Design](#8-api-design) — **MEDIUM** + - 8.1 [Use DTOs and Serialization for API Responses](#81-use-dtos-and-serialization-for-api-responses) + - 8.2 [Use Interceptors for Cross-Cutting Concerns](#82-use-interceptors-for-cross-cutting-concerns) + - 8.3 [Use Pipes for Input Transformation](#83-use-pipes-for-input-transformation) + - 8.4 [Use API Versioning for Breaking Changes](#84-use-api-versioning-for-breaking-changes) +9. [Microservices](#9-microservices) — **MEDIUM** + - 9.1 [Implement Health Checks for Microservices](#91-implement-health-checks-for-microservices) + - 9.2 [Use Message and Event Patterns Correctly](#92-use-message-and-event-patterns-correctly) + - 9.3 [Use Message Queues for Background Jobs](#93-use-message-queues-for-background-jobs) +10. [DevOps & Deployment](#10-devops-deployment) — **LOW-MEDIUM** + - 10.1 [Implement Graceful Shutdown](#101-implement-graceful-shutdown) + - 10.2 [Use ConfigModule for Environment Configuration](#102-use-configmodule-for-environment-configuration) + - 10.3 [Use Structured Logging](#103-use-structured-logging) + +--- + +## 1. Architecture + +**Section Impact: CRITICAL** + +### 1.1 Avoid Circular Dependencies + +**Impact: CRITICAL** — "#1 cause of runtime crashes" + +Circular dependencies occur when Module A imports Module B, and Module B imports Module A (directly or transitively). NestJS can sometimes resolve these through forward references, but they indicate architectural problems and should be avoided. This is the #1 cause of runtime crashes in NestJS applications. + +**Incorrect (circular module imports):** + +```typescript +// users.module.ts +@Module({ + imports: [OrdersModule], // Orders needs Users, Users needs Orders = circular + providers: [UsersService], + exports: [UsersService], +}) +export class UsersModule {} + +// orders.module.ts +@Module({ + imports: [UsersModule], // Circular dependency! + providers: [OrdersService], + exports: [OrdersService], +}) +export class OrdersModule {} +``` + +**Correct (extract shared logic or use events):** + +```typescript +// Option 1: Extract shared logic to a third module +// shared.module.ts +@Module({ + providers: [SharedService], + exports: [SharedService], +}) +export class SharedModule {} + +// users.module.ts +@Module({ + imports: [SharedModule], + providers: [UsersService], +}) +export class UsersModule {} + +// orders.module.ts +@Module({ + imports: [SharedModule], + providers: [OrdersService], +}) +export class OrdersModule {} + +// Option 2: Use events for decoupled communication +// users.service.ts +@Injectable() +export class UsersService { + constructor(private eventEmitter: EventEmitter2) {} + + async createUser(data: CreateUserDto) { + const user = await this.userRepo.save(data); + this.eventEmitter.emit('user.created', user); + return user; + } +} + +// orders.service.ts +@Injectable() +export class OrdersService { + @OnEvent('user.created') + handleUserCreated(user: User) { + // React to user creation without direct dependency + } +} +``` + +Reference: [NestJS Circular Dependency](https://docs.nestjs.com/fundamentals/circular-dependency) + +--- + +### 1.2 Organize by Feature Modules + +**Impact: CRITICAL** — "3-5x faster onboarding and development" + +Organize your application into feature modules that encapsulate related functionality. Each feature module should be self-contained with its own controllers, services, entities, and DTOs. Avoid organizing by technical layer (all controllers together, all services together). This enables 3-5x faster onboarding and feature development. + +**Incorrect (technical layer organization):** + +```typescript +// Technical layer organization (anti-pattern) +src/ +├── controllers/ +│ ├── users.controller.ts +│ ├── orders.controller.ts +│ └── products.controller.ts +├── services/ +│ ├── users.service.ts +│ ├── orders.service.ts +│ └── products.service.ts +├── entities/ +│ ├── user.entity.ts +│ ├── order.entity.ts +│ └── product.entity.ts +└── app.module.ts // Imports everything directly +``` + +**Correct (feature module organization):** + +```typescript +// Feature module organization +src/ +├── users/ +│ ├── dto/ +│ │ ├── create-user.dto.ts +│ │ └── update-user.dto.ts +│ ├── entities/ +│ │ └── user.entity.ts +│ ├── users.controller.ts +│ ├── users.service.ts +│ ├── users.repository.ts +│ └── users.module.ts +├── orders/ +│ ├── dto/ +│ ├── entities/ +│ ├── orders.controller.ts +│ ├── orders.service.ts +│ └── orders.module.ts +├── shared/ +│ ├── guards/ +│ ├── interceptors/ +│ ├── filters/ +│ └── shared.module.ts +└── app.module.ts + +// users.module.ts +@Module({ + imports: [TypeOrmModule.forFeature([User])], + controllers: [UsersController], + providers: [UsersService, UsersRepository], + exports: [UsersService], // Only export what others need +}) +export class UsersModule {} + +// app.module.ts +@Module({ + imports: [ + ConfigModule.forRoot(), + TypeOrmModule.forRoot(), + UsersModule, + OrdersModule, + SharedModule, + ], +}) +export class AppModule {} +``` + +Reference: [NestJS Modules](https://docs.nestjs.com/modules) + +--- + +### 1.3 Use Proper Module Sharing Patterns + +**Impact: CRITICAL** — Prevents duplicate instances, memory leaks, and state inconsistency + +NestJS modules are singletons by default. When a service is properly exported from a module and that module is imported elsewhere, the same instance is shared. However, providing a service in multiple modules creates separate instances, leading to memory waste, state inconsistency, and confusing behavior. Always encapsulate services in dedicated modules, export them explicitly, and import the module where needed. + +**Incorrect (service provided in multiple modules):** + +```typescript +// StorageService provided directly in multiple modules - WRONG +// storage.service.ts +@Injectable() +export class StorageService { + private cache = new Map(); // Each instance has separate state! + + store(key: string, value: any) { + this.cache.set(key, value); + } +} + +// app.module.ts +@Module({ + providers: [StorageService], // Instance #1 + controllers: [AppController], +}) +export class AppModule {} + +// videos.module.ts +@Module({ + providers: [StorageService], // Instance #2 - different from AppModule! + controllers: [VideosController], +}) +export class VideosModule {} + +// Problems: +// 1. Two separate StorageService instances exist +// 2. cache.set() in VideosModule doesn't affect AppModule's cache +// 3. Memory wasted on duplicate instances +// 4. Debugging nightmares when state doesn't sync +``` + +**Correct (dedicated module with exports):** + +```typescript +// storage/storage.module.ts +@Module({ + providers: [StorageService], + exports: [StorageService], // Make available to importers +}) +export class StorageModule {} + +// videos/videos.module.ts +@Module({ + imports: [StorageModule], // Import the module, not the service + controllers: [VideosController], + providers: [VideosService], +}) +export class VideosModule {} + +// channels/channels.module.ts +@Module({ + imports: [StorageModule], // Same instance shared + controllers: [ChannelsController], + providers: [ChannelsService], +}) +export class ChannelsModule {} + +// app.module.ts +@Module({ + imports: [ + StorageModule, // Only if AppModule itself needs StorageService + VideosModule, + ChannelsModule, + ], +}) +export class AppModule {} + +// Now all modules share the SAME StorageService instance +``` + +**When to use @Global() (sparingly):** + +```typescript +// ONLY for truly cross-cutting concerns +@Global() +@Module({ + providers: [ConfigService, LoggerService], + exports: [ConfigService, LoggerService], +}) +export class CoreModule {} + +// Import once in AppModule +@Module({ + imports: [CoreModule], // Registered globally, available everywhere +}) +export class AppModule {} + +// Other modules don't need to import CoreModule +@Module({ + controllers: [UsersController], + providers: [UsersService], // Can inject ConfigService without importing +}) +export class UsersModule {} + +// WARNING: Don't make everything global! +// - Hides dependencies (can't see what a module needs from imports) +// - Makes testing harder +// - Reserve for: config, logging, database connections +``` + +**Module re-exporting pattern:** + +```typescript +// common.module.ts - shared utilities +@Module({ + providers: [DateService, ValidationService], + exports: [DateService, ValidationService], +}) +export class CommonModule {} + +// core.module.ts - re-exports common for convenience +@Module({ + imports: [CommonModule, DatabaseModule], + exports: [CommonModule, DatabaseModule], // Re-export for consumers +}) +export class CoreModule {} + +// feature.module.ts - imports CoreModule, gets both +@Module({ + imports: [CoreModule], // Gets CommonModule + DatabaseModule + controllers: [FeatureController], +}) +export class FeatureModule {} +``` + +Reference: [NestJS Modules](https://docs.nestjs.com/modules#shared-modules) + +--- + +### 1.4 Single Responsibility for Services + +**Impact: CRITICAL** — "40%+ improvement in testability" + +Each service should have a single, well-defined responsibility. Avoid "god services" that handle multiple unrelated concerns. If a service name includes "And" or handles more than one domain concept, it likely violates single responsibility. This reduces complexity and improves testability by 40%+. + +**Incorrect (god service anti-pattern):** + +```typescript +// God service anti-pattern +@Injectable() +export class UserAndOrderService { + constructor( + private userRepo: UserRepository, + private orderRepo: OrderRepository, + private mailer: MailService, + private payment: PaymentService, + ) {} + + async createUser(dto: CreateUserDto) { + const user = await this.userRepo.save(dto); + await this.mailer.sendWelcome(user); + return user; + } + + async createOrder(userId: string, dto: CreateOrderDto) { + const order = await this.orderRepo.save({ userId, ...dto }); + await this.payment.charge(order); + await this.mailer.sendOrderConfirmation(order); + return order; + } + + async calculateOrderStats(userId: string) { + // Stats logic mixed in + } + + async validatePayment(orderId: string) { + // Payment logic mixed in + } +} +``` + +**Correct (focused services with single responsibility):** + +```typescript +// Focused services with single responsibility +@Injectable() +export class UsersService { + constructor(private userRepo: UserRepository) {} + + async create(dto: CreateUserDto): Promise { + return this.userRepo.save(dto); + } + + async findById(id: string): Promise { + return this.userRepo.findOneOrFail({ where: { id } }); + } +} + +@Injectable() +export class OrdersService { + constructor(private orderRepo: OrderRepository) {} + + async create(userId: string, dto: CreateOrderDto): Promise { + return this.orderRepo.save({ userId, ...dto }); + } + + async findByUser(userId: string): Promise { + return this.orderRepo.find({ where: { userId } }); + } +} + +@Injectable() +export class OrderStatsService { + constructor(private orderRepo: OrderRepository) {} + + async calculateForUser(userId: string): Promise { + // Focused stats calculation + } +} + +// Orchestration in controller or dedicated orchestrator +@Controller('orders') +export class OrdersController { + constructor( + private orders: OrdersService, + private payment: PaymentService, + private notifications: NotificationService, + ) {} + + @Post() + async create(@CurrentUser() user: User, @Body() dto: CreateOrderDto) { + const order = await this.orders.create(user.id, dto); + await this.payment.charge(order); + await this.notifications.sendOrderConfirmation(order); + return order; + } +} +``` + +Reference: [NestJS Providers](https://docs.nestjs.com/providers) + +--- + +### 1.5 Use Event-Driven Architecture for Decoupling + +**Impact: MEDIUM-HIGH** — Enables async processing and modularity + +Use `@nestjs/event-emitter` for intra-service events and message brokers for inter-service communication. Events allow modules to react to changes without direct dependencies, improving modularity and enabling async processing. + +**Incorrect (direct service coupling):** + +```typescript +// Direct service coupling +@Injectable() +export class OrdersService { + constructor( + private inventoryService: InventoryService, + private emailService: EmailService, + private analyticsService: AnalyticsService, + private notificationService: NotificationService, + private loyaltyService: LoyaltyService, + ) {} + + async createOrder(dto: CreateOrderDto): Promise { + const order = await this.repo.save(dto); + + // Tight coupling - OrdersService knows about all consumers + await this.inventoryService.reserve(order.items); + await this.emailService.sendConfirmation(order); + await this.analyticsService.track('order_created', order); + await this.notificationService.push(order.userId, 'Order placed'); + await this.loyaltyService.addPoints(order.userId, order.total); + + // Adding new behavior requires modifying this service + return order; + } +} +``` + +**Correct (event-driven decoupling):** + +```typescript +// Use EventEmitter for decoupling +import { EventEmitter2 } from '@nestjs/event-emitter'; + +// Define event +export class OrderCreatedEvent { + constructor( + public readonly orderId: string, + public readonly userId: string, + public readonly items: OrderItem[], + public readonly total: number, + ) {} +} + +// Service emits events +@Injectable() +export class OrdersService { + constructor( + private eventEmitter: EventEmitter2, + private repo: Repository, + ) {} + + async createOrder(dto: CreateOrderDto): Promise { + const order = await this.repo.save(dto); + + // Emit event - no knowledge of consumers + this.eventEmitter.emit( + 'order.created', + new OrderCreatedEvent(order.id, order.userId, order.items, order.total), + ); + + return order; + } +} + +// Listeners in separate modules +@Injectable() +export class InventoryListener { + @OnEvent('order.created') + async handleOrderCreated(event: OrderCreatedEvent): Promise { + await this.inventoryService.reserve(event.items); + } +} + +@Injectable() +export class EmailListener { + @OnEvent('order.created') + async handleOrderCreated(event: OrderCreatedEvent): Promise { + await this.emailService.sendConfirmation(event.orderId); + } +} + +@Injectable() +export class AnalyticsListener { + @OnEvent('order.created') + async handleOrderCreated(event: OrderCreatedEvent): Promise { + await this.analyticsService.track('order_created', { + orderId: event.orderId, + total: event.total, + }); + } +} +``` + +Reference: [NestJS Events](https://docs.nestjs.com/techniques/events) + +--- + +### 1.6 Use Repository Pattern for Data Access + +**Impact: HIGH** — Decouples business logic from database + +Create custom repositories to encapsulate complex queries and database logic. This keeps services focused on business logic, makes testing easier with mock repositories, and allows changing database implementations without affecting business code. + +**Incorrect (complex queries in services):** + +```typescript +// Complex queries in services +@Injectable() +export class UsersService { + constructor( + @InjectRepository(User) private repo: Repository, + ) {} + + async findActiveWithOrders(minOrders: number): Promise { + // Complex query logic mixed with business logic + return this.repo + .createQueryBuilder('user') + .leftJoinAndSelect('user.orders', 'order') + .where('user.isActive = :active', { active: true }) + .andWhere('user.deletedAt IS NULL') + .groupBy('user.id') + .having('COUNT(order.id) >= :min', { min: minOrders }) + .orderBy('user.createdAt', 'DESC') + .getMany(); + } + + // Service becomes bloated with query logic +} +``` + +**Correct (custom repository with encapsulated queries):** + +```typescript +// Custom repository with encapsulated queries +@Injectable() +export class UsersRepository { + constructor( + @InjectRepository(User) private repo: Repository, + ) {} + + async findById(id: string): Promise { + return this.repo.findOne({ where: { id } }); + } + + async findByEmail(email: string): Promise { + return this.repo.findOne({ where: { email } }); + } + + async findActiveWithMinOrders(minOrders: number): Promise { + return this.repo + .createQueryBuilder('user') + .leftJoinAndSelect('user.orders', 'order') + .where('user.isActive = :active', { active: true }) + .andWhere('user.deletedAt IS NULL') + .groupBy('user.id') + .having('COUNT(order.id) >= :min', { min: minOrders }) + .orderBy('user.createdAt', 'DESC') + .getMany(); + } + + async save(user: User): Promise { + return this.repo.save(user); + } +} + +// Clean service with business logic only +@Injectable() +export class UsersService { + constructor(private usersRepo: UsersRepository) {} + + async getActiveUsersWithOrders(): Promise { + return this.usersRepo.findActiveWithMinOrders(1); + } + + async create(dto: CreateUserDto): Promise { + const existing = await this.usersRepo.findByEmail(dto.email); + if (existing) { + throw new ConflictException('Email already registered'); + } + + const user = new User(); + user.email = dto.email; + user.name = dto.name; + return this.usersRepo.save(user); + } +} +``` + +Reference: [Repository Pattern](https://martinfowler.com/eaaCatalog/repository.html) + +--- + +## 2. Dependency Injection + +**Section Impact: CRITICAL** + +### 2.1 Avoid Service Locator Anti-Pattern + +**Impact: HIGH** — Hides dependencies and breaks testability + +Avoid using `ModuleRef.get()` or global containers to resolve dependencies at runtime. This hides dependencies, makes code harder to test, and breaks the benefits of dependency injection. Use constructor injection instead. + +**Incorrect (service locator anti-pattern):** + +```typescript +// Use ModuleRef to get dependencies dynamically +@Injectable() +export class OrdersService { + constructor(private moduleRef: ModuleRef) {} + + async createOrder(dto: CreateOrderDto): Promise { + // Dependencies are hidden - not visible in constructor + const usersService = this.moduleRef.get(UsersService); + const inventoryService = this.moduleRef.get(InventoryService); + const paymentService = this.moduleRef.get(PaymentService); + + const user = await usersService.findOne(dto.userId); + // ... rest of logic + } +} + +// Global singleton container +class ServiceContainer { + private static instance: ServiceContainer; + private services = new Map(); + + static getInstance(): ServiceContainer { + if (!this.instance) { + this.instance = new ServiceContainer(); + } + return this.instance; + } + + get(key: string): T { + return this.services.get(key); + } +} +``` + +**Correct (constructor injection with explicit dependencies):** + +```typescript +// Use constructor injection - dependencies are explicit +@Injectable() +export class OrdersService { + constructor( + private usersService: UsersService, + private inventoryService: InventoryService, + private paymentService: PaymentService, + ) {} + + async createOrder(dto: CreateOrderDto): Promise { + const user = await this.usersService.findOne(dto.userId); + const inventory = await this.inventoryService.check(dto.items); + // Dependencies are clear and testable + } +} + +// Easy to test with mocks +describe('OrdersService', () => { + let service: OrdersService; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + providers: [ + OrdersService, + { provide: UsersService, useValue: mockUsersService }, + { provide: InventoryService, useValue: mockInventoryService }, + { provide: PaymentService, useValue: mockPaymentService }, + ], + }).compile(); + + service = module.get(OrdersService); + }); +}); + +// VALID: Factory pattern for dynamic instantiation +@Injectable() +export class HandlerFactory { + constructor(private moduleRef: ModuleRef) {} + + getHandler(type: string): Handler { + switch (type) { + case 'email': + return this.moduleRef.get(EmailHandler); + case 'sms': + return this.moduleRef.get(SmsHandler); + default: + return this.moduleRef.get(DefaultHandler); + } + } +} +``` + +Reference: [NestJS Module Reference](https://docs.nestjs.com/fundamentals/module-ref) + +--- + +### 2.2 Apply Interface Segregation Principle + +**Impact: HIGH** — Reduces coupling and improves testability by 30-50% + +Clients should not be forced to depend on interfaces they don't use. In NestJS, this means keeping interfaces small and focused on specific capabilities rather than creating "fat" interfaces that bundle unrelated methods. When a service only needs to send emails, it shouldn't depend on an interface that also includes SMS, push notifications, and logging. Split large interfaces into role-based ones. + +**Incorrect (fat interface forcing unused dependencies):** + +```typescript +// Fat interface - forces all consumers to depend on everything +interface NotificationService { + sendEmail(to: string, subject: string, body: string): Promise; + sendSms(phone: string, message: string): Promise; + sendPush(userId: string, notification: PushPayload): Promise; + sendSlack(channel: string, message: string): Promise; + logNotification(type: string, payload: any): Promise; + getDeliveryStatus(id: string): Promise; + retryFailed(id: string): Promise; + scheduleNotification(dto: ScheduleDto): Promise; +} + +// Consumer only needs email, but must mock everything for tests +@Injectable() +export class OrdersService { + constructor( + private notifications: NotificationService, // Depends on 8 methods, uses 1 + ) {} + + async confirmOrder(order: Order): Promise { + await this.notifications.sendEmail( + order.customer.email, + 'Order Confirmed', + `Your order ${order.id} has been confirmed.`, + ); + } +} + +// Testing is painful - must mock unused methods +const mockNotificationService = { + sendEmail: jest.fn(), + sendSms: jest.fn(), // Never used, but required + sendPush: jest.fn(), // Never used, but required + sendSlack: jest.fn(), // Never used, but required + logNotification: jest.fn(), // Never used, but required + getDeliveryStatus: jest.fn(), // Never used, but required + retryFailed: jest.fn(), // Never used, but required + scheduleNotification: jest.fn(), // Never used, but required +}; +``` + +**Correct (segregated interfaces by capability):** + +```typescript +// Segregated interfaces - each focused on one capability +interface EmailSender { + sendEmail(to: string, subject: string, body: string): Promise; +} + +interface SmsSender { + sendSms(phone: string, message: string): Promise; +} + +interface PushSender { + sendPush(userId: string, notification: PushPayload): Promise; +} + +interface NotificationLogger { + logNotification(type: string, payload: any): Promise; +} + +interface NotificationScheduler { + scheduleNotification(dto: ScheduleDto): Promise; +} + +// Implementation can implement multiple interfaces +@Injectable() +export class NotificationService implements EmailSender, SmsSender, PushSender { + async sendEmail(to: string, subject: string, body: string): Promise { + // Email implementation + } + + async sendSms(phone: string, message: string): Promise { + // SMS implementation + } + + async sendPush(userId: string, notification: PushPayload): Promise { + // Push implementation + } +} + +// Or separate implementations +@Injectable() +export class SendGridEmailService implements EmailSender { + async sendEmail(to: string, subject: string, body: string): Promise { + // SendGrid-specific implementation + } +} + +// Consumer depends only on what it needs +@Injectable() +export class OrdersService { + constructor( + @Inject(EMAIL_SENDER) private emailSender: EmailSender, // Minimal dependency + ) {} + + async confirmOrder(order: Order): Promise { + await this.emailSender.sendEmail( + order.customer.email, + 'Order Confirmed', + `Your order ${order.id} has been confirmed.`, + ); + } +} + +// Testing is simple - only mock what's used +const mockEmailSender: EmailSender = { + sendEmail: jest.fn(), +}; + +// Module registration with tokens +export const EMAIL_SENDER = Symbol('EMAIL_SENDER'); +export const SMS_SENDER = Symbol('SMS_SENDER'); + +@Module({ + providers: [ + { provide: EMAIL_SENDER, useClass: SendGridEmailService }, + { provide: SMS_SENDER, useClass: TwilioSmsService }, + ], + exports: [EMAIL_SENDER, SMS_SENDER], +}) +export class NotificationModule {} +``` + +**Combining interfaces when needed:** + +```typescript +// Sometimes a consumer legitimately needs multiple capabilities +interface EmailAndSmsSender extends EmailSender, SmsSender {} + +// Or use intersection types +type MultiChannelSender = EmailSender & SmsSender & PushSender; + +// Consumer that genuinely needs multiple channels +@Injectable() +export class AlertService { + constructor( + @Inject(MULTI_CHANNEL_SENDER) + private sender: EmailSender & SmsSender, + ) {} + + async sendCriticalAlert(user: User, message: string): Promise { + await Promise.all([ + this.sender.sendEmail(user.email, 'Critical Alert', message), + this.sender.sendSms(user.phone, message), + ]); + } +} +``` + +Reference: [Interface Segregation Principle](https://en.wikipedia.org/wiki/Interface_segregation_principle) + +--- + +### 2.3 Honor Liskov Substitution Principle + +**Impact: HIGH** — Ensures implementations are truly interchangeable without breaking callers + +Subtypes must be substitutable for their base types without altering program correctness. In NestJS with dependency injection, this means any implementation of an interface or abstract class must honor the contract completely. A mock payment service used in tests must behave like a real payment service (return similar shapes, handle errors the same way). Violating LSP causes subtle bugs when swapping implementations. + +**Incorrect (implementation violates the contract):** + +```typescript +// Base interface with clear contract +interface PaymentGateway { + /** + * Charges the specified amount. + * @returns PaymentResult on success + * @throws PaymentFailedException on payment failure + */ + charge(amount: number, currency: string): Promise; +} + +// Production implementation - follows the contract +@Injectable() +export class StripeService implements PaymentGateway { + async charge(amount: number, currency: string): Promise { + const response = await this.stripe.charges.create({ amount, currency }); + return { success: true, transactionId: response.id, amount }; + } +} + +// Mock that violates LSP - different behavior! +@Injectable() +export class MockPaymentService implements PaymentGateway { + async charge(amount: number, currency: string): Promise { + // VIOLATION 1: Throws for valid input (contract says return PaymentResult) + if (amount > 1000) { + throw new Error('Mock does not support large amounts'); + } + + // VIOLATION 2: Returns null instead of PaymentResult + if (currency !== 'USD') { + return null as any; // Real service would convert or reject properly + } + + // VIOLATION 3: Missing required field + return { success: true } as PaymentResult; // Missing transactionId! + } +} + +// Consumer trusts the contract +@Injectable() +export class OrdersService { + constructor(@Inject(PAYMENT_GATEWAY) private payment: PaymentGateway) {} + + async checkout(order: Order): Promise { + const result = await this.payment.charge(order.total, order.currency); + // These fail with MockPaymentService: + await this.saveTransaction(result.transactionId); // undefined! + await this.sendReceipt(result); // might be null! + } +} +``` + +**Correct (implementations honor the contract):** + +```typescript +// Well-defined interface with documented behavior +interface PaymentGateway { + /** + * Charges the specified amount. + * @param amount - Amount in smallest currency unit (cents) + * @param currency - ISO 4217 currency code + * @returns PaymentResult with transactionId, success status, and amount + * @throws PaymentFailedException if charge is declined + * @throws InvalidCurrencyException if currency is not supported + */ + charge(amount: number, currency: string): Promise; + + /** + * Refunds a previous charge. + * @throws TransactionNotFoundException if transactionId is invalid + */ + refund(transactionId: string, amount?: number): Promise; +} + +// Production implementation +@Injectable() +export class StripeService implements PaymentGateway { + async charge(amount: number, currency: string): Promise { + try { + const response = await this.stripe.charges.create({ amount, currency }); + return { + success: true, + transactionId: response.id, + amount: response.amount, + }; + } catch (error) { + if (error.type === 'card_error') { + throw new PaymentFailedException(error.message); + } + throw error; + } + } + + async refund(transactionId: string, amount?: number): Promise { + // Implementation... + } +} + +// Mock that honors LSP - same contract, same behavior shape +@Injectable() +export class MockPaymentService implements PaymentGateway { + private transactions = new Map(); + + async charge(amount: number, currency: string): Promise { + // Honor the contract: validate currency like real service would + if (!['USD', 'EUR', 'GBP'].includes(currency)) { + throw new InvalidCurrencyException(`Unsupported currency: ${currency}`); + } + + // Simulate decline for specific test scenarios + if (amount === 99999) { + throw new PaymentFailedException('Card declined (test scenario)'); + } + + // Return same shape as production + const result: PaymentResult = { + success: true, + transactionId: `mock_${Date.now()}_${Math.random().toString(36)}`, + amount, + }; + + this.transactions.set(result.transactionId, result); + return result; + } + + async refund(transactionId: string, amount?: number): Promise { + // Honor the contract: throw if transaction not found + if (!this.transactions.has(transactionId)) { + throw new TransactionNotFoundException(transactionId); + } + + return { + success: true, + refundId: `refund_${transactionId}`, + amount: amount ?? this.transactions.get(transactionId)!.amount, + }; + } +} + +// Consumer can swap implementations safely +@Injectable() +export class OrdersService { + constructor(@Inject(PAYMENT_GATEWAY) private payment: PaymentGateway) {} + + async checkout(order: Order): Promise { + try { + const result = await this.payment.charge(order.total, order.currency); + // Works with both StripeService and MockPaymentService + order.transactionId = result.transactionId; + order.status = 'paid'; + return order; + } catch (error) { + if (error instanceof PaymentFailedException) { + order.status = 'payment_failed'; + return order; + } + throw error; + } + } +} +``` + +**Testing LSP compliance:** + +```typescript +// Shared test suite that any implementation must pass +function testPaymentGatewayContract( + createGateway: () => PaymentGateway, +) { + describe('PaymentGateway contract', () => { + let gateway: PaymentGateway; + + beforeEach(() => { + gateway = createGateway(); + }); + + it('returns PaymentResult with all required fields', async () => { + const result = await gateway.charge(1000, 'USD'); + expect(result).toHaveProperty('success'); + expect(result).toHaveProperty('transactionId'); + expect(result).toHaveProperty('amount'); + expect(typeof result.transactionId).toBe('string'); + }); + + it('throws InvalidCurrencyException for unsupported currency', async () => { + await expect(gateway.charge(1000, 'INVALID')) + .rejects.toThrow(InvalidCurrencyException); + }); + + it('throws TransactionNotFoundException for invalid refund', async () => { + await expect(gateway.refund('nonexistent')) + .rejects.toThrow(TransactionNotFoundException); + }); + }); +} + +// Run against all implementations +describe('StripeService', () => { + testPaymentGatewayContract(() => new StripeService(mockStripeClient)); +}); + +describe('MockPaymentService', () => { + testPaymentGatewayContract(() => new MockPaymentService()); +}); +``` + +Reference: [Liskov Substitution Principle](https://en.wikipedia.org/wiki/Liskov_substitution_principle) + +--- + +### 2.4 Prefer Constructor Injection + +**Impact: CRITICAL** — Required for proper DI and testing + +Always use constructor injection over property injection. Constructor injection makes dependencies explicit, enables TypeScript type checking, ensures dependencies are available when the class is instantiated, and improves testability. This is required for proper DI, testing, and TypeScript support. + +**Incorrect (property injection with hidden dependencies):** + +```typescript +// Property injection - avoid unless necessary +@Injectable() +export class UsersService { + @Inject() + private userRepo: UserRepository; // Hidden dependency + + @Inject('CONFIG') + private config: ConfigType; // Also hidden + + async findAll() { + return this.userRepo.find(); + } +} + +// Problems: +// 1. Dependencies not visible in constructor +// 2. Service can be instantiated without dependencies in tests +// 3. TypeScript can't enforce dependency types at instantiation +``` + +**Correct (constructor injection with explicit dependencies):** + +```typescript +// Constructor injection - explicit and testable +@Injectable() +export class UsersService { + constructor( + private readonly userRepo: UserRepository, + @Inject('CONFIG') private readonly config: ConfigType, + ) {} + + async findAll(): Promise { + return this.userRepo.find(); + } +} + +// Testing is straightforward +describe('UsersService', () => { + let service: UsersService; + let mockRepo: jest.Mocked; + + beforeEach(() => { + mockRepo = { + find: jest.fn(), + save: jest.fn(), + } as any; + + service = new UsersService(mockRepo, { dbUrl: 'test' }); + }); + + it('should find all users', async () => { + mockRepo.find.mockResolvedValue([{ id: '1', name: 'Test' }]); + const result = await service.findAll(); + expect(result).toHaveLength(1); + }); +}); + +// Only use property injection for optional dependencies +@Injectable() +export class LoggingService { + @Optional() + @Inject('ANALYTICS') + private analytics?: AnalyticsService; + + log(message: string) { + console.log(message); + this.analytics?.track('log', message); // Optional enhancement + } +} +``` + +Reference: [NestJS Providers](https://docs.nestjs.com/providers) + +--- + +### 2.5 Understand Provider Scopes + +**Impact: CRITICAL** — Prevents data leaks and performance issues + +NestJS has three provider scopes: DEFAULT (singleton), REQUEST (per-request instance), and TRANSIENT (new instance for each injection). Most providers should be singletons. Request-scoped providers have performance implications as they bubble up through the dependency tree. Understanding scopes prevents memory leaks and incorrect data sharing. + +**Incorrect (wrong scope usage):** + +```typescript +// Request-scoped when not needed (performance hit) +@Injectable({ scope: Scope.REQUEST }) +export class UsersService { + // This creates a new instance for EVERY request + // All dependencies also become request-scoped + async findAll() { + return this.userRepo.find(); + } +} + +// Singleton with mutable request state +@Injectable() // Default: singleton +export class RequestContextService { + private userId: string; // DANGER: Shared across all requests! + + setUser(userId: string) { + this.userId = userId; // Overwrites for all concurrent requests + } + + getUser() { + return this.userId; // Returns wrong user! + } +} +``` + +**Correct (appropriate scope for each use case):** + +```typescript +// Singleton for stateless services (default, most common) +@Injectable() +export class UsersService { + constructor(private readonly userRepo: UserRepository) {} + + async findById(id: string): Promise { + return this.userRepo.findOne({ where: { id } }); + } +} + +// Request-scoped ONLY when you need request context +@Injectable({ scope: Scope.REQUEST }) +export class RequestContextService { + private userId: string; + + setUser(userId: string) { + this.userId = userId; + } + + getUser(): string { + return this.userId; + } +} + +// Better: Use NestJS built-in request context +import { REQUEST } from '@nestjs/core'; +import { Request } from 'express'; + +@Injectable({ scope: Scope.REQUEST }) +export class AuditService { + constructor(@Inject(REQUEST) private request: Request) {} + + log(action: string) { + console.log(`User ${this.request.user?.id} performed ${action}`); + } +} + +// Best: Use ClsModule for async context (no scope bubble-up) +import { ClsService } from 'nestjs-cls'; + +@Injectable() // Stays singleton! +export class AuditService { + constructor(private cls: ClsService) {} + + log(action: string) { + const userId = this.cls.get('userId'); + console.log(`User ${userId} performed ${action}`); + } +} +``` + +Reference: [NestJS Injection Scopes](https://docs.nestjs.com/fundamentals/injection-scopes) + +--- + +### 2.6 Use Injection Tokens for Interfaces + +**Impact: HIGH** — Enables interface-based DI at runtime + +TypeScript interfaces are erased at compile time and can't be used as injection tokens. Use string tokens, symbols, or abstract classes when you want to inject implementations of interfaces. This enables swapping implementations for testing or different environments. + +**Incorrect (interface can't be used as token):** + +```typescript +// Interface can't be used as injection token +interface PaymentGateway { + charge(amount: number): Promise; +} + +@Injectable() +export class StripeService implements PaymentGateway { + charge(amount: number) { /* ... */ } +} + +@Injectable() +export class OrdersService { + // This WON'T work - PaymentGateway doesn't exist at runtime + constructor(private payment: PaymentGateway) {} +} +``` + +**Correct (symbol tokens or abstract classes):** + +```typescript +// Option 1: String/Symbol tokens (most flexible) +export const PAYMENT_GATEWAY = Symbol('PAYMENT_GATEWAY'); + +export interface PaymentGateway { + charge(amount: number): Promise; +} + +@Injectable() +export class StripeService implements PaymentGateway { + async charge(amount: number): Promise { + // Stripe implementation + } +} + +@Injectable() +export class MockPaymentService implements PaymentGateway { + async charge(amount: number): Promise { + return { success: true, id: 'mock-id' }; + } +} + +// Module registration +@Module({ + providers: [ + { + provide: PAYMENT_GATEWAY, + useClass: process.env.NODE_ENV === 'test' + ? MockPaymentService + : StripeService, + }, + ], + exports: [PAYMENT_GATEWAY], +}) +export class PaymentModule {} + +// Injection +@Injectable() +export class OrdersService { + constructor( + @Inject(PAYMENT_GATEWAY) private payment: PaymentGateway, + ) {} + + async createOrder(dto: CreateOrderDto) { + await this.payment.charge(dto.amount); + } +} + +// Option 2: Abstract class (carries runtime type info) +export abstract class PaymentGateway { + abstract charge(amount: number): Promise; +} + +@Injectable() +export class StripeService extends PaymentGateway { + async charge(amount: number): Promise { + // Implementation + } +} + +// No @Inject needed with abstract class +@Injectable() +export class OrdersService { + constructor(private payment: PaymentGateway) {} +} +``` + +Reference: [NestJS Custom Providers](https://docs.nestjs.com/fundamentals/custom-providers) + +--- + +## 3. Error Handling + +**Section Impact: HIGH** + +### 3.1 Handle Async Errors Properly + +**Impact: HIGH** — Prevents process crashes from unhandled rejections + +NestJS automatically catches errors from async route handlers, but errors from background tasks, event handlers, and manually created promises can crash your application. Always handle async errors explicitly and use global handlers as a safety net. + +**Incorrect (fire-and-forget without error handling):** + +```typescript +// Fire-and-forget without error handling +@Injectable() +export class UsersService { + async createUser(dto: CreateUserDto): Promise { + const user = await this.repo.save(dto); + + // Fire and forget - if this fails, error is unhandled! + this.emailService.sendWelcome(user.email); + + return user; + } +} + +// Unhandled promise in event handler +@Injectable() +export class OrdersService { + @OnEvent('order.created') + handleOrderCreated(event: OrderCreatedEvent) { + // This returns a promise but it's not awaited! + this.processOrder(event); + // Errors will crash the process + } + + private async processOrder(event: OrderCreatedEvent): Promise { + await this.inventoryService.reserve(event.items); + await this.notificationService.send(event.userId); + } +} + +// Missing try-catch in scheduled tasks +@Cron('0 0 * * *') +async dailyCleanup(): Promise { + await this.cleanupService.run(); + // If this throws, no error handling +} +``` + +**Correct (explicit async error handling):** + +```typescript +// Handle fire-and-forget with explicit catch +@Injectable() +export class UsersService { + private readonly logger = new Logger(UsersService.name); + + async createUser(dto: CreateUserDto): Promise { + const user = await this.repo.save(dto); + + // Explicitly catch and log errors + this.emailService.sendWelcome(user.email).catch((error) => { + this.logger.error('Failed to send welcome email', error.stack); + // Optionally queue for retry + }); + + return user; + } +} + +// Properly handle async event handlers +@Injectable() +export class OrdersService { + private readonly logger = new Logger(OrdersService.name); + + @OnEvent('order.created') + async handleOrderCreated(event: OrderCreatedEvent): Promise { + try { + await this.processOrder(event); + } catch (error) { + this.logger.error('Failed to process order', { event, error }); + // Don't rethrow - would crash the process + await this.deadLetterQueue.add('order.created', event); + } + } +} + +// Safe scheduled tasks +@Injectable() +export class CleanupService { + private readonly logger = new Logger(CleanupService.name); + + @Cron('0 0 * * *') + async dailyCleanup(): Promise { + try { + await this.cleanupService.run(); + this.logger.log('Daily cleanup completed'); + } catch (error) { + this.logger.error('Daily cleanup failed', error.stack); + // Alert or retry logic + } + } +} + +// Global unhandled rejection handler in main.ts +async function bootstrap() { + const app = await NestFactory.create(AppModule); + const logger = new Logger('Bootstrap'); + + process.on('unhandledRejection', (reason, promise) => { + logger.error('Unhandled Rejection at:', promise, 'reason:', reason); + }); + + process.on('uncaughtException', (error) => { + logger.error('Uncaught Exception:', error); + process.exit(1); + }); + + await app.listen(3000); +} +``` + +Reference: [Node.js Unhandled Rejections](https://nodejs.org/api/process.html#event-unhandledrejection) + +--- + +### 3.2 Throw HTTP Exceptions from Services + +**Impact: HIGH** — Keeps controllers thin and simplifies error handling + +It's acceptable (and often preferable) to throw `HttpException` subclasses from services in HTTP applications. This keeps controllers thin and allows services to communicate appropriate error states. For truly layer-agnostic services, use domain exceptions that map to HTTP status codes. + +**Incorrect (return error objects instead of throwing):** + +```typescript +// Return error objects instead of throwing +@Injectable() +export class UsersService { + async findById(id: string): Promise<{ user?: User; error?: string }> { + const user = await this.repo.findOne({ where: { id } }); + if (!user) { + return { error: 'User not found' }; // Controller must check this + } + return { user }; + } +} + +@Controller('users') +export class UsersController { + @Get(':id') + async findOne(@Param('id') id: string) { + const result = await this.usersService.findById(id); + if (result.error) { + throw new NotFoundException(result.error); + } + return result.user; + } +} +``` + +**Correct (throw exceptions directly from service):** + +```typescript +// Throw exceptions directly from service +@Injectable() +export class UsersService { + constructor(private readonly repo: UserRepository) {} + + async findById(id: string): Promise { + const user = await this.repo.findOne({ where: { id } }); + if (!user) { + throw new NotFoundException(`User #${id} not found`); + } + return user; + } + + async create(dto: CreateUserDto): Promise { + const existing = await this.repo.findOne({ + where: { email: dto.email }, + }); + if (existing) { + throw new ConflictException('Email already registered'); + } + return this.repo.save(dto); + } + + async update(id: string, dto: UpdateUserDto): Promise { + const user = await this.findById(id); // Throws if not found + Object.assign(user, dto); + return this.repo.save(user); + } +} + +// Controller stays thin +@Controller('users') +export class UsersController { + @Get(':id') + findOne(@Param('id') id: string): Promise { + return this.usersService.findById(id); + } + + @Post() + create(@Body() dto: CreateUserDto): Promise { + return this.usersService.create(dto); + } +} + +// For layer-agnostic services, use domain exceptions +export class EntityNotFoundException extends Error { + constructor( + public readonly entity: string, + public readonly id: string, + ) { + super(`${entity} with ID "${id}" not found`); + } +} + +// Map to HTTP in exception filter +@Catch(EntityNotFoundException) +export class EntityNotFoundFilter implements ExceptionFilter { + catch(exception: EntityNotFoundException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + + response.status(404).json({ + statusCode: 404, + message: exception.message, + entity: exception.entity, + id: exception.id, + }); + } +} +``` + +Reference: [NestJS Exception Filters](https://docs.nestjs.com/exception-filters) + +--- + +### 3.3 Use Exception Filters for Error Handling + +**Impact: HIGH** — Consistent, centralized error handling + +Never catch exceptions and manually format error responses in controllers. Use NestJS exception filters to handle errors consistently across your application. Create custom exception filters for specific error types and a global filter for unhandled exceptions. + +**Incorrect (manual error handling in controllers):** + +```typescript +// Manual error handling in controllers +@Controller('users') +export class UsersController { + @Get(':id') + async findOne(@Param('id') id: string, @Res() res: Response) { + try { + const user = await this.usersService.findById(id); + if (!user) { + return res.status(404).json({ + statusCode: 404, + message: 'User not found', + }); + } + return res.json(user); + } catch (error) { + console.error(error); + return res.status(500).json({ + statusCode: 500, + message: 'Internal server error', + }); + } + } +} +``` + +**Correct (exception filters with consistent handling):** + +```typescript +// Use built-in and custom exceptions +@Controller('users') +export class UsersController { + @Get(':id') + async findOne(@Param('id') id: string): Promise { + const user = await this.usersService.findById(id); + if (!user) { + throw new NotFoundException(`User #${id} not found`); + } + return user; + } +} + +// Custom domain exception +export class UserNotFoundException extends NotFoundException { + constructor(userId: string) { + super({ + statusCode: 404, + error: 'Not Found', + message: `User with ID "${userId}" not found`, + code: 'USER_NOT_FOUND', + }); + } +} + +// Custom exception filter for domain errors +@Catch(DomainException) +export class DomainExceptionFilter implements ExceptionFilter { + catch(exception: DomainException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + const status = exception.getStatus?.() || 400; + + response.status(status).json({ + statusCode: status, + code: exception.code, + message: exception.message, + timestamp: new Date().toISOString(), + path: request.url, + }); + } +} + +// Global exception filter for unhandled errors +@Catch() +export class AllExceptionsFilter implements ExceptionFilter { + constructor(private readonly logger: Logger) {} + + catch(exception: unknown, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + const status = + exception instanceof HttpException + ? exception.getStatus() + : HttpStatus.INTERNAL_SERVER_ERROR; + + const message = + exception instanceof HttpException + ? exception.message + : 'Internal server error'; + + this.logger.error( + `${request.method} ${request.url}`, + exception instanceof Error ? exception.stack : exception, + ); + + response.status(status).json({ + statusCode: status, + message, + timestamp: new Date().toISOString(), + path: request.url, + }); + } +} + +// Register globally in main.ts +app.useGlobalFilters( + new AllExceptionsFilter(app.get(Logger)), + new DomainExceptionFilter(), +); + +// Or via module +@Module({ + providers: [ + { + provide: APP_FILTER, + useClass: AllExceptionsFilter, + }, + ], +}) +export class AppModule {} +``` + +Reference: [NestJS Exception Filters](https://docs.nestjs.com/exception-filters) + +--- + +## 4. Security + +**Section Impact: HIGH** + +### 4.1 Implement Secure JWT Authentication + +**Impact: CRITICAL** — Essential for secure APIs + +Use `@nestjs/jwt` with `@nestjs/passport` for authentication. Store secrets securely, use appropriate token lifetimes, implement refresh tokens, and validate tokens properly. Never expose sensitive data in JWT payloads. + +**Incorrect (insecure JWT implementation):** + +```typescript +// Hardcode secrets +@Module({ + imports: [ + JwtModule.register({ + secret: 'my-secret-key', // Exposed in code + signOptions: { expiresIn: '7d' }, // Too long + }), + ], +}) +export class AuthModule {} + +// Store sensitive data in JWT +async login(user: User): Promise<{ accessToken: string }> { + const payload = { + sub: user.id, + email: user.email, + password: user.password, // NEVER include password! + ssn: user.ssn, // NEVER include sensitive data! + isAdmin: user.isAdmin, // Can be tampered if not verified + }; + return { accessToken: this.jwtService.sign(payload) }; +} + +// Skip token validation +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor() { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: 'my-secret', + }); + } + + async validate(payload: any): Promise { + return payload; // No validation of user existence + } +} +``` + +**Correct (secure JWT with refresh tokens):** + +```typescript +// Secure JWT configuration +@Module({ + imports: [ + JwtModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + secret: config.get('JWT_SECRET'), + signOptions: { + expiresIn: '15m', // Short-lived access tokens + issuer: config.get('JWT_ISSUER'), + audience: config.get('JWT_AUDIENCE'), + }, + }), + }), + PassportModule.register({ defaultStrategy: 'jwt' }), + ], +}) +export class AuthModule {} + +// Minimal JWT payload +@Injectable() +export class AuthService { + async login(user: User): Promise { + // Only include necessary, non-sensitive data + const payload: JwtPayload = { + sub: user.id, + email: user.email, + roles: user.roles, + iat: Math.floor(Date.now() / 1000), + }; + + const accessToken = this.jwtService.sign(payload); + const refreshToken = await this.createRefreshToken(user.id); + + return { accessToken, refreshToken, expiresIn: 900 }; + } + + private async createRefreshToken(userId: string): Promise { + const token = randomBytes(32).toString('hex'); + const hashedToken = await bcrypt.hash(token, 10); + + await this.refreshTokenRepo.save({ + userId, + token: hashedToken, + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days + }); + + return token; + } +} + +// Proper JWT strategy with validation +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor( + private config: ConfigService, + private usersService: UsersService, + ) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: config.get('JWT_SECRET'), + ignoreExpiration: false, + issuer: config.get('JWT_ISSUER'), + audience: config.get('JWT_AUDIENCE'), + }); + } + + async validate(payload: JwtPayload): Promise { + // Verify user still exists and is active + const user = await this.usersService.findById(payload.sub); + + if (!user || !user.isActive) { + throw new UnauthorizedException('User not found or inactive'); + } + + // Verify token wasn't issued before password change + if (user.passwordChangedAt) { + const tokenIssuedAt = new Date(payload.iat * 1000); + if (tokenIssuedAt < user.passwordChangedAt) { + throw new UnauthorizedException('Token invalidated by password change'); + } + } + + return user; + } +} +``` + +Reference: [NestJS Authentication](https://docs.nestjs.com/security/authentication) + +--- + +### 4.2 Implement Rate Limiting + +**Impact: HIGH** — Protects against abuse and ensures fair resource usage + +Use `@nestjs/throttler` to limit request rates per client. Apply different limits for different endpoints - stricter for auth endpoints, more relaxed for read operations. Consider using Redis for distributed rate limiting in clustered deployments. + +**Incorrect (no rate limiting on sensitive endpoints):** + +```typescript +// No rate limiting on sensitive endpoints +@Controller('auth') +export class AuthController { + @Post('login') + async login(@Body() dto: LoginDto): Promise { + // Attackers can brute-force credentials + return this.authService.login(dto); + } + + @Post('forgot-password') + async forgotPassword(@Body() dto: ForgotPasswordDto): Promise { + // Can be abused to spam users with emails + return this.authService.sendResetEmail(dto.email); + } +} + +// Same limits for all endpoints +@UseGuards(ThrottlerGuard) +@Controller('api') +export class ApiController { + @Get('public-data') + async getPublic() {} // Should allow more requests + + @Post('process-payment') + async payment() {} // Should be more restrictive +} +``` + +**Correct (configured throttler with endpoint-specific limits):** + +```typescript +// Configure throttler globally with multiple limits +import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; + +@Module({ + imports: [ + ThrottlerModule.forRoot([ + { + name: 'short', + ttl: 1000, // 1 second + limit: 3, // 3 requests per second + }, + { + name: 'medium', + ttl: 10000, // 10 seconds + limit: 20, // 20 requests per 10 seconds + }, + { + name: 'long', + ttl: 60000, // 1 minute + limit: 100, // 100 requests per minute + }, + ]), + ], + providers: [ + { + provide: APP_GUARD, + useClass: ThrottlerGuard, + }, + ], +}) +export class AppModule {} + +// Override limits per endpoint +@Controller('auth') +export class AuthController { + @Post('login') + @Throttle({ short: { limit: 5, ttl: 60000 } }) // 5 attempts per minute + async login(@Body() dto: LoginDto): Promise { + return this.authService.login(dto); + } + + @Post('forgot-password') + @Throttle({ short: { limit: 3, ttl: 3600000 } }) // 3 per hour + async forgotPassword(@Body() dto: ForgotPasswordDto): Promise { + return this.authService.sendResetEmail(dto.email); + } +} + +// Skip throttling for certain routes +@Controller('health') +export class HealthController { + @Get() + @SkipThrottle() + check(): string { + return 'OK'; + } +} + +// Custom throttle per user type +@Injectable() +export class CustomThrottlerGuard extends ThrottlerGuard { + protected async getTracker(req: Request): Promise { + // Use user ID if authenticated, IP otherwise + return req.user?.id || req.ip; + } + + protected async getLimit(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + + // Higher limits for authenticated users + if (request.user) { + return request.user.isPremium ? 1000 : 200; + } + + return 50; // Anonymous users + } +} +``` + +Reference: [NestJS Throttler](https://docs.nestjs.com/security/rate-limiting) + +--- + +### 4.3 Sanitize Output to Prevent XSS + +**Impact: HIGH** — XSS vulnerabilities can compromise user sessions and data + +While NestJS APIs typically return JSON (which browsers don't execute), XSS risks exist when rendering HTML, storing user content, or when frontend frameworks improperly handle API responses. Sanitize user-generated content before storage and use proper Content-Type headers. + +**Incorrect (storing raw HTML without sanitization):** + +```typescript +// Store raw HTML from users +@Injectable() +export class CommentsService { + async create(dto: CreateCommentDto): Promise { + // User can inject: + return this.repo.save({ + content: dto.content, // Raw, unsanitized + authorId: dto.authorId, + }); + } +} + +// Return HTML without sanitization +@Controller('pages') +export class PagesController { + @Get(':slug') + @Header('Content-Type', 'text/html') + async getPage(@Param('slug') slug: string): Promise { + const page = await this.pagesService.findBySlug(slug); + // If page.content contains user input, XSS is possible + return `${page.content}`; + } +} + +// Reflect user input in errors +@Get(':id') +async findOne(@Param('id') id: string): Promise { + const user = await this.repo.findOne({ where: { id } }); + if (!user) { + // XSS if id contains malicious content and error is rendered + throw new NotFoundException(`User ${id} not found`); + } + return user; +} +``` + +**Correct (sanitize content and use proper headers):** + +```typescript +// Sanitize HTML content before storage +import * as sanitizeHtml from 'sanitize-html'; + +@Injectable() +export class CommentsService { + private readonly sanitizeOptions: sanitizeHtml.IOptions = { + allowedTags: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'], + allowedAttributes: { + a: ['href', 'title'], + }, + allowedSchemes: ['http', 'https', 'mailto'], + }; + + async create(dto: CreateCommentDto): Promise { + return this.repo.save({ + content: sanitizeHtml(dto.content, this.sanitizeOptions), + authorId: dto.authorId, + }); + } +} + +// Use validation pipe to strip HTML +import { Transform } from 'class-transformer'; + +export class CreatePostDto { + @IsString() + @MaxLength(1000) + @Transform(({ value }) => sanitizeHtml(value, { allowedTags: [] })) + title: string; + + @IsString() + @Transform(({ value }) => + sanitizeHtml(value, { + allowedTags: ['p', 'br', 'b', 'i', 'a'], + allowedAttributes: { a: ['href'] }, + }), + ) + content: string; +} + +// Set proper Content-Type headers +@Controller('api') +export class ApiController { + @Get('data') + @Header('Content-Type', 'application/json') + async getData(): Promise { + // JSON response - browser won't execute scripts + return this.service.getData(); + } +} + +// Sanitize error messages +@Get(':id') +async findOne(@Param('id', ParseUUIDPipe) id: string): Promise { + const user = await this.repo.findOne({ where: { id } }); + if (!user) { + // UUID validation ensures safe format + throw new NotFoundException('User not found'); + } + return user; +} + +// Use Helmet for CSP headers +import helmet from 'helmet'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + app.use( + helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"], + imgSrc: ["'self'", 'data:', 'https:'], + }, + }, + }), + ); + + await app.listen(3000); +} +``` + +Reference: [OWASP XSS Prevention](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html) + +--- + +### 4.4 Use Guards for Authentication and Authorization + +**Impact: HIGH** — Enforces access control before handlers execute + +Guards determine whether a request should be handled based on authentication state, roles, permissions, or other conditions. They run after middleware but before pipes and interceptors, making them ideal for access control. Use guards instead of manual checks in controllers. + +**Incorrect (manual auth checks in every handler):** + +```typescript +// Manual auth checks in every handler +@Controller('admin') +export class AdminController { + @Get('users') + async getUsers(@Request() req) { + if (!req.user) { + throw new UnauthorizedException(); + } + if (!req.user.roles.includes('admin')) { + throw new ForbiddenException(); + } + return this.adminService.getUsers(); + } + + @Delete('users/:id') + async deleteUser(@Request() req, @Param('id') id: string) { + if (!req.user) { + throw new UnauthorizedException(); + } + if (!req.user.roles.includes('admin')) { + throw new ForbiddenException(); + } + return this.adminService.deleteUser(id); + } +} +``` + +**Correct (guards with declarative decorators):** + +```typescript +// JWT Auth Guard +@Injectable() +export class JwtAuthGuard implements CanActivate { + constructor( + private jwtService: JwtService, + private reflector: Reflector, + ) {} + + async canActivate(context: ExecutionContext): Promise { + // Check for @Public() decorator + const isPublic = this.reflector.getAllAndOverride('isPublic', [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) return true; + + const request = context.switchToHttp().getRequest(); + const token = this.extractToken(request); + + if (!token) { + throw new UnauthorizedException('No token provided'); + } + + try { + request.user = await this.jwtService.verifyAsync(token); + return true; + } catch { + throw new UnauthorizedException('Invalid token'); + } + } + + private extractToken(request: Request): string | undefined { + const [type, token] = request.headers.authorization?.split(' ') ?? []; + return type === 'Bearer' ? token : undefined; + } +} + +// Roles Guard +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredRoles = this.reflector.getAllAndOverride('roles', [ + context.getHandler(), + context.getClass(), + ]); + + if (!requiredRoles) return true; + + const { user } = context.switchToHttp().getRequest(); + return requiredRoles.some((role) => user.roles?.includes(role)); + } +} + +// Decorators +export const Public = () => SetMetadata('isPublic', true); +export const Roles = (...roles: Role[]) => SetMetadata('roles', roles); + +// Register guards globally +@Module({ + providers: [ + { provide: APP_GUARD, useClass: JwtAuthGuard }, + { provide: APP_GUARD, useClass: RolesGuard }, + ], +}) +export class AppModule {} + +// Clean controller +@Controller('admin') +@Roles(Role.Admin) // Applied to all routes +export class AdminController { + @Get('users') + getUsers(): Promise { + return this.adminService.getUsers(); + } + + @Delete('users/:id') + deleteUser(@Param('id') id: string): Promise { + return this.adminService.deleteUser(id); + } + + @Public() // Override: no auth required + @Get('health') + health() { + return { status: 'ok' }; + } +} +``` + +Reference: [NestJS Guards](https://docs.nestjs.com/guards) + +--- + +### 4.5 Validate All Input with DTOs and Pipes + +**Impact: HIGH** — First line of defense against attacks + +Always validate incoming data using class-validator decorators on DTOs and the global ValidationPipe. Never trust user input. Validate all request bodies, query parameters, and route parameters before processing. + +**Incorrect (trust raw input without validation):** + +```typescript +// Trust raw input without validation +@Controller('users') +export class UsersController { + @Post() + create(@Body() body: any) { + // body could contain anything - SQL injection, XSS, etc. + return this.usersService.create(body); + } + + @Get() + findAll(@Query() query: any) { + // query.limit could be "'; DROP TABLE users; --" + return this.usersService.findAll(query.limit); + } +} + +// DTOs without validation decorators +export class CreateUserDto { + name: string; // No validation + email: string; // Could be "not-an-email" + age: number; // Could be "abc" or -999 +} +``` + +**Correct (validated DTOs with global ValidationPipe):** + +```typescript +// Enable ValidationPipe globally in main.ts +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, // Strip unknown properties + forbidNonWhitelisted: true, // Throw on unknown properties + transform: true, // Auto-transform to DTO types + transformOptions: { + enableImplicitConversion: true, + }, + }), + ); + + await app.listen(3000); +} + +// Create well-validated DTOs +import { + IsString, + IsEmail, + IsInt, + Min, + Max, + IsOptional, + MinLength, + MaxLength, + Matches, + IsNotEmpty, +} from 'class-validator'; +import { Transform, Type } from 'class-transformer'; + +export class CreateUserDto { + @IsString() + @IsNotEmpty() + @MinLength(2) + @MaxLength(100) + @Transform(({ value }) => value?.trim()) + name: string; + + @IsEmail() + @Transform(({ value }) => value?.toLowerCase().trim()) + email: string; + + @IsInt() + @Min(0) + @Max(150) + age: number; + + @IsString() + @MinLength(8) + @MaxLength(100) + @Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, { + message: 'Password must contain uppercase, lowercase, and number', + }) + password: string; +} + +// Query DTO with defaults and transformation +export class FindUsersQueryDto { + @IsOptional() + @IsString() + @MaxLength(100) + search?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit: number = 20; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + offset: number = 0; +} + +// Param validation +export class UserIdParamDto { + @IsUUID('4') + id: string; +} + +@Controller('users') +export class UsersController { + @Post() + create(@Body() dto: CreateUserDto): Promise { + // dto is guaranteed to be valid + return this.usersService.create(dto); + } + + @Get() + findAll(@Query() query: FindUsersQueryDto): Promise { + // query.limit is a number, query.search is sanitized + return this.usersService.findAll(query); + } + + @Get(':id') + findOne(@Param() params: UserIdParamDto): Promise { + // params.id is a valid UUID + return this.usersService.findById(params.id); + } +} +``` + +Reference: [NestJS Validation](https://docs.nestjs.com/techniques/validation) + +--- + +## 5. Performance + +**Section Impact: HIGH** + +### 5.1 Use Async Lifecycle Hooks Correctly + +**Impact: HIGH** — Improper async handling blocks application startup + +NestJS lifecycle hooks (`onModuleInit`, `onApplicationBootstrap`, etc.) support async operations. However, misusing them can block application startup or cause race conditions. Understand the lifecycle order and use hooks appropriately. + +**Incorrect (fire-and-forget async without await):** + +```typescript +// Fire-and-forget async without await +@Injectable() +export class DatabaseService implements OnModuleInit { + onModuleInit() { + // This runs but doesn't block - app starts before DB is ready! + this.connect(); + } + + private async connect() { + await this.pool.connect(); + console.log('Database connected'); + } +} + +// Heavy blocking operations in constructor +@Injectable() +export class ConfigService { + private config: Config; + + constructor() { + // BLOCKS entire module instantiation synchronously + this.config = fs.readFileSync('config.json'); + } +} +``` + +**Correct (return promises from async hooks):** + +```typescript +// Return promise from async hooks +@Injectable() +export class DatabaseService implements OnModuleInit { + private pool: Pool; + + async onModuleInit(): Promise { + // NestJS waits for this to complete before continuing + await this.pool.connect(); + console.log('Database connected'); + } + + async onModuleDestroy(): Promise { + // Clean up resources on shutdown + await this.pool.end(); + console.log('Database disconnected'); + } +} + +// Use onApplicationBootstrap for cross-module dependencies +@Injectable() +export class CacheWarmerService implements OnApplicationBootstrap { + constructor( + private cache: CacheService, + private products: ProductsService, + ) {} + + async onApplicationBootstrap(): Promise { + // All modules are initialized, safe to warm cache + const products = await this.products.findPopular(); + await this.cache.warmup(products); + } +} + +// Heavy init in async hooks, not constructor +@Injectable() +export class ConfigService implements OnModuleInit { + private config: Config; + + constructor() { + // Keep constructor synchronous and fast + } + + async onModuleInit(): Promise { + // Async loading in lifecycle hook + this.config = await this.loadConfig(); + } + + private async loadConfig(): Promise { + const file = await fs.promises.readFile('config.json'); + return JSON.parse(file.toString()); + } + + get(key: string): T { + return this.config[key]; + } +} + +// Enable shutdown hooks in main.ts +async function bootstrap() { + const app = await NestFactory.create(AppModule); + app.enableShutdownHooks(); // Enable SIGTERM/SIGINT handling + await app.listen(3000); +} +``` + +Reference: [NestJS Lifecycle Events](https://docs.nestjs.com/fundamentals/lifecycle-events) + +--- + +### 5.2 Use Lazy Loading for Large Modules + +**Impact: MEDIUM** — Improves startup time for large applications + +NestJS supports lazy-loading modules, which defers initialization until first use. This is valuable for large applications where some features are rarely used, serverless deployments where cold start time matters, or when certain modules have heavy initialization costs. + +**Incorrect (loading everything eagerly):** + +```typescript +// Load everything eagerly in a large app +@Module({ + imports: [ + UsersModule, + OrdersModule, + PaymentsModule, + ReportsModule, // Heavy, rarely used + AnalyticsModule, // Heavy, rarely used + AdminModule, // Only admins use this + LegacyModule, // Migration module, rarely used + BulkImportModule, // Used once a month + ], +}) +export class AppModule {} + +// All modules initialize at startup, even if never used +// Slow cold starts in serverless +// Memory wasted on unused modules +``` + +**Correct (lazy load rarely-used modules):** + +```typescript +// Use LazyModuleLoader for optional modules +import { LazyModuleLoader } from '@nestjs/core'; + +@Injectable() +export class ReportsService { + constructor(private lazyModuleLoader: LazyModuleLoader) {} + + async generateReport(type: string): Promise { + // Load module only when needed + const { ReportsModule } = await import('./reports/reports.module'); + const moduleRef = await this.lazyModuleLoader.load(() => ReportsModule); + + const reportsService = moduleRef.get(ReportsGeneratorService); + return reportsService.generate(type); + } +} + +// Lazy load admin features with caching +@Injectable() +export class AdminService { + private adminModule: ModuleRef | null = null; + + constructor(private lazyModuleLoader: LazyModuleLoader) {} + + private async getAdminModule(): Promise { + if (!this.adminModule) { + const { AdminModule } = await import('./admin/admin.module'); + this.adminModule = await this.lazyModuleLoader.load(() => AdminModule); + } + return this.adminModule; + } + + async runAdminTask(task: string): Promise { + const moduleRef = await this.getAdminModule(); + const taskRunner = moduleRef.get(AdminTaskRunner); + await taskRunner.run(task); + } +} + +// Reusable lazy loader service +@Injectable() +export class ModuleLoaderService { + private loadedModules = new Map(); + + constructor(private lazyModuleLoader: LazyModuleLoader) {} + + async load( + key: string, + importFn: () => Promise<{ default: Type } | Type>, + ): Promise { + if (!this.loadedModules.has(key)) { + const module = await importFn(); + const moduleType = 'default' in module ? module.default : module; + const moduleRef = await this.lazyModuleLoader.load(() => moduleType); + this.loadedModules.set(key, moduleRef); + } + return this.loadedModules.get(key)!; + } +} + +// Preload modules in background after startup +@Injectable() +export class ModulePreloader implements OnApplicationBootstrap { + constructor(private lazyModuleLoader: LazyModuleLoader) {} + + async onApplicationBootstrap(): Promise { + setTimeout(async () => { + await this.preloadModule(() => import('./reports/reports.module')); + }, 5000); // 5 seconds after startup + } + + private async preloadModule(importFn: () => Promise): Promise { + try { + const module = await importFn(); + const moduleType = module.default || Object.values(module)[0]; + await this.lazyModuleLoader.load(() => moduleType); + } catch (error) { + console.warn('Failed to preload module', error); + } + } +} +``` + +Reference: [NestJS Lazy Loading Modules](https://docs.nestjs.com/fundamentals/lazy-loading-modules) + +--- + +### 5.3 Optimize Database Queries + +**Impact: HIGH** — Database queries are typically the largest source of latency + +Select only needed columns, use proper indexes, avoid over-fetching relations, and consider query performance when designing your data access. Most API slowness traces back to inefficient database queries. + +**Incorrect (over-fetching data and missing indexes):** + +```typescript +// Select everything when you need few fields +@Injectable() +export class UsersService { + async findAllEmails(): Promise { + const users = await this.repo.find(); + // Fetches ALL columns for ALL users + return users.map((u) => u.email); + } + + async getUserSummary(id: string): Promise { + const user = await this.repo.findOne({ + where: { id }, + relations: ['posts', 'posts.comments', 'posts.comments.author', 'followers'], + }); + // Over-fetches massive relation tree + return { name: user.name, postCount: user.posts.length }; + } +} + +// No indexes on frequently queried columns +@Entity() +export class Order { + @Column() + userId: string; // No index - full table scan on every lookup + + @Column() + status: string; // No index - slow status filtering +} +``` + +**Correct (select only needed data with proper indexes):** + +```typescript +// Select only needed columns +@Injectable() +export class UsersService { + async findAllEmails(): Promise { + const users = await this.repo.find({ + select: ['email'], // Only fetch email column + }); + return users.map((u) => u.email); + } + + // Use QueryBuilder for complex selections + async getUserSummary(id: string): Promise { + return this.repo + .createQueryBuilder('user') + .select('user.name', 'name') + .addSelect('COUNT(post.id)', 'postCount') + .leftJoin('user.posts', 'post') + .where('user.id = :id', { id }) + .groupBy('user.id') + .getRawOne(); + } + + // Fetch relations only when needed + async getFullProfile(id: string): Promise { + return this.repo.findOne({ + where: { id }, + relations: ['posts'], // Only immediate relation + select: { + id: true, + name: true, + email: true, + posts: { + id: true, + title: true, + }, + }, + }); + } +} + +// Add indexes on frequently queried columns +@Entity() +@Index(['userId']) +@Index(['status']) +@Index(['createdAt']) +@Index(['userId', 'status']) // Composite index for common query pattern +export class Order { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + userId: string; + + @Column() + status: string; + + @CreateDateColumn() + createdAt: Date; +} + +// Always paginate large datasets +@Injectable() +export class OrdersService { + async findAll(page = 1, limit = 20): Promise> { + const [items, total] = await this.repo.findAndCount({ + skip: (page - 1) * limit, + take: limit, + order: { createdAt: 'DESC' }, + }); + + return { + items, + meta: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }; + } +} +``` + +Reference: [TypeORM Query Builder](https://typeorm.io/select-query-builder) + +--- + +### 5.4 Use Caching Strategically + +**Impact: HIGH** — Dramatically reduces database load and response times + +Implement caching for expensive operations, frequently accessed data, and external API calls. Use NestJS CacheModule with appropriate TTLs and cache invalidation strategies. Don't cache everything - focus on high-impact areas. + +**Incorrect (no caching or caching everything):** + +```typescript +// No caching for expensive, repeated queries +@Injectable() +export class ProductsService { + async getPopular(): Promise { + // Runs complex aggregation query EVERY request + return this.productsRepo + .createQueryBuilder('p') + .leftJoin('p.orders', 'o') + .select('p.*, COUNT(o.id) as orderCount') + .groupBy('p.id') + .orderBy('orderCount', 'DESC') + .limit(20) + .getMany(); + } +} + +// Cache everything without thought +@Injectable() +export class UsersService { + @CacheKey('users') + @CacheTTL(3600) + @UseInterceptors(CacheInterceptor) + async findAll(): Promise { + // Caching user list for 1 hour is wrong if data changes frequently + return this.usersRepo.find(); + } +} +``` + +**Correct (strategic caching with proper invalidation):** + +```typescript +// Setup caching module +@Module({ + imports: [ + CacheModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + stores: [ + new KeyvRedis(config.get('REDIS_URL')), + ], + ttl: 60 * 1000, // Default 60s + }), + }), + ], +}) +export class AppModule {} + +// Manual caching for granular control +@Injectable() +export class ProductsService { + constructor( + @Inject(CACHE_MANAGER) private cache: Cache, + private productsRepo: ProductRepository, + ) {} + + async getPopular(): Promise { + const cacheKey = 'products:popular'; + + // Try cache first + const cached = await this.cache.get(cacheKey); + if (cached) return cached; + + // Cache miss - fetch and cache + const products = await this.fetchPopularProducts(); + await this.cache.set(cacheKey, products, 5 * 60 * 1000); // 5 min TTL + return products; + } + + // Invalidate cache on changes + async updateProduct(id: string, dto: UpdateProductDto): Promise { + const product = await this.productsRepo.save({ id, ...dto }); + await this.cache.del('products:popular'); // Invalidate + return product; + } +} + +// Decorator-based caching with auto-interceptor +@Controller('categories') +@UseInterceptors(CacheInterceptor) +export class CategoriesController { + @Get() + @CacheTTL(30 * 60 * 1000) // 30 minutes - categories rarely change + findAll(): Promise { + return this.categoriesService.findAll(); + } + + @Get(':id') + @CacheTTL(60 * 1000) // 1 minute + @CacheKey('category') + findOne(@Param('id') id: string): Promise { + return this.categoriesService.findOne(id); + } +} + +// Event-based cache invalidation +@Injectable() +export class CacheInvalidationService { + constructor(@Inject(CACHE_MANAGER) private cache: Cache) {} + + @OnEvent('product.created') + @OnEvent('product.updated') + @OnEvent('product.deleted') + async invalidateProductCaches(event: ProductEvent) { + await Promise.all([ + this.cache.del('products:popular'), + this.cache.del(`product:${event.productId}`), + ]); + } +} +``` + +Reference: [NestJS Caching](https://docs.nestjs.com/techniques/caching) + +--- + +## 6. Testing + +**Section Impact: MEDIUM-HIGH** + +### 6.1 Use Supertest for E2E Testing + +**Impact: HIGH** — Validates the full request/response cycle + +End-to-end tests use Supertest to make real HTTP requests against your NestJS application. They test the full stack including middleware, guards, pipes, and interceptors. E2E tests catch integration issues that unit tests miss. + +**Incorrect (no proper E2E setup or teardown):** + +```typescript +// Only unit test controllers +describe('UsersController', () => { + it('should return users', async () => { + const service = { findAll: jest.fn().mockResolvedValue([]) }; + const controller = new UsersController(service as any); + + const result = await controller.findAll(); + + expect(result).toEqual([]); + // Doesn't test: routes, guards, pipes, serialization + }); +}); + +// E2E tests without proper setup/teardown +describe('Users API', () => { + it('should create user', async () => { + const app = await NestFactory.create(AppModule); + // No proper initialization + // No cleanup after test + // Hits real database + }); +}); +``` + +**Correct (proper E2E setup with Supertest):** + +```typescript +// Proper E2E test setup +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import * as request from 'supertest'; +import { AppModule } from '../src/app.module'; + +describe('UsersController (e2e)', () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + + // Apply same config as production + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: true, + }), + ); + + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('/users (POST)', () => { + it('should create a user', () => { + return request(app.getHttpServer()) + .post('/users') + .send({ name: 'John', email: 'john@test.com' }) + .expect(201) + .expect((res) => { + expect(res.body).toHaveProperty('id'); + expect(res.body.name).toBe('John'); + expect(res.body.email).toBe('john@test.com'); + }); + }); + + it('should return 400 for invalid email', () => { + return request(app.getHttpServer()) + .post('/users') + .send({ name: 'John', email: 'invalid-email' }) + .expect(400) + .expect((res) => { + expect(res.body.message).toContain('email'); + }); + }); + }); + + describe('/users/:id (GET)', () => { + it('should return 404 for non-existent user', () => { + return request(app.getHttpServer()) + .get('/users/non-existent-id') + .expect(404); + }); + }); +}); + +// Testing with authentication +describe('Protected Routes (e2e)', () => { + let app: INestApplication; + let authToken: string; + + beforeAll(async () => { + const moduleFixture = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ whitelist: true })); + await app.init(); + + // Get auth token + const loginResponse = await request(app.getHttpServer()) + .post('/auth/login') + .send({ email: 'test@test.com', password: 'password' }); + + authToken = loginResponse.body.accessToken; + }); + + it('should return 401 without token', () => { + return request(app.getHttpServer()) + .get('/users/me') + .expect(401); + }); + + it('should return user profile with valid token', () => { + return request(app.getHttpServer()) + .get('/users/me') + .set('Authorization', `Bearer ${authToken}`) + .expect(200) + .expect((res) => { + expect(res.body.email).toBe('test@test.com'); + }); + }); +}); + +// Database isolation for E2E tests +describe('Orders API (e2e)', () => { + let app: INestApplication; + let dataSource: DataSource; + + beforeAll(async () => { + const moduleFixture = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + envFilePath: '.env.test', // Test database config + }), + AppModule, + ], + }).compile(); + + app = moduleFixture.createNestApplication(); + dataSource = moduleFixture.get(DataSource); + await app.init(); + }); + + beforeEach(async () => { + // Clean database between tests + await dataSource.synchronize(true); + }); + + afterAll(async () => { + await dataSource.destroy(); + await app.close(); + }); +}); +``` + +Reference: [NestJS E2E Testing](https://docs.nestjs.com/fundamentals/testing#end-to-end-testing) + +--- + +### 6.2 Mock External Services in Tests + +**Impact: HIGH** — Ensures fast, reliable, deterministic tests + +Never call real external services (APIs, databases, message queues) in unit tests. Mock them to ensure tests are fast, deterministic, and don't incur costs. Use realistic mock data and test edge cases like timeouts and errors. + +**Incorrect (calling real APIs and databases):** + +```typescript +// Call real APIs in tests +describe('PaymentService', () => { + it('should process payment', async () => { + const service = new PaymentService(new StripeClient(realApiKey)); + // Hits real Stripe API! + const result = await service.charge('tok_visa', 1000); + // Slow, costs money, flaky + }); +}); + +// Use real database +describe('UsersService', () => { + beforeEach(async () => { + await connection.query('DELETE FROM users'); // Modifies real DB + }); + + it('should create user', async () => { + const user = await service.create({ email: 'test@test.com' }); + // Side effects on shared database + }); +}); + +// Incomplete mocks +const mockHttpService = { + get: jest.fn().mockResolvedValue({ data: {} }), + // Missing error scenarios, missing other methods +}; +``` + +**Correct (mock all external dependencies):** + +```typescript +// Mock HTTP service properly +describe('WeatherService', () => { + let service: WeatherService; + let httpService: jest.Mocked; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + providers: [ + WeatherService, + { + provide: HttpService, + useValue: { + get: jest.fn(), + post: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(WeatherService); + httpService = module.get(HttpService); + }); + + it('should return weather data', async () => { + const mockResponse = { + data: { temperature: 72, humidity: 45 }, + status: 200, + statusText: 'OK', + headers: {}, + config: {}, + }; + + httpService.get.mockReturnValue(of(mockResponse)); + + const result = await service.getWeather('NYC'); + + expect(result).toEqual({ temperature: 72, humidity: 45 }); + }); + + it('should handle API timeout', async () => { + httpService.get.mockReturnValue( + throwError(() => new Error('ETIMEDOUT')), + ); + + await expect(service.getWeather('NYC')).rejects.toThrow('Weather service unavailable'); + }); + + it('should handle rate limiting', async () => { + httpService.get.mockReturnValue( + throwError(() => ({ + response: { status: 429, data: { message: 'Rate limited' } }, + })), + ); + + await expect(service.getWeather('NYC')).rejects.toThrow(TooManyRequestsException); + }); +}); + +// Mock repository instead of database +describe('UsersService', () => { + let service: UsersService; + let repo: jest.Mocked>; + + beforeEach(async () => { + const mockRepo = { + find: jest.fn(), + findOne: jest.fn(), + save: jest.fn(), + delete: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const module = await Test.createTestingModule({ + providers: [ + UsersService, + { provide: getRepositoryToken(User), useValue: mockRepo }, + ], + }).compile(); + + service = module.get(UsersService); + repo = module.get(getRepositoryToken(User)); + }); + + it('should find user by id', async () => { + const mockUser = { id: '1', name: 'John', email: 'john@test.com' }; + repo.findOne.mockResolvedValue(mockUser); + + const result = await service.findById('1'); + + expect(result).toEqual(mockUser); + expect(repo.findOne).toHaveBeenCalledWith({ where: { id: '1' } }); + }); +}); + +// Create mock factory for complex SDKs +function createMockStripe(): jest.Mocked { + return { + paymentIntents: { + create: jest.fn(), + retrieve: jest.fn(), + confirm: jest.fn(), + cancel: jest.fn(), + }, + customers: { + create: jest.fn(), + retrieve: jest.fn(), + }, + } as any; +} + +// Mock time for time-dependent tests +describe('TokenService', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2024-01-15')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should expire token after 1 hour', async () => { + const token = await service.createToken(); + + // Fast-forward time + jest.advanceTimersByTime(61 * 60 * 1000); + + expect(await service.isValid(token)).toBe(false); + }); +}); +``` + +Reference: [Jest Mocking](https://jestjs.io/docs/mock-functions) + +--- + +### 6.3 Use Testing Module for Unit Tests + +**Impact: HIGH** — Enables proper isolated testing with mocked dependencies + +Use `@nestjs/testing` module to create isolated test environments with mocked dependencies. This ensures your tests run fast, don't depend on external services, and properly test your business logic in isolation. + +**Incorrect (manual instantiation bypassing DI):** + +```typescript +// Instantiate services manually without DI +describe('UsersService', () => { + it('should create user', async () => { + // Manual instantiation bypasses DI + const repo = new UserRepository(); // Real repo! + const service = new UsersService(repo); + + const user = await service.create({ name: 'Test' }); + // This hits the real database! + }); +}); + +// Test implementation details +describe('UsersController', () => { + it('should call service', async () => { + const service = { create: jest.fn() }; + const controller = new UsersController(service as any); + + await controller.create({ name: 'Test' }); + + expect(service.create).toHaveBeenCalled(); // Tests implementation, not behavior + }); +}); +``` + +**Correct (use Test.createTestingModule with mocked dependencies):** + +```typescript +// Use Test.createTestingModule for proper DI +import { Test, TestingModule } from '@nestjs/testing'; + +describe('UsersService', () => { + let service: UsersService; + let repo: jest.Mocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UsersService, + { + provide: UserRepository, + useValue: { + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(UsersService); + repo = module.get(UserRepository); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('create', () => { + it('should save and return user', async () => { + const dto = { name: 'John', email: 'john@test.com' }; + const expectedUser = { id: '1', ...dto }; + + repo.save.mockResolvedValue(expectedUser); + + const result = await service.create(dto); + + expect(result).toEqual(expectedUser); + expect(repo.save).toHaveBeenCalledWith(dto); + }); + + it('should throw on duplicate email', async () => { + repo.findOne.mockResolvedValue({ id: '1', email: 'test@test.com' }); + + await expect( + service.create({ name: 'Test', email: 'test@test.com' }), + ).rejects.toThrow(ConflictException); + }); + }); + + describe('findById', () => { + it('should return user when found', async () => { + const user = { id: '1', name: 'John' }; + repo.findOne.mockResolvedValue(user); + + const result = await service.findById('1'); + + expect(result).toEqual(user); + }); + + it('should throw NotFoundException when not found', async () => { + repo.findOne.mockResolvedValue(null); + + await expect(service.findById('999')).rejects.toThrow(NotFoundException); + }); + }); +}); + +// Testing guards and interceptors +describe('RolesGuard', () => { + let guard: RolesGuard; + let reflector: Reflector; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + providers: [RolesGuard, Reflector], + }).compile(); + + guard = module.get(RolesGuard); + reflector = module.get(Reflector); + }); + + it('should allow when no roles required', () => { + const context = createMockExecutionContext({ user: { roles: [] } }); + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(undefined); + + expect(guard.canActivate(context)).toBe(true); + }); + + it('should allow admin for admin-only route', () => { + const context = createMockExecutionContext({ user: { roles: ['admin'] } }); + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(['admin']); + + expect(guard.canActivate(context)).toBe(true); + }); +}); + +function createMockExecutionContext(request: Partial): ExecutionContext { + return { + switchToHttp: () => ({ + getRequest: () => request, + }), + getHandler: () => jest.fn(), + getClass: () => jest.fn(), + } as ExecutionContext; +} +``` + +Reference: [NestJS Testing](https://docs.nestjs.com/fundamentals/testing) + +--- + +## 7. Database & ORM + +**Section Impact: MEDIUM-HIGH** + +### 7.1 Avoid N+1 Query Problems + +**Impact: HIGH** — N+1 queries are one of the most common performance killers + +N+1 queries occur when you fetch a list of entities, then make an additional query for each entity to load related data. Use eager loading with `relations`, query builder joins, or DataLoader to batch queries efficiently. + +**Incorrect (lazy loading in loops causes N+1):** + +```typescript +// Lazy loading in loops causes N+1 +@Injectable() +export class OrdersService { + async getOrdersWithItems(userId: string): Promise { + const orders = await this.orderRepo.find({ where: { userId } }); + // 1 query for orders + + for (const order of orders) { + // N additional queries - one per order! + order.items = await this.itemRepo.find({ where: { orderId: order.id } }); + } + + return orders; + } +} + +// Accessing lazy relations without loading +@Controller('users') +export class UsersController { + @Get() + async findAll(): Promise { + const users = await this.userRepo.find(); + // If User.posts is lazy-loaded, serializing triggers N queries + return users; // Each user.posts access = 1 query + } +} +``` + +**Correct (use relations for eager loading):** + +```typescript +// Use relations option for eager loading +@Injectable() +export class OrdersService { + async getOrdersWithItems(userId: string): Promise { + // Single query with JOIN + return this.orderRepo.find({ + where: { userId }, + relations: ['items', 'items.product'], + }); + } +} + +// Use QueryBuilder for complex joins +@Injectable() +export class UsersService { + async getUsersWithPostCounts(): Promise { + return this.userRepo + .createQueryBuilder('user') + .leftJoin('user.posts', 'post') + .select('user.id', 'id') + .addSelect('user.name', 'name') + .addSelect('COUNT(post.id)', 'postCount') + .groupBy('user.id') + .getRawMany(); + } + + async getActiveUsersWithPosts(): Promise { + return this.userRepo + .createQueryBuilder('user') + .leftJoinAndSelect('user.posts', 'post') + .leftJoinAndSelect('post.comments', 'comment') + .where('user.isActive = :active', { active: true }) + .andWhere('post.status = :status', { status: 'published' }) + .getMany(); + } +} + +// Use find options for specific fields +async getOrderSummaries(userId: string): Promise { + return this.orderRepo.find({ + where: { userId }, + relations: ['items'], + select: { + id: true, + total: true, + status: true, + items: { + id: true, + quantity: true, + price: true, + }, + }, + }); +} + +// Use DataLoader for GraphQL to batch and cache queries +import DataLoader from 'dataloader'; + +@Injectable({ scope: Scope.REQUEST }) +export class PostsLoader { + constructor(private postsService: PostsService) {} + + readonly batchPosts = new DataLoader(async (userIds) => { + // Single query for all users' posts + const posts = await this.postsService.findByUserIds([...userIds]); + + // Group by userId + const postsMap = new Map(); + for (const post of posts) { + const userPosts = postsMap.get(post.userId) || []; + userPosts.push(post); + postsMap.set(post.userId, userPosts); + } + + // Return in same order as input + return userIds.map((id) => postsMap.get(id) || []); + }); +} + +// In resolver +@ResolveField() +async posts(@Parent() user: User): Promise { + // DataLoader batches multiple calls into single query + return this.postsLoader.batchPosts.load(user.id); +} + +// Enable query logging in development to detect N+1 +TypeOrmModule.forRoot({ + logging: ['query', 'error'], + logger: 'advanced-console', +}); +``` + +Reference: [TypeORM Relations](https://typeorm.io/relations) + +--- + +### 7.2 Use Database Migrations + +**Impact: HIGH** — Enables safe, repeatable database schema changes + +Never use `synchronize: true` in production. Use migrations for all schema changes. Migrations provide version control for your database, enable safe rollbacks, and ensure consistency across all environments. + +**Incorrect (using synchronize or manual SQL):** + +```typescript +// Use synchronize in production +TypeOrmModule.forRoot({ + type: 'postgres', + synchronize: true, // DANGEROUS in production! + // Can drop columns, tables, or data +}); + +// Manual SQL in production +@Injectable() +export class DatabaseService { + async addColumn(): Promise { + await this.dataSource.query('ALTER TABLE users ADD COLUMN age INT'); + // No version control, no rollback, inconsistent across envs + } +} + +// Modify entities without migration +@Entity() +export class User { + @Column() + email: string; + + @Column() // Added without migration + newField: string; // Will crash in production if synchronize is false +} +``` + +**Correct (use migrations for all schema changes):** + +```typescript +// Configure TypeORM for migrations +// data-source.ts +export const dataSource = new DataSource({ + type: 'postgres', + host: process.env.DB_HOST, + port: parseInt(process.env.DB_PORT), + username: process.env.DB_USERNAME, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + entities: ['dist/**/*.entity.js'], + migrations: ['dist/migrations/*.js'], + synchronize: false, // Always false in production + migrationsRun: true, // Run migrations on startup +}); + +// app.module.ts +TypeOrmModule.forRootAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + type: 'postgres', + host: config.get('DB_HOST'), + synchronize: config.get('NODE_ENV') === 'development', // Only in dev + migrations: ['dist/migrations/*.js'], + migrationsRun: true, + }), +}); + +// migrations/1705312800000-AddUserAge.ts +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddUserAge1705312800000 implements MigrationInterface { + name = 'AddUserAge1705312800000'; + + public async up(queryRunner: QueryRunner): Promise { + // Add column with default to handle existing rows + await queryRunner.query(` + ALTER TABLE "users" ADD "age" integer DEFAULT 0 + `); + + // Add index for frequently queried columns + await queryRunner.query(` + CREATE INDEX "IDX_users_age" ON "users" ("age") + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Always implement down for rollback + await queryRunner.query(`DROP INDEX "IDX_users_age"`); + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "age"`); + } +} + +// Safe column rename (two-step) +export class RenameNameToFullName1705312900000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Step 1: Add new column + await queryRunner.query(` + ALTER TABLE "users" ADD "full_name" varchar(255) + `); + + // Step 2: Copy data + await queryRunner.query(` + UPDATE "users" SET "full_name" = "name" + `); + + // Step 3: Add NOT NULL constraint + await queryRunner.query(` + ALTER TABLE "users" ALTER COLUMN "full_name" SET NOT NULL + `); + + // Step 4: Drop old column (after verifying app works) + await queryRunner.query(` + ALTER TABLE "users" DROP COLUMN "name" + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" ADD "name" varchar(255)`); + await queryRunner.query(`UPDATE "users" SET "name" = "full_name"`); + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "full_name"`); + } +} +``` + +Reference: [TypeORM Migrations](https://typeorm.io/migrations) + +--- + +### 7.3 Use Transactions for Multi-Step Operations + +**Impact: HIGH** — Ensures data consistency in multi-step operations + +When multiple database operations must succeed or fail together, wrap them in a transaction. This prevents partial updates that leave your data in an inconsistent state. Use TypeORM's transaction APIs or the DataSource query runner for complex scenarios. + +**Incorrect (multiple saves without transaction):** + +```typescript +// Multiple saves without transaction +@Injectable() +export class OrdersService { + async createOrder(userId: string, items: OrderItem[]): Promise { + // If any step fails, data is inconsistent + const order = await this.orderRepo.save({ userId, status: 'pending' }); + + for (const item of items) { + await this.orderItemRepo.save({ orderId: order.id, ...item }); + await this.inventoryRepo.decrement({ productId: item.productId }, 'stock', item.quantity); + } + + await this.paymentService.charge(order.id); + // If payment fails, order and inventory are already modified! + + return order; + } +} +``` + +**Correct (use DataSource.transaction for automatic rollback):** + +```typescript +// Use DataSource.transaction() for automatic rollback +@Injectable() +export class OrdersService { + constructor(private dataSource: DataSource) {} + + async createOrder(userId: string, items: OrderItem[]): Promise { + return this.dataSource.transaction(async (manager) => { + // All operations use the same transactional manager + const order = await manager.save(Order, { userId, status: 'pending' }); + + for (const item of items) { + await manager.save(OrderItem, { orderId: order.id, ...item }); + await manager.decrement( + Inventory, + { productId: item.productId }, + 'stock', + item.quantity, + ); + } + + // If this throws, everything rolls back + await this.paymentService.chargeWithManager(manager, order.id); + + return order; + }); + } +} + +// QueryRunner for manual transaction control +@Injectable() +export class TransferService { + constructor(private dataSource: DataSource) {} + + async transfer(fromId: string, toId: string, amount: number): Promise { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + // Debit source account + await queryRunner.manager.decrement( + Account, + { id: fromId }, + 'balance', + amount, + ); + + // Verify sufficient funds + const source = await queryRunner.manager.findOne(Account, { + where: { id: fromId }, + }); + if (source.balance < 0) { + throw new BadRequestException('Insufficient funds'); + } + + // Credit destination account + await queryRunner.manager.increment( + Account, + { id: toId }, + 'balance', + amount, + ); + + // Log the transaction + await queryRunner.manager.save(TransactionLog, { + fromId, + toId, + amount, + timestamp: new Date(), + }); + + await queryRunner.commitTransaction(); + } catch (error) { + await queryRunner.rollbackTransaction(); + throw error; + } finally { + await queryRunner.release(); + } + } +} + +// Repository method with transaction support +@Injectable() +export class UsersRepository { + constructor( + @InjectRepository(User) private repo: Repository, + private dataSource: DataSource, + ) {} + + async createWithProfile( + userData: CreateUserDto, + profileData: CreateProfileDto, + ): Promise { + return this.dataSource.transaction(async (manager) => { + const user = await manager.save(User, userData); + await manager.save(Profile, { ...profileData, userId: user.id }); + return user; + }); + } +} +``` + +Reference: [TypeORM Transactions](https://typeorm.io/transactions) + +--- + +## 8. API Design + +**Section Impact: MEDIUM** + +### 8.1 Use DTOs and Serialization for API Responses + +**Impact: MEDIUM** — Response DTOs prevent accidental data exposure and ensure consistency + +Never return entity objects directly from controllers. Use response DTOs with class-transformer's `@Exclude()` and `@Expose()` decorators to control exactly what data is sent to clients. This prevents accidental exposure of sensitive fields and provides a stable API contract. + +**Incorrect (returning entities directly or manual spreading):** + +```typescript +// Return entities directly +@Controller('users') +export class UsersController { + @Get(':id') + async findOne(@Param('id') id: string): Promise { + return this.usersService.findById(id); + // Returns: { id, email, passwordHash, ssn, internalNotes, ... } + // Exposes sensitive data! + } +} + +// Manual object spreading (error-prone) +@Get(':id') +async findOne(@Param('id') id: string) { + const user = await this.usersService.findById(id); + return { + id: user.id, + email: user.email, + name: user.name, + // Easy to forget to exclude sensitive fields + // Hard to maintain across endpoints + }; +} +``` + +**Correct (use class-transformer with @Exclude and response DTOs):** + +```typescript +// Enable class-transformer globally +async function bootstrap() { + const app = await NestFactory.create(AppModule); + app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector))); + await app.listen(3000); +} + +// Entity with serialization control +@Entity() +export class User { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + email: string; + + @Column() + name: string; + + @Column() + @Exclude() // Never include in responses + passwordHash: string; + + @Column({ nullable: true }) + @Exclude() + ssn: string; + + @Column({ default: false }) + @Exclude({ toPlainOnly: true }) // Exclude from response, allow in requests + isAdmin: boolean; + + @CreateDateColumn() + createdAt: Date; + + @Column() + @Exclude() + internalNotes: string; +} + +// Now returning entity is safe +@Controller('users') +export class UsersController { + @Get(':id') + async findOne(@Param('id') id: string): Promise { + return this.usersService.findById(id); + // Returns: { id, email, name, createdAt } + // Sensitive fields excluded automatically + } +} + +// For different response shapes, use explicit DTOs +export class UserResponseDto { + @Expose() + id: string; + + @Expose() + email: string; + + @Expose() + name: string; + + @Expose() + @Transform(({ obj }) => obj.posts?.length || 0) + postCount: number; + + constructor(partial: Partial) { + Object.assign(this, partial); + } +} + +export class UserDetailResponseDto extends UserResponseDto { + @Expose() + createdAt: Date; + + @Expose() + @Type(() => PostResponseDto) + posts: PostResponseDto[]; +} + +// Controller with explicit DTOs +@Controller('users') +export class UsersController { + @Get() + @SerializeOptions({ type: UserResponseDto }) + async findAll(): Promise { + const users = await this.usersService.findAll(); + return users.map(u => plainToInstance(UserResponseDto, u)); + } + + @Get(':id') + async findOne(@Param('id') id: string): Promise { + const user = await this.usersService.findByIdWithPosts(id); + return plainToInstance(UserDetailResponseDto, user, { + excludeExtraneousValues: true, + }); + } +} + +// Groups for conditional serialization +export class UserDto { + @Expose() + id: string; + + @Expose() + name: string; + + @Expose({ groups: ['admin'] }) + email: string; + + @Expose({ groups: ['admin'] }) + createdAt: Date; + + @Expose({ groups: ['admin', 'owner'] }) + settings: UserSettings; +} + +@Controller('users') +export class UsersController { + @Get() + @SerializeOptions({ groups: ['public'] }) + async findAllPublic(): Promise { + // Returns: { id, name } + } + + @Get('admin') + @UseGuards(AdminGuard) + @SerializeOptions({ groups: ['admin'] }) + async findAllAdmin(): Promise { + // Returns: { id, name, email, createdAt } + } + + @Get('me') + @SerializeOptions({ groups: ['owner'] }) + async getProfile(@CurrentUser() user: User): Promise { + // Returns: { id, name, settings } + } +} +``` + +Reference: [NestJS Serialization](https://docs.nestjs.com/techniques/serialization) + +--- + +### 8.2 Use Interceptors for Cross-Cutting Concerns + +**Impact: MEDIUM-HIGH** — Interceptors provide clean separation for cross-cutting logic + +Interceptors can transform responses, add logging, handle caching, and measure performance without polluting your business logic. They wrap the route handler execution, giving you access to both the request and response streams. + +**Incorrect (logging and transformation in every method):** + +```typescript +// Logging in every controller method +@Controller('users') +export class UsersController { + @Get() + async findAll(): Promise { + const start = Date.now(); + this.logger.log('findAll called'); + + const users = await this.usersService.findAll(); + + this.logger.log(`findAll completed in ${Date.now() - start}ms`); + return users; + } + + @Get(':id') + async findOne(@Param('id') id: string): Promise { + const start = Date.now(); + this.logger.log(`findOne called with id: ${id}`); + + const user = await this.usersService.findOne(id); + + this.logger.log(`findOne completed in ${Date.now() - start}ms`); + return user; + } + // Repeated in every method! +} + +// Manual response wrapping +@Get() +async findAll(): Promise<{ data: User[]; meta: Meta }> { + const users = await this.usersService.findAll(); + return { + data: users, + meta: { timestamp: new Date(), count: users.length }, + }; +} +``` + +**Correct (use interceptors for cross-cutting concerns):** + +```typescript +// Logging interceptor +@Injectable() +export class LoggingInterceptor implements NestInterceptor { + private readonly logger = new Logger('HTTP'); + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest(); + const { method, url, body } = request; + const now = Date.now(); + + return next.handle().pipe( + tap({ + next: (data) => { + const response = context.switchToHttp().getResponse(); + this.logger.log( + `${method} ${url} ${response.statusCode} - ${Date.now() - now}ms`, + ); + }, + error: (error) => { + this.logger.error( + `${method} ${url} ${error.status || 500} - ${Date.now() - now}ms`, + error.stack, + ); + }, + }), + ); + } +} + +// Response transformation interceptor +@Injectable() +export class TransformInterceptor implements NestInterceptor> { + intercept(context: ExecutionContext, next: CallHandler): Observable> { + return next.handle().pipe( + map((data) => ({ + data, + meta: { + timestamp: new Date().toISOString(), + path: context.switchToHttp().getRequest().url, + }, + })), + ); + } +} + +// Timeout interceptor +@Injectable() +export class TimeoutInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + return next.handle().pipe( + timeout(5000), + catchError((err) => { + if (err instanceof TimeoutError) { + throw new RequestTimeoutException('Request timed out'); + } + throw err; + }), + ); + } +} + +// Apply globally or per-controller +@Module({ + providers: [ + { provide: APP_INTERCEPTOR, useClass: LoggingInterceptor }, + { provide: APP_INTERCEPTOR, useClass: TransformInterceptor }, + ], +}) +export class AppModule {} + +// Or per-controller +@Controller('users') +@UseInterceptors(LoggingInterceptor) +export class UsersController { + @Get() + async findAll(): Promise { + // Clean business logic only + return this.usersService.findAll(); + } +} + +// Custom cache interceptor with TTL +@Injectable() +export class HttpCacheInterceptor implements NestInterceptor { + constructor( + private cacheManager: Cache, + private reflector: Reflector, + ) {} + + async intercept(context: ExecutionContext, next: CallHandler): Promise> { + const request = context.switchToHttp().getRequest(); + + // Only cache GET requests + if (request.method !== 'GET') { + return next.handle(); + } + + const cacheKey = this.generateKey(request); + const ttl = this.reflector.get('cacheTTL', context.getHandler()) || 300; + + const cached = await this.cacheManager.get(cacheKey); + if (cached) { + return of(cached); + } + + return next.handle().pipe( + tap((response) => { + this.cacheManager.set(cacheKey, response, ttl); + }), + ); + } + + private generateKey(request: Request): string { + return `cache:${request.url}:${JSON.stringify(request.query)}`; + } +} + +// Usage with custom TTL +@Get() +@SetMetadata('cacheTTL', 600) +@UseInterceptors(HttpCacheInterceptor) +async findAll(): Promise { + return this.usersService.findAll(); +} + +// Error mapping interceptor +@Injectable() +export class ErrorMappingInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + return next.handle().pipe( + catchError((error) => { + if (error instanceof EntityNotFoundError) { + throw new NotFoundException(error.message); + } + if (error instanceof QueryFailedError) { + if (error.message.includes('duplicate')) { + throw new ConflictException('Resource already exists'); + } + } + throw error; + }), + ); + } +} +``` + +Reference: [NestJS Interceptors](https://docs.nestjs.com/interceptors) + +--- + +### 8.3 Use Pipes for Input Transformation + +**Impact: MEDIUM** — Pipes ensure clean, validated data reaches your handlers + +Use built-in pipes like `ParseIntPipe`, `ParseUUIDPipe`, and `DefaultValuePipe` for common transformations. Create custom pipes for business-specific transformations. Pipes separate validation/transformation logic from controllers. + +**Incorrect (manual type parsing in handlers):** + +```typescript +// Manual type parsing in handlers +@Controller('users') +export class UsersController { + @Get(':id') + async findOne(@Param('id') id: string): Promise { + // Manual validation in every handler + const uuid = id.trim(); + if (!isUUID(uuid)) { + throw new BadRequestException('Invalid UUID'); + } + return this.usersService.findOne(uuid); + } + + @Get() + async findAll( + @Query('page') page: string, + @Query('limit') limit: string, + ): Promise { + // Manual parsing and defaults + const pageNum = parseInt(page) || 1; + const limitNum = parseInt(limit) || 10; + return this.usersService.findAll(pageNum, limitNum); + } +} + +// Type coercion without validation +@Get() +async search(@Query('price') price: string): Promise { + const priceNum = +price; // NaN if invalid, no error + return this.productsService.findByPrice(priceNum); +} +``` + +**Correct (use built-in and custom pipes):** + +```typescript +// Use built-in pipes for common transformations +@Controller('users') +export class UsersController { + @Get(':id') + async findOne(@Param('id', ParseUUIDPipe) id: string): Promise { + // id is guaranteed to be a valid UUID + return this.usersService.findOne(id); + } + + @Get() + async findAll( + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number, + ): Promise { + // Automatic defaults and type conversion + return this.usersService.findAll(page, limit); + } + + @Get('by-status/:status') + async findByStatus( + @Param('status', new ParseEnumPipe(UserStatus)) status: UserStatus, + ): Promise { + return this.usersService.findByStatus(status); + } +} + +// Custom pipe for business logic +@Injectable() +export class ParseDatePipe implements PipeTransform { + transform(value: string): Date { + const date = new Date(value); + if (isNaN(date.getTime())) { + throw new BadRequestException('Invalid date format'); + } + return date; + } +} + +@Get('reports') +async getReports( + @Query('from', ParseDatePipe) from: Date, + @Query('to', ParseDatePipe) to: Date, +): Promise { + return this.reportsService.findBetween(from, to); +} + +// Custom transformation pipes +@Injectable() +export class NormalizeEmailPipe implements PipeTransform { + transform(value: string): string { + if (!value) return value; + return value.trim().toLowerCase(); + } +} + +// Parse comma-separated values +@Injectable() +export class ParseArrayPipe implements PipeTransform { + transform(value: string): string[] { + if (!value) return []; + return value.split(',').map((v) => v.trim()).filter(Boolean); + } +} + +@Get('products') +async findProducts( + @Query('ids', ParseArrayPipe) ids: string[], + @Query('email', NormalizeEmailPipe) email: string, +): Promise { + // ids is already an array, email is normalized + return this.productsService.findByIds(ids); +} + +// Sanitize HTML input +@Injectable() +export class SanitizeHtmlPipe implements PipeTransform { + transform(value: string): string { + if (!value) return value; + return sanitizeHtml(value, { allowedTags: [] }); + } +} + +// Global validation pipe with transformation +app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, // Strip non-DTO properties + transform: true, // Auto-transform to DTO types + transformOptions: { + enableImplicitConversion: true, // Convert query strings to numbers + }, + forbidNonWhitelisted: true, // Throw on extra properties + }), +); + +// DTO with transformation decorators +export class FindProductsDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 10; + + @IsOptional() + @Transform(({ value }) => value?.toLowerCase()) + @IsString() + search?: string; + + @IsOptional() + @Transform(({ value }) => value?.split(',')) + @IsArray() + @IsString({ each: true }) + categories?: string[]; +} + +@Get() +async findAll(@Query() dto: FindProductsDto): Promise { + // dto is already transformed and validated + return this.productsService.findAll(dto); +} + +// Pipe error customization +@Injectable() +export class CustomParseIntPipe extends ParseIntPipe { + constructor() { + super({ + exceptionFactory: (error) => + new BadRequestException(`${error} must be a valid integer`), + }); + } +} + +// Or use options on built-in pipes +@Get(':id') +async findOne( + @Param( + 'id', + new ParseIntPipe({ + errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE, + exceptionFactory: () => new NotAcceptableException('ID must be numeric'), + }), + ) + id: number, +): Promise { + return this.itemsService.findOne(id); +} +``` + +Reference: [NestJS Pipes](https://docs.nestjs.com/pipes) + +--- + +### 8.4 Use API Versioning for Breaking Changes + +**Impact: MEDIUM** — Versioning allows you to evolve APIs without breaking existing clients + +Use NestJS built-in versioning when making breaking changes to your API. Choose a versioning strategy (URI, header, or media type) and apply it consistently. This allows old clients to continue working while new clients use updated endpoints. + +**Incorrect (breaking changes without versioning):** + +```typescript +// Breaking changes without versioning +@Controller('users') +export class UsersController { + @Get(':id') + async findOne(@Param('id') id: string): Promise { + // Original response: { id, name, email } + // Later changed to: { id, firstName, lastName, emailAddress } + // Old clients break! + return this.usersService.findOne(id); + } +} + +// Manual versioning in routes +@Controller('v1/users') +export class UsersV1Controller {} + +@Controller('v2/users') +export class UsersV2Controller {} +// Inconsistent, error-prone, hard to maintain +``` + +**Correct (use NestJS built-in versioning):** + +```typescript +// Enable versioning in main.ts +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + // URI versioning: /v1/users, /v2/users + app.enableVersioning({ + type: VersioningType.URI, + defaultVersion: '1', + }); + + // Or header versioning: X-API-Version: 1 + app.enableVersioning({ + type: VersioningType.HEADER, + header: 'X-API-Version', + defaultVersion: '1', + }); + + // Or media type: Accept: application/json;v=1 + app.enableVersioning({ + type: VersioningType.MEDIA_TYPE, + key: 'v=', + defaultVersion: '1', + }); + + await app.listen(3000); +} + +// Version-specific controllers +@Controller('users') +@Version('1') +export class UsersV1Controller { + @Get(':id') + async findOne(@Param('id') id: string): Promise { + const user = await this.usersService.findOne(id); + // V1 response format + return { + id: user.id, + name: user.name, + email: user.email, + }; + } +} + +@Controller('users') +@Version('2') +export class UsersV2Controller { + @Get(':id') + async findOne(@Param('id') id: string): Promise { + const user = await this.usersService.findOne(id); + // V2 response format with breaking changes + return { + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + emailAddress: user.email, + createdAt: user.createdAt, + }; + } +} + +// Per-route versioning - different versions for different routes +@Controller('users') +export class UsersController { + @Get() + @Version('1') + findAllV1(): Promise { + return this.usersService.findAllV1(); + } + + @Get() + @Version('2') + findAllV2(): Promise { + return this.usersService.findAllV2(); + } + + @Get(':id') + @Version(['1', '2']) // Same handler for multiple versions + findOne(@Param('id') id: string): Promise { + return this.usersService.findOne(id); + } + + @Post() + @Version(VERSION_NEUTRAL) // Available in all versions + create(@Body() dto: CreateUserDto): Promise { + return this.usersService.create(dto); + } +} + +// Shared service with version-specific logic +@Injectable() +export class UsersService { + async findOne(id: string, version: string): Promise { + const user = await this.repo.findOne({ where: { id } }); + + if (version === '1') { + return this.toV1Response(user); + } + return this.toV2Response(user); + } + + private toV1Response(user: User): UserV1Response { + return { + id: user.id, + name: `${user.firstName} ${user.lastName}`, + email: user.email, + }; + } + + private toV2Response(user: User): UserV2Response { + return { + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + emailAddress: user.email, + createdAt: user.createdAt, + }; + } +} + +// Controller extracts version +@Controller('users') +export class UsersController { + @Get(':id') + async findOne( + @Param('id') id: string, + @Headers('X-API-Version') version: string = '1', + ): Promise { + return this.usersService.findOne(id, version); + } +} + +// Deprecation strategy - mark old versions as deprecated +@Controller('users') +@Version('1') +@UseInterceptors(DeprecationInterceptor) +export class UsersV1Controller { + // All V1 routes will include deprecation warning +} + +@Injectable() +export class DeprecationInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + const response = context.switchToHttp().getResponse(); + response.setHeader('Deprecation', 'true'); + response.setHeader('Sunset', 'Sat, 1 Jan 2025 00:00:00 GMT'); + response.setHeader('Link', '; rel="successor-version"'); + + return next.handle(); + } +} +``` + +Reference: [NestJS Versioning](https://docs.nestjs.com/techniques/versioning) + +--- + +## 9. Microservices + +**Section Impact: MEDIUM** + +### 9.1 Implement Health Checks for Microservices + +**Impact: MEDIUM-HIGH** — Health checks enable orchestrators to manage service lifecycle + +Implement liveness and readiness probes using `@nestjs/terminus`. Liveness checks determine if the service should be restarted. Readiness checks determine if the service can accept traffic. Proper health checks enable Kubernetes and load balancers to route traffic correctly. + +**Incorrect (simple ping that doesn't check dependencies):** + +```typescript +// Simple ping that doesn't check dependencies +@Controller('health') +export class HealthController { + @Get() + check(): string { + return 'OK'; // Service might be unhealthy but returns OK + } +} + +// Health check that blocks on slow dependencies +@Controller('health') +export class HealthController { + @Get() + async check(): Promise { + // If database is slow, health check times out + await this.userRepo.findOne({ where: { id: '1' } }); + await this.redis.ping(); + await this.externalApi.healthCheck(); + return 'OK'; + } +} +``` + +**Correct (use @nestjs/terminus for comprehensive health checks):** + +```typescript +// Use @nestjs/terminus for comprehensive health checks +import { + HealthCheckService, + HttpHealthIndicator, + TypeOrmHealthIndicator, + HealthCheck, + DiskHealthIndicator, + MemoryHealthIndicator, +} from '@nestjs/terminus'; + +@Controller('health') +export class HealthController { + constructor( + private health: HealthCheckService, + private http: HttpHealthIndicator, + private db: TypeOrmHealthIndicator, + private disk: DiskHealthIndicator, + private memory: MemoryHealthIndicator, + ) {} + + // Liveness probe - is the service alive? + @Get('live') + @HealthCheck() + liveness() { + return this.health.check([ + // Basic checks only + () => this.memory.checkHeap('memory_heap', 200 * 1024 * 1024), // 200MB + ]); + } + + // Readiness probe - can the service handle traffic? + @Get('ready') + @HealthCheck() + readiness() { + return this.health.check([ + () => this.db.pingCheck('database'), + () => + this.http.pingCheck('redis', 'http://redis:6379', { timeout: 1000 }), + () => + this.disk.checkStorage('disk', { path: '/', thresholdPercent: 0.9 }), + ]); + } + + // Deep health check for debugging + @Get('deep') + @HealthCheck() + deepCheck() { + return this.health.check([ + () => this.db.pingCheck('database'), + () => this.memory.checkHeap('memory_heap', 200 * 1024 * 1024), + () => this.memory.checkRSS('memory_rss', 300 * 1024 * 1024), + () => + this.disk.checkStorage('disk', { path: '/', thresholdPercent: 0.9 }), + () => + this.http.pingCheck('external-api', 'https://api.example.com/health'), + ]); + } +} + +// Custom indicator for business-specific health +@Injectable() +export class QueueHealthIndicator extends HealthIndicator { + constructor(private queueService: QueueService) { + super(); + } + + async isHealthy(key: string): Promise { + const queueStats = await this.queueService.getStats(); + + const isHealthy = queueStats.failedCount < 100; + const result = this.getStatus(key, isHealthy, { + waiting: queueStats.waitingCount, + active: queueStats.activeCount, + failed: queueStats.failedCount, + }); + + if (!isHealthy) { + throw new HealthCheckError('Queue unhealthy', result); + } + + return result; + } +} + +// Redis health indicator +@Injectable() +export class RedisHealthIndicator extends HealthIndicator { + constructor(@InjectRedis() private redis: Redis) { + super(); + } + + async isHealthy(key: string): Promise { + try { + const pong = await this.redis.ping(); + return this.getStatus(key, pong === 'PONG'); + } catch (error) { + throw new HealthCheckError('Redis check failed', this.getStatus(key, false)); + } + } +} + +// Use custom indicators +@Get('ready') +@HealthCheck() +readiness() { + return this.health.check([ + () => this.db.pingCheck('database'), + () => this.redis.isHealthy('redis'), + () => this.queue.isHealthy('job-queue'), + ]); +} + +// Graceful shutdown handling +@Injectable() +export class GracefulShutdownService implements OnApplicationShutdown { + private isShuttingDown = false; + + isShutdown(): boolean { + return this.isShuttingDown; + } + + async onApplicationShutdown(signal: string): Promise { + this.isShuttingDown = true; + console.log(`Shutting down on ${signal}`); + + // Wait for in-flight requests + await new Promise((resolve) => setTimeout(resolve, 5000)); + } +} + +// Health check respects shutdown state +@Get('ready') +@HealthCheck() +readiness() { + if (this.shutdownService.isShutdown()) { + throw new ServiceUnavailableException('Shutting down'); + } + + return this.health.check([ + () => this.db.pingCheck('database'), + ]); +} +``` + +### Kubernetes Configuration + +```yaml +# Kubernetes deployment with probes +apiVersion: apps/v1 +kind: Deployment +metadata: + name: api-service +spec: + template: + spec: + containers: + - name: api + image: api-service:latest + ports: + - containerPort: 3000 + livenessProbe: + httpGet: + path: /health/live + port: 3000 + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health/ready + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + startupProbe: + httpGet: + path: /health/live + port: 3000 + initialDelaySeconds: 0 + periodSeconds: 5 + failureThreshold: 30 +``` + +Reference: [NestJS Terminus](https://docs.nestjs.com/recipes/terminus) + +--- + +### 9.2 Use Message and Event Patterns Correctly + +**Impact: MEDIUM** — Proper patterns ensure reliable microservice communication + +NestJS microservices support two communication patterns: request-response (MessagePattern) and event-based (EventPattern). Use MessagePattern when you need a response, and EventPattern for fire-and-forget notifications. Understanding the difference prevents communication bugs. + +**Incorrect (using wrong pattern for use case):** + +```typescript +// Use @MessagePattern for fire-and-forget +@Controller() +export class NotificationsController { + @MessagePattern('user.created') + async handleUserCreated(data: UserCreatedEvent) { + // This WAITS for response, blocking the sender + await this.emailService.sendWelcome(data.email); + // If email fails, sender gets an error (coupling!) + } +} + +// Use @EventPattern expecting a response +@Controller() +export class OrdersController { + @EventPattern('inventory.check') + async checkInventory(data: CheckInventoryDto) { + const available = await this.inventory.check(data); + return available; // This return value is IGNORED with @EventPattern! + } +} + +// Tight coupling in client +@Injectable() +export class UsersService { + async createUser(dto: CreateUserDto): Promise { + const user = await this.repo.save(dto); + + // Blocks until notification service responds + await this.client.send('user.created', user).toPromise(); + // If notification service is down, user creation fails! + + return user; + } +} +``` + +**Correct (use MessagePattern for request-response, EventPattern for fire-and-forget):** + +```typescript +// MessagePattern: Request-Response (when you NEED a response) +@Controller() +export class InventoryController { + @MessagePattern({ cmd: 'check_inventory' }) + async checkInventory(data: CheckInventoryDto): Promise { + const result = await this.inventoryService.check(data.productId, data.quantity); + return result; // Response sent back to caller + } +} + +// Client expects response +@Injectable() +export class OrdersService { + async createOrder(dto: CreateOrderDto): Promise { + // Check inventory - we NEED this response to proceed + const inventory = await firstValueFrom( + this.inventoryClient.send( + { cmd: 'check_inventory' }, + { productId: dto.productId, quantity: dto.quantity }, + ), + ); + + if (!inventory.available) { + throw new BadRequestException('Insufficient inventory'); + } + + return this.repo.save(dto); + } +} + +// EventPattern: Fire-and-Forget (for notifications, side effects) +@Controller() +export class NotificationsController { + @EventPattern('user.created') + async handleUserCreated(data: UserCreatedEvent): Promise { + // No return value needed - just process the event + await this.emailService.sendWelcome(data.email); + await this.analyticsService.track('user_signup', data); + // If this fails, it doesn't affect the sender + } +} + +// Client emits event without waiting +@Injectable() +export class UsersService { + async createUser(dto: CreateUserDto): Promise { + const user = await this.repo.save(dto); + + // Fire and forget - doesn't block, doesn't wait + this.eventClient.emit('user.created', { + userId: user.id, + email: user.email, + timestamp: new Date(), + }); + + return user; // User creation succeeds regardless of event handling + } +} + +// Hybrid pattern for critical events +@Injectable() +export class OrdersService { + async createOrder(dto: CreateOrderDto): Promise { + const order = await this.repo.save(dto); + + // Critical: inventory reservation (use MessagePattern) + const reserved = await firstValueFrom( + this.inventoryClient.send({ cmd: 'reserve_inventory' }, { + orderId: order.id, + items: dto.items, + }), + ); + + if (!reserved.success) { + await this.repo.delete(order.id); + throw new BadRequestException('Could not reserve inventory'); + } + + // Non-critical: notifications (use EventPattern) + this.eventClient.emit('order.created', { + orderId: order.id, + userId: dto.userId, + total: dto.total, + }); + + return order; + } +} + +// Error handling patterns +// MessagePattern errors propagate to caller +@MessagePattern({ cmd: 'get_user' }) +async getUser(userId: string): Promise { + const user = await this.repo.findOne({ where: { id: userId } }); + if (!user) { + throw new RpcException('User not found'); // Received by caller + } + return user; +} + +// EventPattern errors should be handled locally +@EventPattern('order.created') +async handleOrderCreated(data: OrderCreatedEvent): Promise { + try { + await this.processOrder(data); + } catch (error) { + // Log and potentially retry - don't throw + this.logger.error('Failed to process order event', error); + await this.deadLetterQueue.add(data); + } +} +``` + +Reference: [NestJS Microservices](https://docs.nestjs.com/microservices/basics) + +--- + +### 9.3 Use Message Queues for Background Jobs + +**Impact: MEDIUM-HIGH** — Queues enable reliable background processing + +Use `@nestjs/bullmq` for background job processing. Queues decouple long-running tasks from HTTP requests, enable retry logic, and distribute workload across workers. Use them for emails, file processing, notifications, and any task that shouldn't block user requests. + +**Incorrect (long-running tasks in HTTP handlers):** + +```typescript +// Long-running tasks in HTTP handlers +@Controller('reports') +export class ReportsController { + @Post() + async generate(@Body() dto: GenerateReportDto): Promise { + // This blocks the request for potentially minutes + const data = await this.fetchLargeDataset(dto); + const report = await this.processData(data); // Slow! + await this.sendEmail(dto.email, report); // Can fail! + return report; // Client times out + } +} + +// Fire-and-forget without retry +@Injectable() +export class EmailService { + async sendWelcome(email: string): Promise { + // If this fails, email is never sent + await this.mailer.send({ to: email, template: 'welcome' }); + // No retry, no tracking, no visibility + } +} + +// Use setInterval for scheduled tasks +setInterval(async () => { + await cleanupOldRecords(); +}, 60000); // No error handling, memory leaks +``` + +**Correct (use BullMQ for background processing):** + +```typescript +// Configure BullMQ +import { BullModule } from '@nestjs/bullmq'; + +@Module({ + imports: [ + BullModule.forRoot({ + connection: { + host: 'localhost', + port: 6379, + }, + defaultJobOptions: { + removeOnComplete: 1000, + removeOnFail: 5000, + attempts: 3, + backoff: { + type: 'exponential', + delay: 1000, + }, + }, + }), + BullModule.registerQueue( + { name: 'email' }, + { name: 'reports' }, + { name: 'notifications' }, + ), + ], +}) +export class QueueModule {} + +// Producer: Add jobs to queue +@Injectable() +export class ReportsService { + constructor( + @InjectQueue('reports') private reportsQueue: Queue, + ) {} + + async requestReport(dto: GenerateReportDto): Promise<{ jobId: string }> { + // Return immediately, process in background + const job = await this.reportsQueue.add('generate', dto, { + priority: dto.urgent ? 1 : 10, + delay: dto.scheduledFor ? Date.parse(dto.scheduledFor) - Date.now() : 0, + }); + + return { jobId: job.id }; + } + + async getJobStatus(jobId: string): Promise { + const job = await this.reportsQueue.getJob(jobId); + return { + status: await job.getState(), + progress: job.progress, + result: job.returnvalue, + }; + } +} + +// Consumer: Process jobs +@Processor('reports') +export class ReportsProcessor { + private readonly logger = new Logger(ReportsProcessor.name); + + @Process('generate') + async generateReport(job: Job): Promise { + this.logger.log(`Processing report job ${job.id}`); + + // Update progress + await job.updateProgress(10); + + const data = await this.fetchData(job.data); + await job.updateProgress(50); + + const report = await this.processData(data); + await job.updateProgress(90); + + await this.saveReport(report); + await job.updateProgress(100); + + return report; + } + + @OnQueueActive() + onActive(job: Job) { + this.logger.log(`Processing job ${job.id}`); + } + + @OnQueueCompleted() + onCompleted(job: Job, result: any) { + this.logger.log(`Job ${job.id} completed`); + } + + @OnQueueFailed() + onFailed(job: Job, error: Error) { + this.logger.error(`Job ${job.id} failed: ${error.message}`); + } +} + +// Email queue with retry +@Processor('email') +export class EmailProcessor { + @Process('send') + async sendEmail(job: Job): Promise { + const { to, template, data } = job.data; + + try { + await this.mailer.send({ + to, + template, + context: data, + }); + } catch (error) { + // BullMQ will retry based on job options + throw error; + } + } +} + +// Usage +@Injectable() +export class NotificationService { + constructor(@InjectQueue('email') private emailQueue: Queue) {} + + async sendWelcome(user: User): Promise { + await this.emailQueue.add( + 'send', + { + to: user.email, + template: 'welcome', + data: { name: user.name }, + }, + { + attempts: 5, + backoff: { type: 'exponential', delay: 5000 }, + }, + ); + } +} + +// Scheduled jobs +@Injectable() +export class ScheduledJobsService implements OnModuleInit { + constructor(@InjectQueue('maintenance') private queue: Queue) {} + + async onModuleInit(): Promise { + // Clean up old reports daily at midnight + await this.queue.add( + 'cleanup', + {}, + { + repeat: { cron: '0 0 * * *' }, + jobId: 'daily-cleanup', // Prevent duplicates + }, + ); + + // Send digest every hour + await this.queue.add( + 'digest', + {}, + { + repeat: { every: 60 * 60 * 1000 }, + jobId: 'hourly-digest', + }, + ); + } +} + +@Processor('maintenance') +export class MaintenanceProcessor { + @Process('cleanup') + async cleanup(): Promise { + await this.cleanupOldReports(); + await this.cleanupExpiredSessions(); + } + + @Process('digest') + async sendDigest(): Promise { + const users = await this.getUsersForDigest(); + for (const user of users) { + await this.emailQueue.add('send', { to: user.email, template: 'digest' }); + } + } +} + +// Queue monitoring with Bull Board +import { BullBoardModule } from '@bull-board/nestjs'; +import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'; + +@Module({ + imports: [ + BullBoardModule.forRoot({ + route: '/admin/queues', + adapter: ExpressAdapter, + }), + BullBoardModule.forFeature({ + name: 'email', + adapter: BullMQAdapter, + }), + BullBoardModule.forFeature({ + name: 'reports', + adapter: BullMQAdapter, + }), + ], +}) +export class AdminModule {} +``` + +Reference: [NestJS Queues](https://docs.nestjs.com/techniques/queues) + +--- + +## 10. DevOps & Deployment + +**Section Impact: LOW-MEDIUM** + +### 10.1 Implement Graceful Shutdown + +**Impact: MEDIUM-HIGH** — Proper shutdown handling ensures zero-downtime deployments + +Handle SIGTERM and SIGINT signals to gracefully shutdown your NestJS application. Stop accepting new requests, wait for in-flight requests to complete, close database connections, and clean up resources. This prevents data loss and connection errors during deployments. + +**Incorrect (ignoring shutdown signals):** + +```typescript +// Ignore shutdown signals +async function bootstrap() { + const app = await NestFactory.create(AppModule); + await app.listen(3000); + // App crashes immediately on SIGTERM + // In-flight requests fail + // Database connections are abruptly closed +} + +// Long-running tasks without cancellation +@Injectable() +export class ProcessingService { + async processLargeFile(file: File): Promise { + // No way to interrupt this during shutdown + for (let i = 0; i < file.chunks.length; i++) { + await this.processChunk(file.chunks[i]); + // May run for minutes, blocking shutdown + } + } +} +``` + +**Correct (enable shutdown hooks and handle cleanup):** + +```typescript +// Enable shutdown hooks in main.ts +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + // Enable shutdown hooks + app.enableShutdownHooks(); + + // Optional: Add timeout for forced shutdown + const server = await app.listen(3000); + server.setTimeout(30000); // 30 second timeout + + // Handle graceful shutdown + const signals = ['SIGTERM', 'SIGINT']; + signals.forEach((signal) => { + process.on(signal, async () => { + console.log(`Received ${signal}, starting graceful shutdown...`); + + // Stop accepting new connections + server.close(async () => { + console.log('HTTP server closed'); + await app.close(); + process.exit(0); + }); + + // Force exit after timeout + setTimeout(() => { + console.error('Forced shutdown after timeout'); + process.exit(1); + }, 30000); + }); + }); +} + +// Lifecycle hooks for cleanup +@Injectable() +export class DatabaseService implements OnApplicationShutdown { + private readonly connections: Connection[] = []; + + async onApplicationShutdown(signal?: string): Promise { + console.log(`Database service shutting down on ${signal}`); + + // Close all connections gracefully + await Promise.all( + this.connections.map((conn) => conn.close()), + ); + + console.log('All database connections closed'); + } +} + +// Queue processor with graceful shutdown +@Injectable() +export class QueueService implements OnApplicationShutdown, OnModuleDestroy { + private isShuttingDown = false; + + onModuleDestroy(): void { + this.isShuttingDown = true; + } + + async onApplicationShutdown(): Promise { + // Wait for current jobs to complete + await this.queue.close(); + } + + async processJob(job: Job): Promise { + if (this.isShuttingDown) { + throw new Error('Service is shutting down'); + } + await this.doWork(job); + } +} + +// WebSocket gateway cleanup +@WebSocketGateway() +export class EventsGateway implements OnApplicationShutdown { + @WebSocketServer() + server: Server; + + async onApplicationShutdown(): Promise { + // Notify all connected clients + this.server.emit('shutdown', { message: 'Server is shutting down' }); + + // Close all connections + this.server.disconnectSockets(); + } +} + +// Health check integration +@Injectable() +export class ShutdownService { + private isShuttingDown = false; + + startShutdown(): void { + this.isShuttingDown = true; + } + + isShutdown(): boolean { + return this.isShuttingDown; + } +} + +@Controller('health') +export class HealthController { + constructor(private shutdownService: ShutdownService) {} + + @Get('ready') + @HealthCheck() + readiness(): Promise { + // Return 503 during shutdown - k8s stops sending traffic + if (this.shutdownService.isShutdown()) { + throw new ServiceUnavailableException('Shutting down'); + } + + return this.health.check([ + () => this.db.pingCheck('database'), + ]); + } +} + +// Integrate with shutdown +@Injectable() +export class AppShutdownService implements OnApplicationShutdown { + constructor(private shutdownService: ShutdownService) {} + + async onApplicationShutdown(): Promise { + // Mark as unhealthy first + this.shutdownService.startShutdown(); + + // Wait for k8s to update endpoints + await this.sleep(5000); + + // Then proceed with cleanup + } +} + +// Request tracking for in-flight requests +@Injectable() +export class RequestTracker implements NestMiddleware, OnApplicationShutdown { + private activeRequests = 0; + private isShuttingDown = false; + private shutdownPromise: Promise | null = null; + private resolveShutdown: (() => void) | null = null; + + use(req: Request, res: Response, next: NextFunction): void { + if (this.isShuttingDown) { + res.status(503).send('Service Unavailable'); + return; + } + + this.activeRequests++; + + res.on('finish', () => { + this.activeRequests--; + if (this.isShuttingDown && this.activeRequests === 0 && this.resolveShutdown) { + this.resolveShutdown(); + } + }); + + next(); + } + + async onApplicationShutdown(): Promise { + this.isShuttingDown = true; + + if (this.activeRequests > 0) { + console.log(`Waiting for ${this.activeRequests} requests to complete`); + this.shutdownPromise = new Promise((resolve) => { + this.resolveShutdown = resolve; + }); + + // Wait with timeout + await Promise.race([ + this.shutdownPromise, + new Promise((resolve) => setTimeout(resolve, 30000)), + ]); + } + + console.log('All requests completed'); + } +} +``` + +Reference: [NestJS Lifecycle Events](https://docs.nestjs.com/fundamentals/lifecycle-events) + +--- + +### 10.2 Use ConfigModule for Environment Configuration + +**Impact: LOW-MEDIUM** — Proper configuration prevents deployment failures + +Use `@nestjs/config` for environment-based configuration. Validate configuration at startup to fail fast on misconfigurations. Use namespaced configuration for organization and type safety. + +**Incorrect (accessing process.env directly):** + +```typescript +// Access process.env directly +@Injectable() +export class DatabaseService { + constructor() { + // No validation, can fail at runtime + this.connection = new Pool({ + host: process.env.DB_HOST, + port: parseInt(process.env.DB_PORT), // NaN if missing + password: process.env.DB_PASSWORD, // undefined if missing + }); + } +} + +// Scattered env access +@Injectable() +export class EmailService { + sendEmail() { + // Different services access env differently + const apiKey = process.env.SENDGRID_API_KEY || 'default'; + // Typos go unnoticed: process.env.SENDGRID_API_KY + } +} +``` + +**Correct (use @nestjs/config with validation):** + +```typescript +// Setup validated configuration +import { ConfigModule, ConfigService, registerAs } from '@nestjs/config'; +import * as Joi from 'joi'; + +// config/database.config.ts +export const databaseConfig = registerAs('database', () => ({ + host: process.env.DB_HOST, + port: parseInt(process.env.DB_PORT, 10), + username: process.env.DB_USERNAME, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, +})); + +// config/app.config.ts +export const appConfig = registerAs('app', () => ({ + port: parseInt(process.env.PORT, 10) || 3000, + environment: process.env.NODE_ENV || 'development', + apiPrefix: process.env.API_PREFIX || 'api', +})); + +// config/validation.schema.ts +export const validationSchema = Joi.object({ + NODE_ENV: Joi.string() + .valid('development', 'production', 'test') + .default('development'), + PORT: Joi.number().default(3000), + DB_HOST: Joi.string().required(), + DB_PORT: Joi.number().default(5432), + DB_USERNAME: Joi.string().required(), + DB_PASSWORD: Joi.string().required(), + DB_NAME: Joi.string().required(), + JWT_SECRET: Joi.string().min(32).required(), + REDIS_URL: Joi.string().uri().required(), +}); + +// app.module.ts +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, // Available everywhere without importing + load: [databaseConfig, appConfig], + validationSchema, + validationOptions: { + abortEarly: true, // Stop on first error + allowUnknown: true, // Allow other env vars + }, + }), + TypeOrmModule.forRootAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + type: 'postgres', + host: config.get('database.host'), + port: config.get('database.port'), + username: config.get('database.username'), + password: config.get('database.password'), + database: config.get('database.database'), + autoLoadEntities: true, + }), + }), + ], +}) +export class AppModule {} + +// Type-safe configuration access +export interface AppConfig { + port: number; + environment: 'development' | 'production' | 'test'; + apiPrefix: string; +} + +export interface DatabaseConfig { + host: string; + port: number; + username: string; + password: string; + database: string; +} + +// Type-safe access +@Injectable() +export class AppService { + constructor(private config: ConfigService) {} + + getPort(): number { + // Type-safe with generic + return this.config.get('app.port'); + } + + getDatabaseConfig(): DatabaseConfig { + return this.config.get('database'); + } +} + +// Inject namespaced config directly +@Injectable() +export class DatabaseService { + constructor( + @Inject(databaseConfig.KEY) + private dbConfig: ConfigType, + ) { + // Full type inference! + const host = this.dbConfig.host; // string + const port = this.dbConfig.port; // number + } +} + +// Environment files support +ConfigModule.forRoot({ + envFilePath: [ + `.env.${process.env.NODE_ENV}.local`, + `.env.${process.env.NODE_ENV}`, + '.env.local', + '.env', + ], +}); + +// .env.development +// DB_HOST=localhost +// DB_PORT=5432 + +// .env.production +// DB_HOST=prod-db.example.com +// DB_PORT=5432 +``` + +Reference: [NestJS Configuration](https://docs.nestjs.com/techniques/configuration) + +--- + +### 10.3 Use Structured Logging + +**Impact: MEDIUM-HIGH** — Structured logging enables effective debugging and monitoring + +Use NestJS Logger with structured JSON output in production. Include contextual information (request ID, user ID, operation) to trace requests across services. Avoid console.log and implement proper log levels. + +**Incorrect (using console.log in production):** + +```typescript +// Use console.log in production +@Injectable() +export class UsersService { + async createUser(dto: CreateUserDto): Promise { + console.log('Creating user:', dto); + // Not structured, no levels, lost in production logs + + try { + const user = await this.repo.save(dto); + console.log('User created:', user.id); + return user; + } catch (error) { + console.log('Error:', error); // Using log for errors + throw error; + } + } +} + +// Log sensitive data +console.log('Login attempt:', { email, password }); // SECURITY RISK! + +// Inconsistent log format +logger.log('User ' + userId + ' created at ' + new Date()); +// Hard to parse, no structure +``` + +**Correct (use structured logging with context):** + +```typescript +// Configure logger in main.ts +async function bootstrap() { + const app = await NestFactory.create(AppModule, { + logger: + process.env.NODE_ENV === 'production' + ? ['error', 'warn', 'log'] + : ['error', 'warn', 'log', 'debug', 'verbose'], + }); +} + +// Use NestJS Logger with context +@Injectable() +export class UsersService { + private readonly logger = new Logger(UsersService.name); + + async createUser(dto: CreateUserDto): Promise { + this.logger.log('Creating user', { email: dto.email }); + + try { + const user = await this.repo.save(dto); + this.logger.log('User created', { userId: user.id }); + return user; + } catch (error) { + this.logger.error('Failed to create user', error.stack, { + email: dto.email, + }); + throw error; + } + } +} + +// Custom logger for JSON output +@Injectable() +export class JsonLogger implements LoggerService { + log(message: string, context?: object): void { + console.log( + JSON.stringify({ + level: 'info', + timestamp: new Date().toISOString(), + message, + ...context, + }), + ); + } + + error(message: string, trace?: string, context?: object): void { + console.error( + JSON.stringify({ + level: 'error', + timestamp: new Date().toISOString(), + message, + trace, + ...context, + }), + ); + } + + warn(message: string, context?: object): void { + console.warn( + JSON.stringify({ + level: 'warn', + timestamp: new Date().toISOString(), + message, + ...context, + }), + ); + } + + debug(message: string, context?: object): void { + console.debug( + JSON.stringify({ + level: 'debug', + timestamp: new Date().toISOString(), + message, + ...context, + }), + ); + } +} + +// Request context logging with ClsModule +import { ClsModule, ClsService } from 'nestjs-cls'; + +@Module({ + imports: [ + ClsModule.forRoot({ + global: true, + middleware: { + mount: true, + generateId: true, + }, + }), + ], +}) +export class AppModule {} + +// Middleware to set request context +@Injectable() +export class RequestContextMiddleware implements NestMiddleware { + constructor(private cls: ClsService) {} + + use(req: Request, res: Response, next: NextFunction): void { + const requestId = req.headers['x-request-id'] || randomUUID(); + this.cls.set('requestId', requestId); + this.cls.set('userId', req.user?.id); + + res.setHeader('x-request-id', requestId); + next(); + } +} + +// Logger that includes request context +@Injectable() +export class ContextLogger { + constructor(private cls: ClsService) {} + + log(message: string, data?: object): void { + console.log( + JSON.stringify({ + level: 'info', + timestamp: new Date().toISOString(), + requestId: this.cls.get('requestId'), + userId: this.cls.get('userId'), + message, + ...data, + }), + ); + } + + error(message: string, error: Error, data?: object): void { + console.error( + JSON.stringify({ + level: 'error', + timestamp: new Date().toISOString(), + requestId: this.cls.get('requestId'), + userId: this.cls.get('userId'), + message, + error: error.message, + stack: error.stack, + ...data, + }), + ); + } +} + +// Pino integration for high-performance logging +import { LoggerModule } from 'nestjs-pino'; + +@Module({ + imports: [ + LoggerModule.forRoot({ + pinoHttp: { + level: process.env.NODE_ENV === 'production' ? 'info' : 'debug', + transport: + process.env.NODE_ENV !== 'production' + ? { target: 'pino-pretty' } + : undefined, + redact: ['req.headers.authorization', 'req.body.password'], + serializers: { + req: (req) => ({ + method: req.method, + url: req.url, + query: req.query, + }), + res: (res) => ({ + statusCode: res.statusCode, + }), + }, + }, + }), + ], +}) +export class AppModule {} + +// Usage with Pino +@Injectable() +export class UsersService { + constructor(private logger: PinoLogger) { + this.logger.setContext(UsersService.name); + } + + async findOne(id: string): Promise { + this.logger.info({ userId: id }, 'Finding user'); + // Pino uses first arg for data, second for message + } +} +``` + +Reference: [NestJS Logger](https://docs.nestjs.com/techniques/logger) + +--- + +## References + +- https://docs.nestjs.com +- https://github.com/nestjs/nest +- https://typeorm.io +- https://github.com/typestack/class-validator +- https://github.com/goldbergyoni/nodebestpractices + +--- + +*Generated by build-agents.ts on 2026-01-16* diff --git a/.agents/skills/nestjs-best-practices/SKILL.md b/.agents/skills/nestjs-best-practices/SKILL.md new file mode 100644 index 000000000..de75eac99 --- /dev/null +++ b/.agents/skills/nestjs-best-practices/SKILL.md @@ -0,0 +1,130 @@ +--- +name: nestjs-best-practices +description: NestJS best practices and architecture patterns for building production-ready applications. This skill should be used when writing, reviewing, or refactoring NestJS code to ensure proper patterns for modules, dependency injection, security, and performance. +license: MIT +metadata: + author: Kadajett + version: "1.1.0" +--- + +# NestJS Best Practices + +Comprehensive best practices guide for NestJS applications. Contains 40 rules across 10 categories, prioritized by impact to guide automated refactoring and code generation. + +## When to Apply + +Reference these guidelines when: + +- Writing new NestJS modules, controllers, or services +- Implementing authentication and authorization +- Reviewing code for architecture and security issues +- Refactoring existing NestJS codebases +- Optimizing performance or database queries +- Building microservices architectures + +## Rule Categories by Priority + +| Priority | Category | Impact | Prefix | +|----------|----------|--------|--------| +| 1 | Architecture | CRITICAL | `arch-` | +| 2 | Dependency Injection | CRITICAL | `di-` | +| 3 | Error Handling | HIGH | `error-` | +| 4 | Security | HIGH | `security-` | +| 5 | Performance | HIGH | `perf-` | +| 6 | Testing | MEDIUM-HIGH | `test-` | +| 7 | Database & ORM | MEDIUM-HIGH | `db-` | +| 8 | API Design | MEDIUM | `api-` | +| 9 | Microservices | MEDIUM | `micro-` | +| 10 | DevOps & Deployment | LOW-MEDIUM | `devops-` | + +## Quick Reference + +### 1. Architecture (CRITICAL) + +- `arch-avoid-circular-deps` - Avoid circular module dependencies +- `arch-feature-modules` - Organize by feature, not technical layer +- `arch-module-sharing` - Proper module exports/imports, avoid duplicate providers +- `arch-single-responsibility` - Focused services over "god services" +- `arch-use-repository-pattern` - Abstract database logic for testability +- `arch-use-events` - Event-driven architecture for decoupling + +### 2. Dependency Injection (CRITICAL) + +- `di-avoid-service-locator` - Avoid service locator anti-pattern +- `di-interface-segregation` - Interface Segregation Principle (ISP) +- `di-liskov-substitution` - Liskov Substitution Principle (LSP) +- `di-prefer-constructor-injection` - Constructor over property injection +- `di-scope-awareness` - Understand singleton/request/transient scopes +- `di-use-interfaces-tokens` - Use injection tokens for interfaces + +### 3. Error Handling (HIGH) + +- `error-use-exception-filters` - Centralized exception handling +- `error-throw-http-exceptions` - Use NestJS HTTP exceptions +- `error-handle-async-errors` - Handle async errors properly + +### 4. Security (HIGH) + +- `security-auth-jwt` - Secure JWT authentication +- `security-validate-all-input` - Validate with class-validator +- `security-use-guards` - Authentication and authorization guards +- `security-sanitize-output` - Prevent XSS attacks +- `security-rate-limiting` - Implement rate limiting + +### 5. Performance (HIGH) + +- `perf-async-hooks` - Proper async lifecycle hooks +- `perf-use-caching` - Implement caching strategies +- `perf-optimize-database` - Optimize database queries +- `perf-lazy-loading` - Lazy load modules for faster startup + +### 6. Testing (MEDIUM-HIGH) + +- `test-use-testing-module` - Use NestJS testing utilities +- `test-e2e-supertest` - E2E testing with Supertest +- `test-mock-external-services` - Mock external dependencies + +### 7. Database & ORM (MEDIUM-HIGH) + +- `db-use-transactions` - Transaction management +- `db-avoid-n-plus-one` - Avoid N+1 query problems +- `db-use-migrations` - Use migrations for schema changes + +### 8. API Design (MEDIUM) + +- `api-use-dto-serialization` - DTO and response serialization +- `api-use-interceptors` - Cross-cutting concerns +- `api-versioning` - API versioning strategies +- `api-use-pipes` - Input transformation with pipes + +### 9. Microservices (MEDIUM) + +- `micro-use-patterns` - Message and event patterns +- `micro-use-health-checks` - Health checks for orchestration +- `micro-use-queues` - Background job processing + +### 10. DevOps & Deployment (LOW-MEDIUM) + +- `devops-use-config-module` - Environment configuration +- `devops-use-logging` - Structured logging +- `devops-graceful-shutdown` - Zero-downtime deployments + +## How to Use + +Read individual rule files for detailed explanations and code examples: + +``` +rules/arch-avoid-circular-deps.md +rules/security-validate-all-input.md +rules/_sections.md +``` + +Each rule file contains: +- Brief explanation of why it matters +- Incorrect code example with explanation +- Correct code example with explanation +- Additional context and references + +## Full Compiled Document + +For the complete guide with all rules expanded: `AGENTS.md` diff --git a/.agents/skills/nestjs-best-practices/rules/_sections.md b/.agents/skills/nestjs-best-practices/rules/_sections.md new file mode 100644 index 000000000..91bc8029a --- /dev/null +++ b/.agents/skills/nestjs-best-practices/rules/_sections.md @@ -0,0 +1,56 @@ +# Sections + +This file defines all sections, their ordering, impact levels, and descriptions. +The section ID (in parentheses) is the filename prefix used to group rules. + +--- + +## 1. Architecture (arch) + +**Impact:** CRITICAL +**Description:** Proper module organization and dependency management are the foundation of maintainable NestJS applications. Circular dependencies and god services are the #1 architecture killer. + +## 2. Dependency Injection (di) + +**Impact:** CRITICAL +**Description:** NestJS's IoC container is powerful but can be misused. Understanding scopes, injection tokens, and proper patterns is essential for testable code. + +## 3. Error Handling (error) + +**Impact:** HIGH +**Description:** Consistent error handling improves debugging, user experience, and API reliability. Centralized exception filters ensure uniform error responses. + +## 4. Security (security) + +**Impact:** HIGH +**Description:** Security vulnerabilities can be catastrophic. Input validation, authentication, authorization, and data protection are non-negotiable. + +## 5. Performance (perf) + +**Impact:** HIGH +**Description:** Optimizing request handling, caching, and database queries directly impacts application responsiveness and scalability. + +## 6. Testing (test) + +**Impact:** MEDIUM-HIGH +**Description:** Well-tested applications are more reliable. NestJS testing utilities enable comprehensive unit and e2e coverage. + +## 7. Database & ORM (db) + +**Impact:** MEDIUM-HIGH +**Description:** Proper database access patterns, transactions, and query optimization are crucial for data-intensive applications. + +## 8. API Design (api) + +**Impact:** MEDIUM +**Description:** RESTful conventions, versioning, DTOs, and consistent response formats improve API usability and maintainability. + +## 9. Microservices (micro) + +**Impact:** MEDIUM +**Description:** Building distributed systems requires understanding message patterns, health checks, and inter-service communication. + +## 10. DevOps & Deployment (devops) + +**Impact:** LOW-MEDIUM +**Description:** Configuration management, structured logging, and graceful shutdown ensure production readiness and zero-downtime deployments. diff --git a/.agents/skills/nestjs-best-practices/rules/_template.md b/.agents/skills/nestjs-best-practices/rules/_template.md new file mode 100644 index 000000000..1e9e70703 --- /dev/null +++ b/.agents/skills/nestjs-best-practices/rules/_template.md @@ -0,0 +1,28 @@ +--- +title: Rule Title Here +impact: MEDIUM +impactDescription: Optional description of impact (e.g., "20-50% improvement") +tags: tag1, tag2 +--- + +## Rule Title Here + +**Impact: MEDIUM (optional impact description)** + +Brief explanation of the rule and why it matters. This should be clear and concise, explaining the performance implications. + +**Incorrect (description of what's wrong):** + +```typescript +// Bad code example here +const bad = example() +``` + +**Correct (description of what's right):** + +```typescript +// Good code example here +const good = example() +``` + +Reference: [Link to documentation or resource](https://example.com) diff --git a/.agents/skills/nestjs-best-practices/rules/api-use-dto-serialization.md b/.agents/skills/nestjs-best-practices/rules/api-use-dto-serialization.md new file mode 100644 index 000000000..be95c07f7 --- /dev/null +++ b/.agents/skills/nestjs-best-practices/rules/api-use-dto-serialization.md @@ -0,0 +1,182 @@ +--- +title: Use DTOs and Serialization for API Responses +impact: MEDIUM +impactDescription: Response DTOs prevent accidental data exposure and ensure consistency +tags: api, dto, serialization, class-transformer +--- + +## Use DTOs and Serialization for API Responses + +Never return entity objects directly from controllers. Use response DTOs with class-transformer's `@Exclude()` and `@Expose()` decorators to control exactly what data is sent to clients. This prevents accidental exposure of sensitive fields and provides a stable API contract. + +**Incorrect (returning entities directly or manual spreading):** + +```typescript +// Return entities directly +@Controller('users') +export class UsersController { + @Get(':id') + async findOne(@Param('id') id: string): Promise { + return this.usersService.findById(id); + // Returns: { id, email, passwordHash, ssn, internalNotes, ... } + // Exposes sensitive data! + } +} + +// Manual object spreading (error-prone) +@Get(':id') +async findOne(@Param('id') id: string) { + const user = await this.usersService.findById(id); + return { + id: user.id, + email: user.email, + name: user.name, + // Easy to forget to exclude sensitive fields + // Hard to maintain across endpoints + }; +} +``` + +**Correct (use class-transformer with @Exclude and response DTOs):** + +```typescript +// Enable class-transformer globally +async function bootstrap() { + const app = await NestFactory.create(AppModule); + app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector))); + await app.listen(3000); +} + +// Entity with serialization control +@Entity() +export class User { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + email: string; + + @Column() + name: string; + + @Column() + @Exclude() // Never include in responses + passwordHash: string; + + @Column({ nullable: true }) + @Exclude() + ssn: string; + + @Column({ default: false }) + @Exclude({ toPlainOnly: true }) // Exclude from response, allow in requests + isAdmin: boolean; + + @CreateDateColumn() + createdAt: Date; + + @Column() + @Exclude() + internalNotes: string; +} + +// Now returning entity is safe +@Controller('users') +export class UsersController { + @Get(':id') + async findOne(@Param('id') id: string): Promise { + return this.usersService.findById(id); + // Returns: { id, email, name, createdAt } + // Sensitive fields excluded automatically + } +} + +// For different response shapes, use explicit DTOs +export class UserResponseDto { + @Expose() + id: string; + + @Expose() + email: string; + + @Expose() + name: string; + + @Expose() + @Transform(({ obj }) => obj.posts?.length || 0) + postCount: number; + + constructor(partial: Partial) { + Object.assign(this, partial); + } +} + +export class UserDetailResponseDto extends UserResponseDto { + @Expose() + createdAt: Date; + + @Expose() + @Type(() => PostResponseDto) + posts: PostResponseDto[]; +} + +// Controller with explicit DTOs +@Controller('users') +export class UsersController { + @Get() + @SerializeOptions({ type: UserResponseDto }) + async findAll(): Promise { + const users = await this.usersService.findAll(); + return users.map(u => plainToInstance(UserResponseDto, u)); + } + + @Get(':id') + async findOne(@Param('id') id: string): Promise { + const user = await this.usersService.findByIdWithPosts(id); + return plainToInstance(UserDetailResponseDto, user, { + excludeExtraneousValues: true, + }); + } +} + +// Groups for conditional serialization +export class UserDto { + @Expose() + id: string; + + @Expose() + name: string; + + @Expose({ groups: ['admin'] }) + email: string; + + @Expose({ groups: ['admin'] }) + createdAt: Date; + + @Expose({ groups: ['admin', 'owner'] }) + settings: UserSettings; +} + +@Controller('users') +export class UsersController { + @Get() + @SerializeOptions({ groups: ['public'] }) + async findAllPublic(): Promise { + // Returns: { id, name } + } + + @Get('admin') + @UseGuards(AdminGuard) + @SerializeOptions({ groups: ['admin'] }) + async findAllAdmin(): Promise { + // Returns: { id, name, email, createdAt } + } + + @Get('me') + @SerializeOptions({ groups: ['owner'] }) + async getProfile(@CurrentUser() user: User): Promise { + // Returns: { id, name, settings } + } +} +``` + +Reference: [NestJS Serialization](https://docs.nestjs.com/techniques/serialization) diff --git a/.agents/skills/nestjs-best-practices/rules/api-use-interceptors.md b/.agents/skills/nestjs-best-practices/rules/api-use-interceptors.md new file mode 100644 index 000000000..522ab3d52 --- /dev/null +++ b/.agents/skills/nestjs-best-practices/rules/api-use-interceptors.md @@ -0,0 +1,202 @@ +--- +title: Use Interceptors for Cross-Cutting Concerns +impact: MEDIUM-HIGH +impactDescription: Interceptors provide clean separation for cross-cutting logic +tags: api, interceptors, logging, caching +--- + +## Use Interceptors for Cross-Cutting Concerns + +Interceptors can transform responses, add logging, handle caching, and measure performance without polluting your business logic. They wrap the route handler execution, giving you access to both the request and response streams. + +**Incorrect (logging and transformation in every method):** + +```typescript +// Logging in every controller method +@Controller('users') +export class UsersController { + @Get() + async findAll(): Promise { + const start = Date.now(); + this.logger.log('findAll called'); + + const users = await this.usersService.findAll(); + + this.logger.log(`findAll completed in ${Date.now() - start}ms`); + return users; + } + + @Get(':id') + async findOne(@Param('id') id: string): Promise { + const start = Date.now(); + this.logger.log(`findOne called with id: ${id}`); + + const user = await this.usersService.findOne(id); + + this.logger.log(`findOne completed in ${Date.now() - start}ms`); + return user; + } + // Repeated in every method! +} + +// Manual response wrapping +@Get() +async findAll(): Promise<{ data: User[]; meta: Meta }> { + const users = await this.usersService.findAll(); + return { + data: users, + meta: { timestamp: new Date(), count: users.length }, + }; +} +``` + +**Correct (use interceptors for cross-cutting concerns):** + +```typescript +// Logging interceptor +@Injectable() +export class LoggingInterceptor implements NestInterceptor { + private readonly logger = new Logger('HTTP'); + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest(); + const { method, url, body } = request; + const now = Date.now(); + + return next.handle().pipe( + tap({ + next: (data) => { + const response = context.switchToHttp().getResponse(); + this.logger.log( + `${method} ${url} ${response.statusCode} - ${Date.now() - now}ms`, + ); + }, + error: (error) => { + this.logger.error( + `${method} ${url} ${error.status || 500} - ${Date.now() - now}ms`, + error.stack, + ); + }, + }), + ); + } +} + +// Response transformation interceptor +@Injectable() +export class TransformInterceptor implements NestInterceptor> { + intercept(context: ExecutionContext, next: CallHandler): Observable> { + return next.handle().pipe( + map((data) => ({ + data, + meta: { + timestamp: new Date().toISOString(), + path: context.switchToHttp().getRequest().url, + }, + })), + ); + } +} + +// Timeout interceptor +@Injectable() +export class TimeoutInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + return next.handle().pipe( + timeout(5000), + catchError((err) => { + if (err instanceof TimeoutError) { + throw new RequestTimeoutException('Request timed out'); + } + throw err; + }), + ); + } +} + +// Apply globally or per-controller +@Module({ + providers: [ + { provide: APP_INTERCEPTOR, useClass: LoggingInterceptor }, + { provide: APP_INTERCEPTOR, useClass: TransformInterceptor }, + ], +}) +export class AppModule {} + +// Or per-controller +@Controller('users') +@UseInterceptors(LoggingInterceptor) +export class UsersController { + @Get() + async findAll(): Promise { + // Clean business logic only + return this.usersService.findAll(); + } +} + +// Custom cache interceptor with TTL +@Injectable() +export class HttpCacheInterceptor implements NestInterceptor { + constructor( + private cacheManager: Cache, + private reflector: Reflector, + ) {} + + async intercept(context: ExecutionContext, next: CallHandler): Promise> { + const request = context.switchToHttp().getRequest(); + + // Only cache GET requests + if (request.method !== 'GET') { + return next.handle(); + } + + const cacheKey = this.generateKey(request); + const ttl = this.reflector.get('cacheTTL', context.getHandler()) || 300; + + const cached = await this.cacheManager.get(cacheKey); + if (cached) { + return of(cached); + } + + return next.handle().pipe( + tap((response) => { + this.cacheManager.set(cacheKey, response, ttl); + }), + ); + } + + private generateKey(request: Request): string { + return `cache:${request.url}:${JSON.stringify(request.query)}`; + } +} + +// Usage with custom TTL +@Get() +@SetMetadata('cacheTTL', 600) +@UseInterceptors(HttpCacheInterceptor) +async findAll(): Promise { + return this.usersService.findAll(); +} + +// Error mapping interceptor +@Injectable() +export class ErrorMappingInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + return next.handle().pipe( + catchError((error) => { + if (error instanceof EntityNotFoundError) { + throw new NotFoundException(error.message); + } + if (error instanceof QueryFailedError) { + if (error.message.includes('duplicate')) { + throw new ConflictException('Resource already exists'); + } + } + throw error; + }), + ); + } +} +``` + +Reference: [NestJS Interceptors](https://docs.nestjs.com/interceptors) diff --git a/.agents/skills/nestjs-best-practices/rules/api-use-pipes.md b/.agents/skills/nestjs-best-practices/rules/api-use-pipes.md new file mode 100644 index 000000000..72b9a6e9d --- /dev/null +++ b/.agents/skills/nestjs-best-practices/rules/api-use-pipes.md @@ -0,0 +1,205 @@ +--- +title: Use Pipes for Input Transformation +impact: MEDIUM +impactDescription: Pipes ensure clean, validated data reaches your handlers +tags: api, pipes, validation, transformation +--- + +## Use Pipes for Input Transformation + +Use built-in pipes like `ParseIntPipe`, `ParseUUIDPipe`, and `DefaultValuePipe` for common transformations. Create custom pipes for business-specific transformations. Pipes separate validation/transformation logic from controllers. + +**Incorrect (manual type parsing in handlers):** + +```typescript +// Manual type parsing in handlers +@Controller('users') +export class UsersController { + @Get(':id') + async findOne(@Param('id') id: string): Promise { + // Manual validation in every handler + const uuid = id.trim(); + if (!isUUID(uuid)) { + throw new BadRequestException('Invalid UUID'); + } + return this.usersService.findOne(uuid); + } + + @Get() + async findAll( + @Query('page') page: string, + @Query('limit') limit: string, + ): Promise { + // Manual parsing and defaults + const pageNum = parseInt(page) || 1; + const limitNum = parseInt(limit) || 10; + return this.usersService.findAll(pageNum, limitNum); + } +} + +// Type coercion without validation +@Get() +async search(@Query('price') price: string): Promise { + const priceNum = +price; // NaN if invalid, no error + return this.productsService.findByPrice(priceNum); +} +``` + +**Correct (use built-in and custom pipes):** + +```typescript +// Use built-in pipes for common transformations +@Controller('users') +export class UsersController { + @Get(':id') + async findOne(@Param('id', ParseUUIDPipe) id: string): Promise { + // id is guaranteed to be a valid UUID + return this.usersService.findOne(id); + } + + @Get() + async findAll( + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number, + ): Promise { + // Automatic defaults and type conversion + return this.usersService.findAll(page, limit); + } + + @Get('by-status/:status') + async findByStatus( + @Param('status', new ParseEnumPipe(UserStatus)) status: UserStatus, + ): Promise { + return this.usersService.findByStatus(status); + } +} + +// Custom pipe for business logic +@Injectable() +export class ParseDatePipe implements PipeTransform { + transform(value: string): Date { + const date = new Date(value); + if (isNaN(date.getTime())) { + throw new BadRequestException('Invalid date format'); + } + return date; + } +} + +@Get('reports') +async getReports( + @Query('from', ParseDatePipe) from: Date, + @Query('to', ParseDatePipe) to: Date, +): Promise { + return this.reportsService.findBetween(from, to); +} + +// Custom transformation pipes +@Injectable() +export class NormalizeEmailPipe implements PipeTransform { + transform(value: string): string { + if (!value) return value; + return value.trim().toLowerCase(); + } +} + +// Parse comma-separated values +@Injectable() +export class ParseArrayPipe implements PipeTransform { + transform(value: string): string[] { + if (!value) return []; + return value.split(',').map((v) => v.trim()).filter(Boolean); + } +} + +@Get('products') +async findProducts( + @Query('ids', ParseArrayPipe) ids: string[], + @Query('email', NormalizeEmailPipe) email: string, +): Promise { + // ids is already an array, email is normalized + return this.productsService.findByIds(ids); +} + +// Sanitize HTML input +@Injectable() +export class SanitizeHtmlPipe implements PipeTransform { + transform(value: string): string { + if (!value) return value; + return sanitizeHtml(value, { allowedTags: [] }); + } +} + +// Global validation pipe with transformation +app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, // Strip non-DTO properties + transform: true, // Auto-transform to DTO types + transformOptions: { + enableImplicitConversion: true, // Convert query strings to numbers + }, + forbidNonWhitelisted: true, // Throw on extra properties + }), +); + +// DTO with transformation decorators +export class FindProductsDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 10; + + @IsOptional() + @Transform(({ value }) => value?.toLowerCase()) + @IsString() + search?: string; + + @IsOptional() + @Transform(({ value }) => value?.split(',')) + @IsArray() + @IsString({ each: true }) + categories?: string[]; +} + +@Get() +async findAll(@Query() dto: FindProductsDto): Promise { + // dto is already transformed and validated + return this.productsService.findAll(dto); +} + +// Pipe error customization +@Injectable() +export class CustomParseIntPipe extends ParseIntPipe { + constructor() { + super({ + exceptionFactory: (error) => + new BadRequestException(`${error} must be a valid integer`), + }); + } +} + +// Or use options on built-in pipes +@Get(':id') +async findOne( + @Param( + 'id', + new ParseIntPipe({ + errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE, + exceptionFactory: () => new NotAcceptableException('ID must be numeric'), + }), + ) + id: number, +): Promise { + return this.itemsService.findOne(id); +} +``` + +Reference: [NestJS Pipes](https://docs.nestjs.com/pipes) diff --git a/.agents/skills/nestjs-best-practices/rules/api-versioning.md b/.agents/skills/nestjs-best-practices/rules/api-versioning.md new file mode 100644 index 000000000..5e4546ca0 --- /dev/null +++ b/.agents/skills/nestjs-best-practices/rules/api-versioning.md @@ -0,0 +1,191 @@ +--- +title: Use API Versioning for Breaking Changes +impact: MEDIUM +impactDescription: Versioning allows you to evolve APIs without breaking existing clients +tags: api, versioning, breaking-changes, compatibility +--- + +## Use API Versioning for Breaking Changes + +Use NestJS built-in versioning when making breaking changes to your API. Choose a versioning strategy (URI, header, or media type) and apply it consistently. This allows old clients to continue working while new clients use updated endpoints. + +**Incorrect (breaking changes without versioning):** + +```typescript +// Breaking changes without versioning +@Controller('users') +export class UsersController { + @Get(':id') + async findOne(@Param('id') id: string): Promise { + // Original response: { id, name, email } + // Later changed to: { id, firstName, lastName, emailAddress } + // Old clients break! + return this.usersService.findOne(id); + } +} + +// Manual versioning in routes +@Controller('v1/users') +export class UsersV1Controller {} + +@Controller('v2/users') +export class UsersV2Controller {} +// Inconsistent, error-prone, hard to maintain +``` + +**Correct (use NestJS built-in versioning):** + +```typescript +// Enable versioning in main.ts +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + // URI versioning: /v1/users, /v2/users + app.enableVersioning({ + type: VersioningType.URI, + defaultVersion: '1', + }); + + // Or header versioning: X-API-Version: 1 + app.enableVersioning({ + type: VersioningType.HEADER, + header: 'X-API-Version', + defaultVersion: '1', + }); + + // Or media type: Accept: application/json;v=1 + app.enableVersioning({ + type: VersioningType.MEDIA_TYPE, + key: 'v=', + defaultVersion: '1', + }); + + await app.listen(3000); +} + +// Version-specific controllers +@Controller('users') +@Version('1') +export class UsersV1Controller { + @Get(':id') + async findOne(@Param('id') id: string): Promise { + const user = await this.usersService.findOne(id); + // V1 response format + return { + id: user.id, + name: user.name, + email: user.email, + }; + } +} + +@Controller('users') +@Version('2') +export class UsersV2Controller { + @Get(':id') + async findOne(@Param('id') id: string): Promise { + const user = await this.usersService.findOne(id); + // V2 response format with breaking changes + return { + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + emailAddress: user.email, + createdAt: user.createdAt, + }; + } +} + +// Per-route versioning - different versions for different routes +@Controller('users') +export class UsersController { + @Get() + @Version('1') + findAllV1(): Promise { + return this.usersService.findAllV1(); + } + + @Get() + @Version('2') + findAllV2(): Promise { + return this.usersService.findAllV2(); + } + + @Get(':id') + @Version(['1', '2']) // Same handler for multiple versions + findOne(@Param('id') id: string): Promise { + return this.usersService.findOne(id); + } + + @Post() + @Version(VERSION_NEUTRAL) // Available in all versions + create(@Body() dto: CreateUserDto): Promise { + return this.usersService.create(dto); + } +} + +// Shared service with version-specific logic +@Injectable() +export class UsersService { + async findOne(id: string, version: string): Promise { + const user = await this.repo.findOne({ where: { id } }); + + if (version === '1') { + return this.toV1Response(user); + } + return this.toV2Response(user); + } + + private toV1Response(user: User): UserV1Response { + return { + id: user.id, + name: `${user.firstName} ${user.lastName}`, + email: user.email, + }; + } + + private toV2Response(user: User): UserV2Response { + return { + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + emailAddress: user.email, + createdAt: user.createdAt, + }; + } +} + +// Controller extracts version +@Controller('users') +export class UsersController { + @Get(':id') + async findOne( + @Param('id') id: string, + @Headers('X-API-Version') version: string = '1', + ): Promise { + return this.usersService.findOne(id, version); + } +} + +// Deprecation strategy - mark old versions as deprecated +@Controller('users') +@Version('1') +@UseInterceptors(DeprecationInterceptor) +export class UsersV1Controller { + // All V1 routes will include deprecation warning +} + +@Injectable() +export class DeprecationInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + const response = context.switchToHttp().getResponse(); + response.setHeader('Deprecation', 'true'); + response.setHeader('Sunset', 'Sat, 1 Jan 2025 00:00:00 GMT'); + response.setHeader('Link', '; rel="successor-version"'); + + return next.handle(); + } +} +``` + +Reference: [NestJS Versioning](https://docs.nestjs.com/techniques/versioning) diff --git a/.agents/skills/nestjs-best-practices/rules/arch-avoid-circular-deps.md b/.agents/skills/nestjs-best-practices/rules/arch-avoid-circular-deps.md new file mode 100644 index 000000000..aea5c8be5 --- /dev/null +++ b/.agents/skills/nestjs-best-practices/rules/arch-avoid-circular-deps.md @@ -0,0 +1,80 @@ +--- +title: Avoid Circular Dependencies +impact: CRITICAL +impactDescription: "#1 cause of runtime crashes" +tags: architecture, modules, dependencies +--- + +## Avoid Circular Dependencies + +Circular dependencies occur when Module A imports Module B, and Module B imports Module A (directly or transitively). NestJS can sometimes resolve these through forward references, but they indicate architectural problems and should be avoided. This is the #1 cause of runtime crashes in NestJS applications. + +**Incorrect (circular module imports):** + +```typescript +// users.module.ts +@Module({ + imports: [OrdersModule], // Orders needs Users, Users needs Orders = circular + providers: [UsersService], + exports: [UsersService], +}) +export class UsersModule {} + +// orders.module.ts +@Module({ + imports: [UsersModule], // Circular dependency! + providers: [OrdersService], + exports: [OrdersService], +}) +export class OrdersModule {} +``` + +**Correct (extract shared logic or use events):** + +```typescript +// Option 1: Extract shared logic to a third module +// shared.module.ts +@Module({ + providers: [SharedService], + exports: [SharedService], +}) +export class SharedModule {} + +// users.module.ts +@Module({ + imports: [SharedModule], + providers: [UsersService], +}) +export class UsersModule {} + +// orders.module.ts +@Module({ + imports: [SharedModule], + providers: [OrdersService], +}) +export class OrdersModule {} + +// Option 2: Use events for decoupled communication +// users.service.ts +@Injectable() +export class UsersService { + constructor(private eventEmitter: EventEmitter2) {} + + async createUser(data: CreateUserDto) { + const user = await this.userRepo.save(data); + this.eventEmitter.emit('user.created', user); + return user; + } +} + +// orders.service.ts +@Injectable() +export class OrdersService { + @OnEvent('user.created') + handleUserCreated(user: User) { + // React to user creation without direct dependency + } +} +``` + +Reference: [NestJS Circular Dependency](https://docs.nestjs.com/fundamentals/circular-dependency) diff --git a/.agents/skills/nestjs-best-practices/rules/arch-feature-modules.md b/.agents/skills/nestjs-best-practices/rules/arch-feature-modules.md new file mode 100644 index 000000000..4f3ef5679 --- /dev/null +++ b/.agents/skills/nestjs-best-practices/rules/arch-feature-modules.md @@ -0,0 +1,82 @@ +--- +title: Organize by Feature Modules +impact: CRITICAL +impactDescription: "3-5x faster onboarding and development" +tags: architecture, modules, organization +--- + +## Organize by Feature Modules + +Organize your application into feature modules that encapsulate related functionality. Each feature module should be self-contained with its own controllers, services, entities, and DTOs. Avoid organizing by technical layer (all controllers together, all services together). This enables 3-5x faster onboarding and feature development. + +**Incorrect (technical layer organization):** + +```typescript +// Technical layer organization (anti-pattern) +src/ +├── controllers/ +│ ├── users.controller.ts +│ ├── orders.controller.ts +│ └── products.controller.ts +├── services/ +│ ├── users.service.ts +│ ├── orders.service.ts +│ └── products.service.ts +├── entities/ +│ ├── user.entity.ts +│ ├── order.entity.ts +│ └── product.entity.ts +└── app.module.ts // Imports everything directly +``` + +**Correct (feature module organization):** + +```typescript +// Feature module organization +src/ +├── users/ +│ ├── dto/ +│ │ ├── create-user.dto.ts +│ │ └── update-user.dto.ts +│ ├── entities/ +│ │ └── user.entity.ts +│ ├── users.controller.ts +│ ├── users.service.ts +│ ├── users.repository.ts +│ └── users.module.ts +├── orders/ +│ ├── dto/ +│ ├── entities/ +│ ├── orders.controller.ts +│ ├── orders.service.ts +│ └── orders.module.ts +├── shared/ +│ ├── guards/ +│ ├── interceptors/ +│ ├── filters/ +│ └── shared.module.ts +└── app.module.ts + +// users.module.ts +@Module({ + imports: [TypeOrmModule.forFeature([User])], + controllers: [UsersController], + providers: [UsersService, UsersRepository], + exports: [UsersService], // Only export what others need +}) +export class UsersModule {} + +// app.module.ts +@Module({ + imports: [ + ConfigModule.forRoot(), + TypeOrmModule.forRoot(), + UsersModule, + OrdersModule, + SharedModule, + ], +}) +export class AppModule {} +``` + +Reference: [NestJS Modules](https://docs.nestjs.com/modules) diff --git a/.agents/skills/nestjs-best-practices/rules/arch-module-sharing.md b/.agents/skills/nestjs-best-practices/rules/arch-module-sharing.md new file mode 100644 index 000000000..06ab76786 --- /dev/null +++ b/.agents/skills/nestjs-best-practices/rules/arch-module-sharing.md @@ -0,0 +1,141 @@ +--- +title: Use Proper Module Sharing Patterns +impact: CRITICAL +impactDescription: Prevents duplicate instances, memory leaks, and state inconsistency +tags: architecture, modules, sharing, exports +--- + +## Use Proper Module Sharing Patterns + +NestJS modules are singletons by default. When a service is properly exported from a module and that module is imported elsewhere, the same instance is shared. However, providing a service in multiple modules creates separate instances, leading to memory waste, state inconsistency, and confusing behavior. Always encapsulate services in dedicated modules, export them explicitly, and import the module where needed. + +**Incorrect (service provided in multiple modules):** + +```typescript +// StorageService provided directly in multiple modules - WRONG +// storage.service.ts +@Injectable() +export class StorageService { + private cache = new Map(); // Each instance has separate state! + + store(key: string, value: any) { + this.cache.set(key, value); + } +} + +// app.module.ts +@Module({ + providers: [StorageService], // Instance #1 + controllers: [AppController], +}) +export class AppModule {} + +// videos.module.ts +@Module({ + providers: [StorageService], // Instance #2 - different from AppModule! + controllers: [VideosController], +}) +export class VideosModule {} + +// Problems: +// 1. Two separate StorageService instances exist +// 2. cache.set() in VideosModule doesn't affect AppModule's cache +// 3. Memory wasted on duplicate instances +// 4. Debugging nightmares when state doesn't sync +``` + +**Correct (dedicated module with exports):** + +```typescript +// storage/storage.module.ts +@Module({ + providers: [StorageService], + exports: [StorageService], // Make available to importers +}) +export class StorageModule {} + +// videos/videos.module.ts +@Module({ + imports: [StorageModule], // Import the module, not the service + controllers: [VideosController], + providers: [VideosService], +}) +export class VideosModule {} + +// channels/channels.module.ts +@Module({ + imports: [StorageModule], // Same instance shared + controllers: [ChannelsController], + providers: [ChannelsService], +}) +export class ChannelsModule {} + +// app.module.ts +@Module({ + imports: [ + StorageModule, // Only if AppModule itself needs StorageService + VideosModule, + ChannelsModule, + ], +}) +export class AppModule {} + +// Now all modules share the SAME StorageService instance +``` + +**When to use @Global() (sparingly):** + +```typescript +// ONLY for truly cross-cutting concerns +@Global() +@Module({ + providers: [ConfigService, LoggerService], + exports: [ConfigService, LoggerService], +}) +export class CoreModule {} + +// Import once in AppModule +@Module({ + imports: [CoreModule], // Registered globally, available everywhere +}) +export class AppModule {} + +// Other modules don't need to import CoreModule +@Module({ + controllers: [UsersController], + providers: [UsersService], // Can inject ConfigService without importing +}) +export class UsersModule {} + +// WARNING: Don't make everything global! +// - Hides dependencies (can't see what a module needs from imports) +// - Makes testing harder +// - Reserve for: config, logging, database connections +``` + +**Module re-exporting pattern:** + +```typescript +// common.module.ts - shared utilities +@Module({ + providers: [DateService, ValidationService], + exports: [DateService, ValidationService], +}) +export class CommonModule {} + +// core.module.ts - re-exports common for convenience +@Module({ + imports: [CommonModule, DatabaseModule], + exports: [CommonModule, DatabaseModule], // Re-export for consumers +}) +export class CoreModule {} + +// feature.module.ts - imports CoreModule, gets both +@Module({ + imports: [CoreModule], // Gets CommonModule + DatabaseModule + controllers: [FeatureController], +}) +export class FeatureModule {} +``` + +Reference: [NestJS Modules](https://docs.nestjs.com/modules#shared-modules) diff --git a/.agents/skills/nestjs-best-practices/rules/arch-single-responsibility.md b/.agents/skills/nestjs-best-practices/rules/arch-single-responsibility.md new file mode 100644 index 000000000..6ffc46eb2 --- /dev/null +++ b/.agents/skills/nestjs-best-practices/rules/arch-single-responsibility.md @@ -0,0 +1,106 @@ +--- +title: Single Responsibility for Services +impact: CRITICAL +impactDescription: "40%+ improvement in testability" +tags: architecture, services, single-responsibility +--- + +## Single Responsibility for Services + +Each service should have a single, well-defined responsibility. Avoid "god services" that handle multiple unrelated concerns. If a service name includes "And" or handles more than one domain concept, it likely violates single responsibility. This reduces complexity and improves testability by 40%+. + +**Incorrect (god service anti-pattern):** + +```typescript +// God service anti-pattern +@Injectable() +export class UserAndOrderService { + constructor( + private userRepo: UserRepository, + private orderRepo: OrderRepository, + private mailer: MailService, + private payment: PaymentService, + ) {} + + async createUser(dto: CreateUserDto) { + const user = await this.userRepo.save(dto); + await this.mailer.sendWelcome(user); + return user; + } + + async createOrder(userId: string, dto: CreateOrderDto) { + const order = await this.orderRepo.save({ userId, ...dto }); + await this.payment.charge(order); + await this.mailer.sendOrderConfirmation(order); + return order; + } + + async calculateOrderStats(userId: string) { + // Stats logic mixed in + } + + async validatePayment(orderId: string) { + // Payment logic mixed in + } +} +``` + +**Correct (focused services with single responsibility):** + +```typescript +// Focused services with single responsibility +@Injectable() +export class UsersService { + constructor(private userRepo: UserRepository) {} + + async create(dto: CreateUserDto): Promise { + return this.userRepo.save(dto); + } + + async findById(id: string): Promise { + return this.userRepo.findOneOrFail({ where: { id } }); + } +} + +@Injectable() +export class OrdersService { + constructor(private orderRepo: OrderRepository) {} + + async create(userId: string, dto: CreateOrderDto): Promise { + return this.orderRepo.save({ userId, ...dto }); + } + + async findByUser(userId: string): Promise { + return this.orderRepo.find({ where: { userId } }); + } +} + +@Injectable() +export class OrderStatsService { + constructor(private orderRepo: OrderRepository) {} + + async calculateForUser(userId: string): Promise { + // Focused stats calculation + } +} + +// Orchestration in controller or dedicated orchestrator +@Controller('orders') +export class OrdersController { + constructor( + private orders: OrdersService, + private payment: PaymentService, + private notifications: NotificationService, + ) {} + + @Post() + async create(@CurrentUser() user: User, @Body() dto: CreateOrderDto) { + const order = await this.orders.create(user.id, dto); + await this.payment.charge(order); + await this.notifications.sendOrderConfirmation(order); + return order; + } +} +``` + +Reference: [NestJS Providers](https://docs.nestjs.com/providers) diff --git a/.agents/skills/nestjs-best-practices/rules/arch-use-events.md b/.agents/skills/nestjs-best-practices/rules/arch-use-events.md new file mode 100644 index 000000000..f8cda270f --- /dev/null +++ b/.agents/skills/nestjs-best-practices/rules/arch-use-events.md @@ -0,0 +1,108 @@ +--- +title: Use Event-Driven Architecture for Decoupling +impact: MEDIUM-HIGH +impactDescription: Enables async processing and modularity +tags: architecture, events, decoupling +--- + +## Use Event-Driven Architecture for Decoupling + +Use `@nestjs/event-emitter` for intra-service events and message brokers for inter-service communication. Events allow modules to react to changes without direct dependencies, improving modularity and enabling async processing. + +**Incorrect (direct service coupling):** + +```typescript +// Direct service coupling +@Injectable() +export class OrdersService { + constructor( + private inventoryService: InventoryService, + private emailService: EmailService, + private analyticsService: AnalyticsService, + private notificationService: NotificationService, + private loyaltyService: LoyaltyService, + ) {} + + async createOrder(dto: CreateOrderDto): Promise { + const order = await this.repo.save(dto); + + // Tight coupling - OrdersService knows about all consumers + await this.inventoryService.reserve(order.items); + await this.emailService.sendConfirmation(order); + await this.analyticsService.track('order_created', order); + await this.notificationService.push(order.userId, 'Order placed'); + await this.loyaltyService.addPoints(order.userId, order.total); + + // Adding new behavior requires modifying this service + return order; + } +} +``` + +**Correct (event-driven decoupling):** + +```typescript +// Use EventEmitter for decoupling +import { EventEmitter2 } from '@nestjs/event-emitter'; + +// Define event +export class OrderCreatedEvent { + constructor( + public readonly orderId: string, + public readonly userId: string, + public readonly items: OrderItem[], + public readonly total: number, + ) {} +} + +// Service emits events +@Injectable() +export class OrdersService { + constructor( + private eventEmitter: EventEmitter2, + private repo: Repository, + ) {} + + async createOrder(dto: CreateOrderDto): Promise { + const order = await this.repo.save(dto); + + // Emit event - no knowledge of consumers + this.eventEmitter.emit( + 'order.created', + new OrderCreatedEvent(order.id, order.userId, order.items, order.total), + ); + + return order; + } +} + +// Listeners in separate modules +@Injectable() +export class InventoryListener { + @OnEvent('order.created') + async handleOrderCreated(event: OrderCreatedEvent): Promise { + await this.inventoryService.reserve(event.items); + } +} + +@Injectable() +export class EmailListener { + @OnEvent('order.created') + async handleOrderCreated(event: OrderCreatedEvent): Promise { + await this.emailService.sendConfirmation(event.orderId); + } +} + +@Injectable() +export class AnalyticsListener { + @OnEvent('order.created') + async handleOrderCreated(event: OrderCreatedEvent): Promise { + await this.analyticsService.track('order_created', { + orderId: event.orderId, + total: event.total, + }); + } +} +``` + +Reference: [NestJS Events](https://docs.nestjs.com/techniques/events) diff --git a/.agents/skills/nestjs-best-practices/rules/arch-use-repository-pattern.md b/.agents/skills/nestjs-best-practices/rules/arch-use-repository-pattern.md new file mode 100644 index 000000000..75df38101 --- /dev/null +++ b/.agents/skills/nestjs-best-practices/rules/arch-use-repository-pattern.md @@ -0,0 +1,97 @@ +--- +title: Use Repository Pattern for Data Access +impact: HIGH +impactDescription: Decouples business logic from database +tags: architecture, repository, data-access +--- + +## Use Repository Pattern for Data Access + +Create custom repositories to encapsulate complex queries and database logic. This keeps services focused on business logic, makes testing easier with mock repositories, and allows changing database implementations without affecting business code. + +**Incorrect (complex queries in services):** + +```typescript +// Complex queries in services +@Injectable() +export class UsersService { + constructor( + @InjectRepository(User) private repo: Repository, + ) {} + + async findActiveWithOrders(minOrders: number): Promise { + // Complex query logic mixed with business logic + return this.repo + .createQueryBuilder('user') + .leftJoinAndSelect('user.orders', 'order') + .where('user.isActive = :active', { active: true }) + .andWhere('user.deletedAt IS NULL') + .groupBy('user.id') + .having('COUNT(order.id) >= :min', { min: minOrders }) + .orderBy('user.createdAt', 'DESC') + .getMany(); + } + + // Service becomes bloated with query logic +} +``` + +**Correct (custom repository with encapsulated queries):** + +```typescript +// Custom repository with encapsulated queries +@Injectable() +export class UsersRepository { + constructor( + @InjectRepository(User) private repo: Repository, + ) {} + + async findById(id: string): Promise { + return this.repo.findOne({ where: { id } }); + } + + async findByEmail(email: string): Promise { + return this.repo.findOne({ where: { email } }); + } + + async findActiveWithMinOrders(minOrders: number): Promise { + return this.repo + .createQueryBuilder('user') + .leftJoinAndSelect('user.orders', 'order') + .where('user.isActive = :active', { active: true }) + .andWhere('user.deletedAt IS NULL') + .groupBy('user.id') + .having('COUNT(order.id) >= :min', { min: minOrders }) + .orderBy('user.createdAt', 'DESC') + .getMany(); + } + + async save(user: User): Promise { + return this.repo.save(user); + } +} + +// Clean service with business logic only +@Injectable() +export class UsersService { + constructor(private usersRepo: UsersRepository) {} + + async getActiveUsersWithOrders(): Promise { + return this.usersRepo.findActiveWithMinOrders(1); + } + + async create(dto: CreateUserDto): Promise { + const existing = await this.usersRepo.findByEmail(dto.email); + if (existing) { + throw new ConflictException('Email already registered'); + } + + const user = new User(); + user.email = dto.email; + user.name = dto.name; + return this.usersRepo.save(user); + } +} +``` + +Reference: [Repository Pattern](https://martinfowler.com/eaaCatalog/repository.html) diff --git a/.agents/skills/nestjs-best-practices/rules/db-avoid-n-plus-one.md b/.agents/skills/nestjs-best-practices/rules/db-avoid-n-plus-one.md new file mode 100644 index 000000000..a93ec4b59 --- /dev/null +++ b/.agents/skills/nestjs-best-practices/rules/db-avoid-n-plus-one.md @@ -0,0 +1,139 @@ +--- +title: Avoid N+1 Query Problems +impact: HIGH +impactDescription: N+1 queries are one of the most common performance killers +tags: database, n-plus-one, queries, performance +--- + +## Avoid N+1 Query Problems + +N+1 queries occur when you fetch a list of entities, then make an additional query for each entity to load related data. Use eager loading with `relations`, query builder joins, or DataLoader to batch queries efficiently. + +**Incorrect (lazy loading in loops causes N+1):** + +```typescript +// Lazy loading in loops causes N+1 +@Injectable() +export class OrdersService { + async getOrdersWithItems(userId: string): Promise { + const orders = await this.orderRepo.find({ where: { userId } }); + // 1 query for orders + + for (const order of orders) { + // N additional queries - one per order! + order.items = await this.itemRepo.find({ where: { orderId: order.id } }); + } + + return orders; + } +} + +// Accessing lazy relations without loading +@Controller('users') +export class UsersController { + @Get() + async findAll(): Promise { + const users = await this.userRepo.find(); + // If User.posts is lazy-loaded, serializing triggers N queries + return users; // Each user.posts access = 1 query + } +} +``` + +**Correct (use relations for eager loading):** + +```typescript +// Use relations option for eager loading +@Injectable() +export class OrdersService { + async getOrdersWithItems(userId: string): Promise { + // Single query with JOIN + return this.orderRepo.find({ + where: { userId }, + relations: ['items', 'items.product'], + }); + } +} + +// Use QueryBuilder for complex joins +@Injectable() +export class UsersService { + async getUsersWithPostCounts(): Promise { + return this.userRepo + .createQueryBuilder('user') + .leftJoin('user.posts', 'post') + .select('user.id', 'id') + .addSelect('user.name', 'name') + .addSelect('COUNT(post.id)', 'postCount') + .groupBy('user.id') + .getRawMany(); + } + + async getActiveUsersWithPosts(): Promise { + return this.userRepo + .createQueryBuilder('user') + .leftJoinAndSelect('user.posts', 'post') + .leftJoinAndSelect('post.comments', 'comment') + .where('user.isActive = :active', { active: true }) + .andWhere('post.status = :status', { status: 'published' }) + .getMany(); + } +} + +// Use find options for specific fields +async getOrderSummaries(userId: string): Promise { + return this.orderRepo.find({ + where: { userId }, + relations: ['items'], + select: { + id: true, + total: true, + status: true, + items: { + id: true, + quantity: true, + price: true, + }, + }, + }); +} + +// Use DataLoader for GraphQL to batch and cache queries +import DataLoader from 'dataloader'; + +@Injectable({ scope: Scope.REQUEST }) +export class PostsLoader { + constructor(private postsService: PostsService) {} + + readonly batchPosts = new DataLoader(async (userIds) => { + // Single query for all users' posts + const posts = await this.postsService.findByUserIds([...userIds]); + + // Group by userId + const postsMap = new Map(); + for (const post of posts) { + const userPosts = postsMap.get(post.userId) || []; + userPosts.push(post); + postsMap.set(post.userId, userPosts); + } + + // Return in same order as input + return userIds.map((id) => postsMap.get(id) || []); + }); +} + +// In resolver +@ResolveField() +async posts(@Parent() user: User): Promise { + // DataLoader batches multiple calls into single query + return this.postsLoader.batchPosts.load(user.id); +} + +// Enable query logging in development to detect N+1 +TypeOrmModule.forRoot({ + logging: ['query', 'error'], + logger: 'advanced-console', +}); +``` + +Reference: [TypeORM Relations](https://typeorm.io/relations) diff --git a/.agents/skills/nestjs-best-practices/rules/db-use-migrations.md b/.agents/skills/nestjs-best-practices/rules/db-use-migrations.md new file mode 100644 index 000000000..4c3b72408 --- /dev/null +++ b/.agents/skills/nestjs-best-practices/rules/db-use-migrations.md @@ -0,0 +1,129 @@ +--- +title: Use Database Migrations +impact: HIGH +impactDescription: Enables safe, repeatable database schema changes +tags: database, migrations, typeorm, schema +--- + +## Use Database Migrations + +Never use `synchronize: true` in production. Use migrations for all schema changes. Migrations provide version control for your database, enable safe rollbacks, and ensure consistency across all environments. + +**Incorrect (using synchronize or manual SQL):** + +```typescript +// Use synchronize in production +TypeOrmModule.forRoot({ + type: 'postgres', + synchronize: true, // DANGEROUS in production! + // Can drop columns, tables, or data +}); + +// Manual SQL in production +@Injectable() +export class DatabaseService { + async addColumn(): Promise { + await this.dataSource.query('ALTER TABLE users ADD COLUMN age INT'); + // No version control, no rollback, inconsistent across envs + } +} + +// Modify entities without migration +@Entity() +export class User { + @Column() + email: string; + + @Column() // Added without migration + newField: string; // Will crash in production if synchronize is false +} +``` + +**Correct (use migrations for all schema changes):** + +```typescript +// Configure TypeORM for migrations +// data-source.ts +export const dataSource = new DataSource({ + type: 'postgres', + host: process.env.DB_HOST, + port: parseInt(process.env.DB_PORT), + username: process.env.DB_USERNAME, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + entities: ['dist/**/*.entity.js'], + migrations: ['dist/migrations/*.js'], + synchronize: false, // Always false in production + migrationsRun: true, // Run migrations on startup +}); + +// app.module.ts +TypeOrmModule.forRootAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + type: 'postgres', + host: config.get('DB_HOST'), + synchronize: config.get('NODE_ENV') === 'development', // Only in dev + migrations: ['dist/migrations/*.js'], + migrationsRun: true, + }), +}); + +// migrations/1705312800000-AddUserAge.ts +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddUserAge1705312800000 implements MigrationInterface { + name = 'AddUserAge1705312800000'; + + public async up(queryRunner: QueryRunner): Promise { + // Add column with default to handle existing rows + await queryRunner.query(` + ALTER TABLE "users" ADD "age" integer DEFAULT 0 + `); + + // Add index for frequently queried columns + await queryRunner.query(` + CREATE INDEX "IDX_users_age" ON "users" ("age") + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Always implement down for rollback + await queryRunner.query(`DROP INDEX "IDX_users_age"`); + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "age"`); + } +} + +// Safe column rename (two-step) +export class RenameNameToFullName1705312900000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Step 1: Add new column + await queryRunner.query(` + ALTER TABLE "users" ADD "full_name" varchar(255) + `); + + // Step 2: Copy data + await queryRunner.query(` + UPDATE "users" SET "full_name" = "name" + `); + + // Step 3: Add NOT NULL constraint + await queryRunner.query(` + ALTER TABLE "users" ALTER COLUMN "full_name" SET NOT NULL + `); + + // Step 4: Drop old column (after verifying app works) + await queryRunner.query(` + ALTER TABLE "users" DROP COLUMN "name" + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" ADD "name" varchar(255)`); + await queryRunner.query(`UPDATE "users" SET "name" = "full_name"`); + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "full_name"`); + } +} +``` + +Reference: [TypeORM Migrations](https://typeorm.io/migrations) diff --git a/.agents/skills/nestjs-best-practices/rules/db-use-transactions.md b/.agents/skills/nestjs-best-practices/rules/db-use-transactions.md new file mode 100644 index 000000000..543bf974f --- /dev/null +++ b/.agents/skills/nestjs-best-practices/rules/db-use-transactions.md @@ -0,0 +1,140 @@ +--- +title: Use Transactions for Multi-Step Operations +impact: HIGH +impactDescription: Ensures data consistency in multi-step operations +tags: database, transactions, typeorm, consistency +--- + +## Use Transactions for Multi-Step Operations + +When multiple database operations must succeed or fail together, wrap them in a transaction. This prevents partial updates that leave your data in an inconsistent state. Use TypeORM's transaction APIs or the DataSource query runner for complex scenarios. + +**Incorrect (multiple saves without transaction):** + +```typescript +// Multiple saves without transaction +@Injectable() +export class OrdersService { + async createOrder(userId: string, items: OrderItem[]): Promise { + // If any step fails, data is inconsistent + const order = await this.orderRepo.save({ userId, status: 'pending' }); + + for (const item of items) { + await this.orderItemRepo.save({ orderId: order.id, ...item }); + await this.inventoryRepo.decrement({ productId: item.productId }, 'stock', item.quantity); + } + + await this.paymentService.charge(order.id); + // If payment fails, order and inventory are already modified! + + return order; + } +} +``` + +**Correct (use DataSource.transaction for automatic rollback):** + +```typescript +// Use DataSource.transaction() for automatic rollback +@Injectable() +export class OrdersService { + constructor(private dataSource: DataSource) {} + + async createOrder(userId: string, items: OrderItem[]): Promise { + return this.dataSource.transaction(async (manager) => { + // All operations use the same transactional manager + const order = await manager.save(Order, { userId, status: 'pending' }); + + for (const item of items) { + await manager.save(OrderItem, { orderId: order.id, ...item }); + await manager.decrement( + Inventory, + { productId: item.productId }, + 'stock', + item.quantity, + ); + } + + // If this throws, everything rolls back + await this.paymentService.chargeWithManager(manager, order.id); + + return order; + }); + } +} + +// QueryRunner for manual transaction control +@Injectable() +export class TransferService { + constructor(private dataSource: DataSource) {} + + async transfer(fromId: string, toId: string, amount: number): Promise { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + // Debit source account + await queryRunner.manager.decrement( + Account, + { id: fromId }, + 'balance', + amount, + ); + + // Verify sufficient funds + const source = await queryRunner.manager.findOne(Account, { + where: { id: fromId }, + }); + if (source.balance < 0) { + throw new BadRequestException('Insufficient funds'); + } + + // Credit destination account + await queryRunner.manager.increment( + Account, + { id: toId }, + 'balance', + amount, + ); + + // Log the transaction + await queryRunner.manager.save(TransactionLog, { + fromId, + toId, + amount, + timestamp: new Date(), + }); + + await queryRunner.commitTransaction(); + } catch (error) { + await queryRunner.rollbackTransaction(); + throw error; + } finally { + await queryRunner.release(); + } + } +} + +// Repository method with transaction support +@Injectable() +export class UsersRepository { + constructor( + @InjectRepository(User) private repo: Repository, + private dataSource: DataSource, + ) {} + + async createWithProfile( + userData: CreateUserDto, + profileData: CreateProfileDto, + ): Promise { + return this.dataSource.transaction(async (manager) => { + const user = await manager.save(User, userData); + await manager.save(Profile, { ...profileData, userId: user.id }); + return user; + }); + } +} +``` + +Reference: [TypeORM Transactions](https://typeorm.io/transactions) diff --git a/.agents/skills/nestjs-best-practices/rules/devops-graceful-shutdown.md b/.agents/skills/nestjs-best-practices/rules/devops-graceful-shutdown.md new file mode 100644 index 000000000..b3b18f820 --- /dev/null +++ b/.agents/skills/nestjs-best-practices/rules/devops-graceful-shutdown.md @@ -0,0 +1,222 @@ +--- +title: Implement Graceful Shutdown +impact: MEDIUM-HIGH +impactDescription: Proper shutdown handling ensures zero-downtime deployments +tags: devops, graceful-shutdown, lifecycle, kubernetes +--- + +## Implement Graceful Shutdown + +Handle SIGTERM and SIGINT signals to gracefully shutdown your NestJS application. Stop accepting new requests, wait for in-flight requests to complete, close database connections, and clean up resources. This prevents data loss and connection errors during deployments. + +**Incorrect (ignoring shutdown signals):** + +```typescript +// Ignore shutdown signals +async function bootstrap() { + const app = await NestFactory.create(AppModule); + await app.listen(3000); + // App crashes immediately on SIGTERM + // In-flight requests fail + // Database connections are abruptly closed +} + +// Long-running tasks without cancellation +@Injectable() +export class ProcessingService { + async processLargeFile(file: File): Promise { + // No way to interrupt this during shutdown + for (let i = 0; i < file.chunks.length; i++) { + await this.processChunk(file.chunks[i]); + // May run for minutes, blocking shutdown + } + } +} +``` + +**Correct (enable shutdown hooks and handle cleanup):** + +```typescript +// Enable shutdown hooks in main.ts +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + // Enable shutdown hooks + app.enableShutdownHooks(); + + // Optional: Add timeout for forced shutdown + const server = await app.listen(3000); + server.setTimeout(30000); // 30 second timeout + + // Handle graceful shutdown + const signals = ['SIGTERM', 'SIGINT']; + signals.forEach((signal) => { + process.on(signal, async () => { + console.log(`Received ${signal}, starting graceful shutdown...`); + + // Stop accepting new connections + server.close(async () => { + console.log('HTTP server closed'); + await app.close(); + process.exit(0); + }); + + // Force exit after timeout + setTimeout(() => { + console.error('Forced shutdown after timeout'); + process.exit(1); + }, 30000); + }); + }); +} + +// Lifecycle hooks for cleanup +@Injectable() +export class DatabaseService implements OnApplicationShutdown { + private readonly connections: Connection[] = []; + + async onApplicationShutdown(signal?: string): Promise { + console.log(`Database service shutting down on ${signal}`); + + // Close all connections gracefully + await Promise.all( + this.connections.map((conn) => conn.close()), + ); + + console.log('All database connections closed'); + } +} + +// Queue processor with graceful shutdown +@Injectable() +export class QueueService implements OnApplicationShutdown, OnModuleDestroy { + private isShuttingDown = false; + + onModuleDestroy(): void { + this.isShuttingDown = true; + } + + async onApplicationShutdown(): Promise { + // Wait for current jobs to complete + await this.queue.close(); + } + + async processJob(job: Job): Promise { + if (this.isShuttingDown) { + throw new Error('Service is shutting down'); + } + await this.doWork(job); + } +} + +// WebSocket gateway cleanup +@WebSocketGateway() +export class EventsGateway implements OnApplicationShutdown { + @WebSocketServer() + server: Server; + + async onApplicationShutdown(): Promise { + // Notify all connected clients + this.server.emit('shutdown', { message: 'Server is shutting down' }); + + // Close all connections + this.server.disconnectSockets(); + } +} + +// Health check integration +@Injectable() +export class ShutdownService { + private isShuttingDown = false; + + startShutdown(): void { + this.isShuttingDown = true; + } + + isShutdown(): boolean { + return this.isShuttingDown; + } +} + +@Controller('health') +export class HealthController { + constructor(private shutdownService: ShutdownService) {} + + @Get('ready') + @HealthCheck() + readiness(): Promise { + // Return 503 during shutdown - k8s stops sending traffic + if (this.shutdownService.isShutdown()) { + throw new ServiceUnavailableException('Shutting down'); + } + + return this.health.check([ + () => this.db.pingCheck('database'), + ]); + } +} + +// Integrate with shutdown +@Injectable() +export class AppShutdownService implements OnApplicationShutdown { + constructor(private shutdownService: ShutdownService) {} + + async onApplicationShutdown(): Promise { + // Mark as unhealthy first + this.shutdownService.startShutdown(); + + // Wait for k8s to update endpoints + await this.sleep(5000); + + // Then proceed with cleanup + } +} + +// Request tracking for in-flight requests +@Injectable() +export class RequestTracker implements NestMiddleware, OnApplicationShutdown { + private activeRequests = 0; + private isShuttingDown = false; + private shutdownPromise: Promise | null = null; + private resolveShutdown: (() => void) | null = null; + + use(req: Request, res: Response, next: NextFunction): void { + if (this.isShuttingDown) { + res.status(503).send('Service Unavailable'); + return; + } + + this.activeRequests++; + + res.on('finish', () => { + this.activeRequests--; + if (this.isShuttingDown && this.activeRequests === 0 && this.resolveShutdown) { + this.resolveShutdown(); + } + }); + + next(); + } + + async onApplicationShutdown(): Promise { + this.isShuttingDown = true; + + if (this.activeRequests > 0) { + console.log(`Waiting for ${this.activeRequests} requests to complete`); + this.shutdownPromise = new Promise((resolve) => { + this.resolveShutdown = resolve; + }); + + // Wait with timeout + await Promise.race([ + this.shutdownPromise, + new Promise((resolve) => setTimeout(resolve, 30000)), + ]); + } + + console.log('All requests completed'); + } +} +``` + +Reference: [NestJS Lifecycle Events](https://docs.nestjs.com/fundamentals/lifecycle-events) diff --git a/.agents/skills/nestjs-best-practices/rules/devops-use-config-module.md b/.agents/skills/nestjs-best-practices/rules/devops-use-config-module.md new file mode 100644 index 000000000..a9483dedf --- /dev/null +++ b/.agents/skills/nestjs-best-practices/rules/devops-use-config-module.md @@ -0,0 +1,167 @@ +--- +title: Use ConfigModule for Environment Configuration +impact: LOW-MEDIUM +impactDescription: Proper configuration prevents deployment failures +tags: devops, configuration, environment, validation +--- + +## Use ConfigModule for Environment Configuration + +Use `@nestjs/config` for environment-based configuration. Validate configuration at startup to fail fast on misconfigurations. Use namespaced configuration for organization and type safety. + +**Incorrect (accessing process.env directly):** + +```typescript +// Access process.env directly +@Injectable() +export class DatabaseService { + constructor() { + // No validation, can fail at runtime + this.connection = new Pool({ + host: process.env.DB_HOST, + port: parseInt(process.env.DB_PORT), // NaN if missing + password: process.env.DB_PASSWORD, // undefined if missing + }); + } +} + +// Scattered env access +@Injectable() +export class EmailService { + sendEmail() { + // Different services access env differently + const apiKey = process.env.SENDGRID_API_KEY || 'default'; + // Typos go unnoticed: process.env.SENDGRID_API_KY + } +} +``` + +**Correct (use @nestjs/config with validation):** + +```typescript +// Setup validated configuration +import { ConfigModule, ConfigService, registerAs } from '@nestjs/config'; +import * as Joi from 'joi'; + +// config/database.config.ts +export const databaseConfig = registerAs('database', () => ({ + host: process.env.DB_HOST, + port: parseInt(process.env.DB_PORT, 10), + username: process.env.DB_USERNAME, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, +})); + +// config/app.config.ts +export const appConfig = registerAs('app', () => ({ + port: parseInt(process.env.PORT, 10) || 3000, + environment: process.env.NODE_ENV || 'development', + apiPrefix: process.env.API_PREFIX || 'api', +})); + +// config/validation.schema.ts +export const validationSchema = Joi.object({ + NODE_ENV: Joi.string() + .valid('development', 'production', 'test') + .default('development'), + PORT: Joi.number().default(3000), + DB_HOST: Joi.string().required(), + DB_PORT: Joi.number().default(5432), + DB_USERNAME: Joi.string().required(), + DB_PASSWORD: Joi.string().required(), + DB_NAME: Joi.string().required(), + JWT_SECRET: Joi.string().min(32).required(), + REDIS_URL: Joi.string().uri().required(), +}); + +// app.module.ts +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, // Available everywhere without importing + load: [databaseConfig, appConfig], + validationSchema, + validationOptions: { + abortEarly: true, // Stop on first error + allowUnknown: true, // Allow other env vars + }, + }), + TypeOrmModule.forRootAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + type: 'postgres', + host: config.get('database.host'), + port: config.get('database.port'), + username: config.get('database.username'), + password: config.get('database.password'), + database: config.get('database.database'), + autoLoadEntities: true, + }), + }), + ], +}) +export class AppModule {} + +// Type-safe configuration access +export interface AppConfig { + port: number; + environment: 'development' | 'production' | 'test'; + apiPrefix: string; +} + +export interface DatabaseConfig { + host: string; + port: number; + username: string; + password: string; + database: string; +} + +// Type-safe access +@Injectable() +export class AppService { + constructor(private config: ConfigService) {} + + getPort(): number { + // Type-safe with generic + return this.config.get('app.port'); + } + + getDatabaseConfig(): DatabaseConfig { + return this.config.get('database'); + } +} + +// Inject namespaced config directly +@Injectable() +export class DatabaseService { + constructor( + @Inject(databaseConfig.KEY) + private dbConfig: ConfigType, + ) { + // Full type inference! + const host = this.dbConfig.host; // string + const port = this.dbConfig.port; // number + } +} + +// Environment files support +ConfigModule.forRoot({ + envFilePath: [ + `.env.${process.env.NODE_ENV}.local`, + `.env.${process.env.NODE_ENV}`, + '.env.local', + '.env', + ], +}); + +// .env.development +// DB_HOST=localhost +// DB_PORT=5432 + +// .env.production +// DB_HOST=prod-db.example.com +// DB_PORT=5432 +``` + +Reference: [NestJS Configuration](https://docs.nestjs.com/techniques/configuration) diff --git a/.agents/skills/nestjs-best-practices/rules/devops-use-logging.md b/.agents/skills/nestjs-best-practices/rules/devops-use-logging.md new file mode 100644 index 000000000..5fb0162bd --- /dev/null +++ b/.agents/skills/nestjs-best-practices/rules/devops-use-logging.md @@ -0,0 +1,232 @@ +--- +title: Use Structured Logging +impact: MEDIUM-HIGH +impactDescription: Structured logging enables effective debugging and monitoring +tags: devops, logging, structured-logs, pino +--- + +## Use Structured Logging + +Use NestJS Logger with structured JSON output in production. Include contextual information (request ID, user ID, operation) to trace requests across services. Avoid console.log and implement proper log levels. + +**Incorrect (using console.log in production):** + +```typescript +// Use console.log in production +@Injectable() +export class UsersService { + async createUser(dto: CreateUserDto): Promise { + console.log('Creating user:', dto); + // Not structured, no levels, lost in production logs + + try { + const user = await this.repo.save(dto); + console.log('User created:', user.id); + return user; + } catch (error) { + console.log('Error:', error); // Using log for errors + throw error; + } + } +} + +// Log sensitive data +console.log('Login attempt:', { email, password }); // SECURITY RISK! + +// Inconsistent log format +logger.log('User ' + userId + ' created at ' + new Date()); +// Hard to parse, no structure +``` + +**Correct (use structured logging with context):** + +```typescript +// Configure logger in main.ts +async function bootstrap() { + const app = await NestFactory.create(AppModule, { + logger: + process.env.NODE_ENV === 'production' + ? ['error', 'warn', 'log'] + : ['error', 'warn', 'log', 'debug', 'verbose'], + }); +} + +// Use NestJS Logger with context +@Injectable() +export class UsersService { + private readonly logger = new Logger(UsersService.name); + + async createUser(dto: CreateUserDto): Promise { + this.logger.log('Creating user', { email: dto.email }); + + try { + const user = await this.repo.save(dto); + this.logger.log('User created', { userId: user.id }); + return user; + } catch (error) { + this.logger.error('Failed to create user', error.stack, { + email: dto.email, + }); + throw error; + } + } +} + +// Custom logger for JSON output +@Injectable() +export class JsonLogger implements LoggerService { + log(message: string, context?: object): void { + console.log( + JSON.stringify({ + level: 'info', + timestamp: new Date().toISOString(), + message, + ...context, + }), + ); + } + + error(message: string, trace?: string, context?: object): void { + console.error( + JSON.stringify({ + level: 'error', + timestamp: new Date().toISOString(), + message, + trace, + ...context, + }), + ); + } + + warn(message: string, context?: object): void { + console.warn( + JSON.stringify({ + level: 'warn', + timestamp: new Date().toISOString(), + message, + ...context, + }), + ); + } + + debug(message: string, context?: object): void { + console.debug( + JSON.stringify({ + level: 'debug', + timestamp: new Date().toISOString(), + message, + ...context, + }), + ); + } +} + +// Request context logging with ClsModule +import { ClsModule, ClsService } from 'nestjs-cls'; + +@Module({ + imports: [ + ClsModule.forRoot({ + global: true, + middleware: { + mount: true, + generateId: true, + }, + }), + ], +}) +export class AppModule {} + +// Middleware to set request context +@Injectable() +export class RequestContextMiddleware implements NestMiddleware { + constructor(private cls: ClsService) {} + + use(req: Request, res: Response, next: NextFunction): void { + const requestId = req.headers['x-request-id'] || randomUUID(); + this.cls.set('requestId', requestId); + this.cls.set('userId', req.user?.id); + + res.setHeader('x-request-id', requestId); + next(); + } +} + +// Logger that includes request context +@Injectable() +export class ContextLogger { + constructor(private cls: ClsService) {} + + log(message: string, data?: object): void { + console.log( + JSON.stringify({ + level: 'info', + timestamp: new Date().toISOString(), + requestId: this.cls.get('requestId'), + userId: this.cls.get('userId'), + message, + ...data, + }), + ); + } + + error(message: string, error: Error, data?: object): void { + console.error( + JSON.stringify({ + level: 'error', + timestamp: new Date().toISOString(), + requestId: this.cls.get('requestId'), + userId: this.cls.get('userId'), + message, + error: error.message, + stack: error.stack, + ...data, + }), + ); + } +} + +// Pino integration for high-performance logging +import { LoggerModule } from 'nestjs-pino'; + +@Module({ + imports: [ + LoggerModule.forRoot({ + pinoHttp: { + level: process.env.NODE_ENV === 'production' ? 'info' : 'debug', + transport: + process.env.NODE_ENV !== 'production' + ? { target: 'pino-pretty' } + : undefined, + redact: ['req.headers.authorization', 'req.body.password'], + serializers: { + req: (req) => ({ + method: req.method, + url: req.url, + query: req.query, + }), + res: (res) => ({ + statusCode: res.statusCode, + }), + }, + }, + }), + ], +}) +export class AppModule {} + +// Usage with Pino +@Injectable() +export class UsersService { + constructor(private logger: PinoLogger) { + this.logger.setContext(UsersService.name); + } + + async findOne(id: string): Promise { + this.logger.info({ userId: id }, 'Finding user'); + // Pino uses first arg for data, second for message + } +} +``` + +Reference: [NestJS Logger](https://docs.nestjs.com/techniques/logger) diff --git a/.agents/skills/nestjs-best-practices/rules/di-avoid-service-locator.md b/.agents/skills/nestjs-best-practices/rules/di-avoid-service-locator.md new file mode 100644 index 000000000..d4c04b479 --- /dev/null +++ b/.agents/skills/nestjs-best-practices/rules/di-avoid-service-locator.md @@ -0,0 +1,104 @@ +--- +title: Avoid Service Locator Anti-Pattern +impact: HIGH +impactDescription: Hides dependencies and breaks testability +tags: dependency-injection, anti-patterns, testing +--- + +## Avoid Service Locator Anti-Pattern + +Avoid using `ModuleRef.get()` or global containers to resolve dependencies at runtime. This hides dependencies, makes code harder to test, and breaks the benefits of dependency injection. Use constructor injection instead. + +**Incorrect (service locator anti-pattern):** + +```typescript +// Use ModuleRef to get dependencies dynamically +@Injectable() +export class OrdersService { + constructor(private moduleRef: ModuleRef) {} + + async createOrder(dto: CreateOrderDto): Promise { + // Dependencies are hidden - not visible in constructor + const usersService = this.moduleRef.get(UsersService); + const inventoryService = this.moduleRef.get(InventoryService); + const paymentService = this.moduleRef.get(PaymentService); + + const user = await usersService.findOne(dto.userId); + // ... rest of logic + } +} + +// Global singleton container +class ServiceContainer { + private static instance: ServiceContainer; + private services = new Map(); + + static getInstance(): ServiceContainer { + if (!this.instance) { + this.instance = new ServiceContainer(); + } + return this.instance; + } + + get(key: string): T { + return this.services.get(key); + } +} +``` + +**Correct (constructor injection with explicit dependencies):** + +```typescript +// Use constructor injection - dependencies are explicit +@Injectable() +export class OrdersService { + constructor( + private usersService: UsersService, + private inventoryService: InventoryService, + private paymentService: PaymentService, + ) {} + + async createOrder(dto: CreateOrderDto): Promise { + const user = await this.usersService.findOne(dto.userId); + const inventory = await this.inventoryService.check(dto.items); + // Dependencies are clear and testable + } +} + +// Easy to test with mocks +describe('OrdersService', () => { + let service: OrdersService; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + providers: [ + OrdersService, + { provide: UsersService, useValue: mockUsersService }, + { provide: InventoryService, useValue: mockInventoryService }, + { provide: PaymentService, useValue: mockPaymentService }, + ], + }).compile(); + + service = module.get(OrdersService); + }); +}); + +// VALID: Factory pattern for dynamic instantiation +@Injectable() +export class HandlerFactory { + constructor(private moduleRef: ModuleRef) {} + + getHandler(type: string): Handler { + switch (type) { + case 'email': + return this.moduleRef.get(EmailHandler); + case 'sms': + return this.moduleRef.get(SmsHandler); + default: + return this.moduleRef.get(DefaultHandler); + } + } +} +``` + +Reference: [NestJS Module Reference](https://docs.nestjs.com/fundamentals/module-ref) diff --git a/.agents/skills/nestjs-best-practices/rules/di-interface-segregation.md b/.agents/skills/nestjs-best-practices/rules/di-interface-segregation.md new file mode 100644 index 000000000..8c96cd814 --- /dev/null +++ b/.agents/skills/nestjs-best-practices/rules/di-interface-segregation.md @@ -0,0 +1,165 @@ +--- +title: Apply Interface Segregation Principle +impact: HIGH +impactDescription: Reduces coupling and improves testability by 30-50% +tags: dependency-injection, interfaces, solid, isp +--- + +## Apply Interface Segregation Principle + +Clients should not be forced to depend on interfaces they don't use. In NestJS, this means keeping interfaces small and focused on specific capabilities rather than creating "fat" interfaces that bundle unrelated methods. When a service only needs to send emails, it shouldn't depend on an interface that also includes SMS, push notifications, and logging. Split large interfaces into role-based ones. + +**Incorrect (fat interface forcing unused dependencies):** + +```typescript +// Fat interface - forces all consumers to depend on everything +interface NotificationService { + sendEmail(to: string, subject: string, body: string): Promise; + sendSms(phone: string, message: string): Promise; + sendPush(userId: string, notification: PushPayload): Promise; + sendSlack(channel: string, message: string): Promise; + logNotification(type: string, payload: any): Promise; + getDeliveryStatus(id: string): Promise; + retryFailed(id: string): Promise; + scheduleNotification(dto: ScheduleDto): Promise; +} + +// Consumer only needs email, but must mock everything for tests +@Injectable() +export class OrdersService { + constructor( + private notifications: NotificationService, // Depends on 8 methods, uses 1 + ) {} + + async confirmOrder(order: Order): Promise { + await this.notifications.sendEmail( + order.customer.email, + 'Order Confirmed', + `Your order ${order.id} has been confirmed.`, + ); + } +} + +// Testing is painful - must mock unused methods +const mockNotificationService = { + sendEmail: jest.fn(), + sendSms: jest.fn(), // Never used, but required + sendPush: jest.fn(), // Never used, but required + sendSlack: jest.fn(), // Never used, but required + logNotification: jest.fn(), // Never used, but required + getDeliveryStatus: jest.fn(), // Never used, but required + retryFailed: jest.fn(), // Never used, but required + scheduleNotification: jest.fn(), // Never used, but required +}; +``` + +**Correct (segregated interfaces by capability):** + +```typescript +// Segregated interfaces - each focused on one capability +interface EmailSender { + sendEmail(to: string, subject: string, body: string): Promise; +} + +interface SmsSender { + sendSms(phone: string, message: string): Promise; +} + +interface PushSender { + sendPush(userId: string, notification: PushPayload): Promise; +} + +interface NotificationLogger { + logNotification(type: string, payload: any): Promise; +} + +interface NotificationScheduler { + scheduleNotification(dto: ScheduleDto): Promise; +} + +// Implementation can implement multiple interfaces +@Injectable() +export class NotificationService implements EmailSender, SmsSender, PushSender { + async sendEmail(to: string, subject: string, body: string): Promise { + // Email implementation + } + + async sendSms(phone: string, message: string): Promise { + // SMS implementation + } + + async sendPush(userId: string, notification: PushPayload): Promise { + // Push implementation + } +} + +// Or separate implementations +@Injectable() +export class SendGridEmailService implements EmailSender { + async sendEmail(to: string, subject: string, body: string): Promise { + // SendGrid-specific implementation + } +} + +// Consumer depends only on what it needs +@Injectable() +export class OrdersService { + constructor( + @Inject(EMAIL_SENDER) private emailSender: EmailSender, // Minimal dependency + ) {} + + async confirmOrder(order: Order): Promise { + await this.emailSender.sendEmail( + order.customer.email, + 'Order Confirmed', + `Your order ${order.id} has been confirmed.`, + ); + } +} + +// Testing is simple - only mock what's used +const mockEmailSender: EmailSender = { + sendEmail: jest.fn(), +}; + +// Module registration with tokens +export const EMAIL_SENDER = Symbol('EMAIL_SENDER'); +export const SMS_SENDER = Symbol('SMS_SENDER'); + +@Module({ + providers: [ + { provide: EMAIL_SENDER, useClass: SendGridEmailService }, + { provide: SMS_SENDER, useClass: TwilioSmsService }, + ], + exports: [EMAIL_SENDER, SMS_SENDER], +}) +export class NotificationModule {} +``` + +**Combining interfaces when needed:** + +```typescript +// Sometimes a consumer legitimately needs multiple capabilities +interface EmailAndSmsSender extends EmailSender, SmsSender {} + +// Or use intersection types +type MultiChannelSender = EmailSender & SmsSender & PushSender; + +// Consumer that genuinely needs multiple channels +@Injectable() +export class AlertService { + constructor( + @Inject(MULTI_CHANNEL_SENDER) + private sender: EmailSender & SmsSender, + ) {} + + async sendCriticalAlert(user: User, message: string): Promise { + await Promise.all([ + this.sender.sendEmail(user.email, 'Critical Alert', message), + this.sender.sendSms(user.phone, message), + ]); + } +} +``` + +Reference: [Interface Segregation Principle](https://en.wikipedia.org/wiki/Interface_segregation_principle) diff --git a/.agents/skills/nestjs-best-practices/rules/di-liskov-substitution.md b/.agents/skills/nestjs-best-practices/rules/di-liskov-substitution.md new file mode 100644 index 000000000..d6701178d --- /dev/null +++ b/.agents/skills/nestjs-best-practices/rules/di-liskov-substitution.md @@ -0,0 +1,221 @@ +--- +title: Honor Liskov Substitution Principle +impact: HIGH +impactDescription: Ensures implementations are truly interchangeable without breaking callers +tags: dependency-injection, inheritance, solid, lsp +--- + +## Honor Liskov Substitution Principle + +Subtypes must be substitutable for their base types without altering program correctness. In NestJS with dependency injection, this means any implementation of an interface or abstract class must honor the contract completely. A mock payment service used in tests must behave like a real payment service (return similar shapes, handle errors the same way). Violating LSP causes subtle bugs when swapping implementations. + +**Incorrect (implementation violates the contract):** + +```typescript +// Base interface with clear contract +interface PaymentGateway { + /** + * Charges the specified amount. + * @returns PaymentResult on success + * @throws PaymentFailedException on payment failure + */ + charge(amount: number, currency: string): Promise; +} + +// Production implementation - follows the contract +@Injectable() +export class StripeService implements PaymentGateway { + async charge(amount: number, currency: string): Promise { + const response = await this.stripe.charges.create({ amount, currency }); + return { success: true, transactionId: response.id, amount }; + } +} + +// Mock that violates LSP - different behavior! +@Injectable() +export class MockPaymentService implements PaymentGateway { + async charge(amount: number, currency: string): Promise { + // VIOLATION 1: Throws for valid input (contract says return PaymentResult) + if (amount > 1000) { + throw new Error('Mock does not support large amounts'); + } + + // VIOLATION 2: Returns null instead of PaymentResult + if (currency !== 'USD') { + return null as any; // Real service would convert or reject properly + } + + // VIOLATION 3: Missing required field + return { success: true } as PaymentResult; // Missing transactionId! + } +} + +// Consumer trusts the contract +@Injectable() +export class OrdersService { + constructor(@Inject(PAYMENT_GATEWAY) private payment: PaymentGateway) {} + + async checkout(order: Order): Promise { + const result = await this.payment.charge(order.total, order.currency); + // These fail with MockPaymentService: + await this.saveTransaction(result.transactionId); // undefined! + await this.sendReceipt(result); // might be null! + } +} +``` + +**Correct (implementations honor the contract):** + +```typescript +// Well-defined interface with documented behavior +interface PaymentGateway { + /** + * Charges the specified amount. + * @param amount - Amount in smallest currency unit (cents) + * @param currency - ISO 4217 currency code + * @returns PaymentResult with transactionId, success status, and amount + * @throws PaymentFailedException if charge is declined + * @throws InvalidCurrencyException if currency is not supported + */ + charge(amount: number, currency: string): Promise; + + /** + * Refunds a previous charge. + * @throws TransactionNotFoundException if transactionId is invalid + */ + refund(transactionId: string, amount?: number): Promise; +} + +// Production implementation +@Injectable() +export class StripeService implements PaymentGateway { + async charge(amount: number, currency: string): Promise { + try { + const response = await this.stripe.charges.create({ amount, currency }); + return { + success: true, + transactionId: response.id, + amount: response.amount, + }; + } catch (error) { + if (error.type === 'card_error') { + throw new PaymentFailedException(error.message); + } + throw error; + } + } + + async refund(transactionId: string, amount?: number): Promise { + // Implementation... + } +} + +// Mock that honors LSP - same contract, same behavior shape +@Injectable() +export class MockPaymentService implements PaymentGateway { + private transactions = new Map(); + + async charge(amount: number, currency: string): Promise { + // Honor the contract: validate currency like real service would + if (!['USD', 'EUR', 'GBP'].includes(currency)) { + throw new InvalidCurrencyException(`Unsupported currency: ${currency}`); + } + + // Simulate decline for specific test scenarios + if (amount === 99999) { + throw new PaymentFailedException('Card declined (test scenario)'); + } + + // Return same shape as production + const result: PaymentResult = { + success: true, + transactionId: `mock_${Date.now()}_${Math.random().toString(36)}`, + amount, + }; + + this.transactions.set(result.transactionId, result); + return result; + } + + async refund(transactionId: string, amount?: number): Promise { + // Honor the contract: throw if transaction not found + if (!this.transactions.has(transactionId)) { + throw new TransactionNotFoundException(transactionId); + } + + return { + success: true, + refundId: `refund_${transactionId}`, + amount: amount ?? this.transactions.get(transactionId)!.amount, + }; + } +} + +// Consumer can swap implementations safely +@Injectable() +export class OrdersService { + constructor(@Inject(PAYMENT_GATEWAY) private payment: PaymentGateway) {} + + async checkout(order: Order): Promise { + try { + const result = await this.payment.charge(order.total, order.currency); + // Works with both StripeService and MockPaymentService + order.transactionId = result.transactionId; + order.status = 'paid'; + return order; + } catch (error) { + if (error instanceof PaymentFailedException) { + order.status = 'payment_failed'; + return order; + } + throw error; + } + } +} +``` + +**Testing LSP compliance:** + +```typescript +// Shared test suite that any implementation must pass +function testPaymentGatewayContract( + createGateway: () => PaymentGateway, +) { + describe('PaymentGateway contract', () => { + let gateway: PaymentGateway; + + beforeEach(() => { + gateway = createGateway(); + }); + + it('returns PaymentResult with all required fields', async () => { + const result = await gateway.charge(1000, 'USD'); + expect(result).toHaveProperty('success'); + expect(result).toHaveProperty('transactionId'); + expect(result).toHaveProperty('amount'); + expect(typeof result.transactionId).toBe('string'); + }); + + it('throws InvalidCurrencyException for unsupported currency', async () => { + await expect(gateway.charge(1000, 'INVALID')) + .rejects.toThrow(InvalidCurrencyException); + }); + + it('throws TransactionNotFoundException for invalid refund', async () => { + await expect(gateway.refund('nonexistent')) + .rejects.toThrow(TransactionNotFoundException); + }); + }); +} + +// Run against all implementations +describe('StripeService', () => { + testPaymentGatewayContract(() => new StripeService(mockStripeClient)); +}); + +describe('MockPaymentService', () => { + testPaymentGatewayContract(() => new MockPaymentService()); +}); +``` + +Reference: [Liskov Substitution Principle](https://en.wikipedia.org/wiki/Liskov_substitution_principle) diff --git a/.agents/skills/nestjs-best-practices/rules/di-prefer-constructor-injection.md b/.agents/skills/nestjs-best-practices/rules/di-prefer-constructor-injection.md new file mode 100644 index 000000000..c4a3274c5 --- /dev/null +++ b/.agents/skills/nestjs-best-practices/rules/di-prefer-constructor-injection.md @@ -0,0 +1,86 @@ +--- +title: Prefer Constructor Injection +impact: CRITICAL +impactDescription: Required for proper DI and testing +tags: dependency-injection, constructor, testing +--- + +## Prefer Constructor Injection + +Always use constructor injection over property injection. Constructor injection makes dependencies explicit, enables TypeScript type checking, ensures dependencies are available when the class is instantiated, and improves testability. This is required for proper DI, testing, and TypeScript support. + +**Incorrect (property injection with hidden dependencies):** + +```typescript +// Property injection - avoid unless necessary +@Injectable() +export class UsersService { + @Inject() + private userRepo: UserRepository; // Hidden dependency + + @Inject('CONFIG') + private config: ConfigType; // Also hidden + + async findAll() { + return this.userRepo.find(); + } +} + +// Problems: +// 1. Dependencies not visible in constructor +// 2. Service can be instantiated without dependencies in tests +// 3. TypeScript can't enforce dependency types at instantiation +``` + +**Correct (constructor injection with explicit dependencies):** + +```typescript +// Constructor injection - explicit and testable +@Injectable() +export class UsersService { + constructor( + private readonly userRepo: UserRepository, + @Inject('CONFIG') private readonly config: ConfigType, + ) {} + + async findAll(): Promise { + return this.userRepo.find(); + } +} + +// Testing is straightforward +describe('UsersService', () => { + let service: UsersService; + let mockRepo: jest.Mocked; + + beforeEach(() => { + mockRepo = { + find: jest.fn(), + save: jest.fn(), + } as any; + + service = new UsersService(mockRepo, { dbUrl: 'test' }); + }); + + it('should find all users', async () => { + mockRepo.find.mockResolvedValue([{ id: '1', name: 'Test' }]); + const result = await service.findAll(); + expect(result).toHaveLength(1); + }); +}); + +// Only use property injection for optional dependencies +@Injectable() +export class LoggingService { + @Optional() + @Inject('ANALYTICS') + private analytics?: AnalyticsService; + + log(message: string) { + console.log(message); + this.analytics?.track('log', message); // Optional enhancement + } +} +``` + +Reference: [NestJS Providers](https://docs.nestjs.com/providers) diff --git a/.agents/skills/nestjs-best-practices/rules/di-scope-awareness.md b/.agents/skills/nestjs-best-practices/rules/di-scope-awareness.md new file mode 100644 index 000000000..a6c77efbd --- /dev/null +++ b/.agents/skills/nestjs-best-practices/rules/di-scope-awareness.md @@ -0,0 +1,94 @@ +--- +title: Understand Provider Scopes +impact: CRITICAL +impactDescription: Prevents data leaks and performance issues +tags: dependency-injection, scopes, request-context +--- + +## Understand Provider Scopes + +NestJS has three provider scopes: DEFAULT (singleton), REQUEST (per-request instance), and TRANSIENT (new instance for each injection). Most providers should be singletons. Request-scoped providers have performance implications as they bubble up through the dependency tree. Understanding scopes prevents memory leaks and incorrect data sharing. + +**Incorrect (wrong scope usage):** + +```typescript +// Request-scoped when not needed (performance hit) +@Injectable({ scope: Scope.REQUEST }) +export class UsersService { + // This creates a new instance for EVERY request + // All dependencies also become request-scoped + async findAll() { + return this.userRepo.find(); + } +} + +// Singleton with mutable request state +@Injectable() // Default: singleton +export class RequestContextService { + private userId: string; // DANGER: Shared across all requests! + + setUser(userId: string) { + this.userId = userId; // Overwrites for all concurrent requests + } + + getUser() { + return this.userId; // Returns wrong user! + } +} +``` + +**Correct (appropriate scope for each use case):** + +```typescript +// Singleton for stateless services (default, most common) +@Injectable() +export class UsersService { + constructor(private readonly userRepo: UserRepository) {} + + async findById(id: string): Promise { + return this.userRepo.findOne({ where: { id } }); + } +} + +// Request-scoped ONLY when you need request context +@Injectable({ scope: Scope.REQUEST }) +export class RequestContextService { + private userId: string; + + setUser(userId: string) { + this.userId = userId; + } + + getUser(): string { + return this.userId; + } +} + +// Better: Use NestJS built-in request context +import { REQUEST } from '@nestjs/core'; +import { Request } from 'express'; + +@Injectable({ scope: Scope.REQUEST }) +export class AuditService { + constructor(@Inject(REQUEST) private request: Request) {} + + log(action: string) { + console.log(`User ${this.request.user?.id} performed ${action}`); + } +} + +// Best: Use ClsModule for async context (no scope bubble-up) +import { ClsService } from 'nestjs-cls'; + +@Injectable() // Stays singleton! +export class AuditService { + constructor(private cls: ClsService) {} + + log(action: string) { + const userId = this.cls.get('userId'); + console.log(`User ${userId} performed ${action}`); + } +} +``` + +Reference: [NestJS Injection Scopes](https://docs.nestjs.com/fundamentals/injection-scopes) diff --git a/.agents/skills/nestjs-best-practices/rules/di-use-interfaces-tokens.md b/.agents/skills/nestjs-best-practices/rules/di-use-interfaces-tokens.md new file mode 100644 index 000000000..f5376a17f --- /dev/null +++ b/.agents/skills/nestjs-best-practices/rules/di-use-interfaces-tokens.md @@ -0,0 +1,101 @@ +--- +title: Use Injection Tokens for Interfaces +impact: HIGH +impactDescription: Enables interface-based DI at runtime +tags: dependency-injection, tokens, interfaces +--- + +## Use Injection Tokens for Interfaces + +TypeScript interfaces are erased at compile time and can't be used as injection tokens. Use string tokens, symbols, or abstract classes when you want to inject implementations of interfaces. This enables swapping implementations for testing or different environments. + +**Incorrect (interface can't be used as token):** + +```typescript +// Interface can't be used as injection token +interface PaymentGateway { + charge(amount: number): Promise; +} + +@Injectable() +export class StripeService implements PaymentGateway { + charge(amount: number) { /* ... */ } +} + +@Injectable() +export class OrdersService { + // This WON'T work - PaymentGateway doesn't exist at runtime + constructor(private payment: PaymentGateway) {} +} +``` + +**Correct (symbol tokens or abstract classes):** + +```typescript +// Option 1: String/Symbol tokens (most flexible) +export const PAYMENT_GATEWAY = Symbol('PAYMENT_GATEWAY'); + +export interface PaymentGateway { + charge(amount: number): Promise; +} + +@Injectable() +export class StripeService implements PaymentGateway { + async charge(amount: number): Promise { + // Stripe implementation + } +} + +@Injectable() +export class MockPaymentService implements PaymentGateway { + async charge(amount: number): Promise { + return { success: true, id: 'mock-id' }; + } +} + +// Module registration +@Module({ + providers: [ + { + provide: PAYMENT_GATEWAY, + useClass: process.env.NODE_ENV === 'test' + ? MockPaymentService + : StripeService, + }, + ], + exports: [PAYMENT_GATEWAY], +}) +export class PaymentModule {} + +// Injection +@Injectable() +export class OrdersService { + constructor( + @Inject(PAYMENT_GATEWAY) private payment: PaymentGateway, + ) {} + + async createOrder(dto: CreateOrderDto) { + await this.payment.charge(dto.amount); + } +} + +// Option 2: Abstract class (carries runtime type info) +export abstract class PaymentGateway { + abstract charge(amount: number): Promise; +} + +@Injectable() +export class StripeService extends PaymentGateway { + async charge(amount: number): Promise { + // Implementation + } +} + +// No @Inject needed with abstract class +@Injectable() +export class OrdersService { + constructor(private payment: PaymentGateway) {} +} +``` + +Reference: [NestJS Custom Providers](https://docs.nestjs.com/fundamentals/custom-providers) diff --git a/.agents/skills/nestjs-best-practices/rules/error-handle-async-errors.md b/.agents/skills/nestjs-best-practices/rules/error-handle-async-errors.md new file mode 100644 index 000000000..36c3f6af3 --- /dev/null +++ b/.agents/skills/nestjs-best-practices/rules/error-handle-async-errors.md @@ -0,0 +1,125 @@ +--- +title: Handle Async Errors Properly +impact: HIGH +impactDescription: Prevents process crashes from unhandled rejections +tags: error-handling, async, promises +--- + +## Handle Async Errors Properly + +NestJS automatically catches errors from async route handlers, but errors from background tasks, event handlers, and manually created promises can crash your application. Always handle async errors explicitly and use global handlers as a safety net. + +**Incorrect (fire-and-forget without error handling):** + +```typescript +// Fire-and-forget without error handling +@Injectable() +export class UsersService { + async createUser(dto: CreateUserDto): Promise { + const user = await this.repo.save(dto); + + // Fire and forget - if this fails, error is unhandled! + this.emailService.sendWelcome(user.email); + + return user; + } +} + +// Unhandled promise in event handler +@Injectable() +export class OrdersService { + @OnEvent('order.created') + handleOrderCreated(event: OrderCreatedEvent) { + // This returns a promise but it's not awaited! + this.processOrder(event); + // Errors will crash the process + } + + private async processOrder(event: OrderCreatedEvent): Promise { + await this.inventoryService.reserve(event.items); + await this.notificationService.send(event.userId); + } +} + +// Missing try-catch in scheduled tasks +@Cron('0 0 * * *') +async dailyCleanup(): Promise { + await this.cleanupService.run(); + // If this throws, no error handling +} +``` + +**Correct (explicit async error handling):** + +```typescript +// Handle fire-and-forget with explicit catch +@Injectable() +export class UsersService { + private readonly logger = new Logger(UsersService.name); + + async createUser(dto: CreateUserDto): Promise { + const user = await this.repo.save(dto); + + // Explicitly catch and log errors + this.emailService.sendWelcome(user.email).catch((error) => { + this.logger.error('Failed to send welcome email', error.stack); + // Optionally queue for retry + }); + + return user; + } +} + +// Properly handle async event handlers +@Injectable() +export class OrdersService { + private readonly logger = new Logger(OrdersService.name); + + @OnEvent('order.created') + async handleOrderCreated(event: OrderCreatedEvent): Promise { + try { + await this.processOrder(event); + } catch (error) { + this.logger.error('Failed to process order', { event, error }); + // Don't rethrow - would crash the process + await this.deadLetterQueue.add('order.created', event); + } + } +} + +// Safe scheduled tasks +@Injectable() +export class CleanupService { + private readonly logger = new Logger(CleanupService.name); + + @Cron('0 0 * * *') + async dailyCleanup(): Promise { + try { + await this.cleanupService.run(); + this.logger.log('Daily cleanup completed'); + } catch (error) { + this.logger.error('Daily cleanup failed', error.stack); + // Alert or retry logic + } + } +} + +// Global unhandled rejection handler in main.ts +async function bootstrap() { + const app = await NestFactory.create(AppModule); + const logger = new Logger('Bootstrap'); + + process.on('unhandledRejection', (reason, promise) => { + logger.error('Unhandled Rejection at:', promise, 'reason:', reason); + }); + + process.on('uncaughtException', (error) => { + logger.error('Uncaught Exception:', error); + process.exit(1); + }); + + await app.listen(3000); +} +``` + +Reference: [Node.js Unhandled Rejections](https://nodejs.org/api/process.html#event-unhandledrejection) diff --git a/.agents/skills/nestjs-best-practices/rules/error-throw-http-exceptions.md b/.agents/skills/nestjs-best-practices/rules/error-throw-http-exceptions.md new file mode 100644 index 000000000..6aad9fa35 --- /dev/null +++ b/.agents/skills/nestjs-best-practices/rules/error-throw-http-exceptions.md @@ -0,0 +1,114 @@ +--- +title: Throw HTTP Exceptions from Services +impact: HIGH +impactDescription: Keeps controllers thin and simplifies error handling +tags: error-handling, exceptions, services +--- + +## Throw HTTP Exceptions from Services + +It's acceptable (and often preferable) to throw `HttpException` subclasses from services in HTTP applications. This keeps controllers thin and allows services to communicate appropriate error states. For truly layer-agnostic services, use domain exceptions that map to HTTP status codes. + +**Incorrect (return error objects instead of throwing):** + +```typescript +// Return error objects instead of throwing +@Injectable() +export class UsersService { + async findById(id: string): Promise<{ user?: User; error?: string }> { + const user = await this.repo.findOne({ where: { id } }); + if (!user) { + return { error: 'User not found' }; // Controller must check this + } + return { user }; + } +} + +@Controller('users') +export class UsersController { + @Get(':id') + async findOne(@Param('id') id: string) { + const result = await this.usersService.findById(id); + if (result.error) { + throw new NotFoundException(result.error); + } + return result.user; + } +} +``` + +**Correct (throw exceptions directly from service):** + +```typescript +// Throw exceptions directly from service +@Injectable() +export class UsersService { + constructor(private readonly repo: UserRepository) {} + + async findById(id: string): Promise { + const user = await this.repo.findOne({ where: { id } }); + if (!user) { + throw new NotFoundException(`User #${id} not found`); + } + return user; + } + + async create(dto: CreateUserDto): Promise { + const existing = await this.repo.findOne({ + where: { email: dto.email }, + }); + if (existing) { + throw new ConflictException('Email already registered'); + } + return this.repo.save(dto); + } + + async update(id: string, dto: UpdateUserDto): Promise { + const user = await this.findById(id); // Throws if not found + Object.assign(user, dto); + return this.repo.save(user); + } +} + +// Controller stays thin +@Controller('users') +export class UsersController { + @Get(':id') + findOne(@Param('id') id: string): Promise { + return this.usersService.findById(id); + } + + @Post() + create(@Body() dto: CreateUserDto): Promise { + return this.usersService.create(dto); + } +} + +// For layer-agnostic services, use domain exceptions +export class EntityNotFoundException extends Error { + constructor( + public readonly entity: string, + public readonly id: string, + ) { + super(`${entity} with ID "${id}" not found`); + } +} + +// Map to HTTP in exception filter +@Catch(EntityNotFoundException) +export class EntityNotFoundFilter implements ExceptionFilter { + catch(exception: EntityNotFoundException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + + response.status(404).json({ + statusCode: 404, + message: exception.message, + entity: exception.entity, + id: exception.id, + }); + } +} +``` + +Reference: [NestJS Exception Filters](https://docs.nestjs.com/exception-filters) diff --git a/.agents/skills/nestjs-best-practices/rules/error-use-exception-filters.md b/.agents/skills/nestjs-best-practices/rules/error-use-exception-filters.md new file mode 100644 index 000000000..635823a82 --- /dev/null +++ b/.agents/skills/nestjs-best-practices/rules/error-use-exception-filters.md @@ -0,0 +1,140 @@ +--- +title: Use Exception Filters for Error Handling +impact: HIGH +impactDescription: Consistent, centralized error handling +tags: error-handling, exception-filters, consistency +--- + +## Use Exception Filters for Error Handling + +Never catch exceptions and manually format error responses in controllers. Use NestJS exception filters to handle errors consistently across your application. Create custom exception filters for specific error types and a global filter for unhandled exceptions. + +**Incorrect (manual error handling in controllers):** + +```typescript +// Manual error handling in controllers +@Controller('users') +export class UsersController { + @Get(':id') + async findOne(@Param('id') id: string, @Res() res: Response) { + try { + const user = await this.usersService.findById(id); + if (!user) { + return res.status(404).json({ + statusCode: 404, + message: 'User not found', + }); + } + return res.json(user); + } catch (error) { + console.error(error); + return res.status(500).json({ + statusCode: 500, + message: 'Internal server error', + }); + } + } +} +``` + +**Correct (exception filters with consistent handling):** + +```typescript +// Use built-in and custom exceptions +@Controller('users') +export class UsersController { + @Get(':id') + async findOne(@Param('id') id: string): Promise { + const user = await this.usersService.findById(id); + if (!user) { + throw new NotFoundException(`User #${id} not found`); + } + return user; + } +} + +// Custom domain exception +export class UserNotFoundException extends NotFoundException { + constructor(userId: string) { + super({ + statusCode: 404, + error: 'Not Found', + message: `User with ID "${userId}" not found`, + code: 'USER_NOT_FOUND', + }); + } +} + +// Custom exception filter for domain errors +@Catch(DomainException) +export class DomainExceptionFilter implements ExceptionFilter { + catch(exception: DomainException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + const status = exception.getStatus?.() || 400; + + response.status(status).json({ + statusCode: status, + code: exception.code, + message: exception.message, + timestamp: new Date().toISOString(), + path: request.url, + }); + } +} + +// Global exception filter for unhandled errors +@Catch() +export class AllExceptionsFilter implements ExceptionFilter { + constructor(private readonly logger: Logger) {} + + catch(exception: unknown, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + const status = + exception instanceof HttpException + ? exception.getStatus() + : HttpStatus.INTERNAL_SERVER_ERROR; + + const message = + exception instanceof HttpException + ? exception.message + : 'Internal server error'; + + this.logger.error( + `${request.method} ${request.url}`, + exception instanceof Error ? exception.stack : exception, + ); + + response.status(status).json({ + statusCode: status, + message, + timestamp: new Date().toISOString(), + path: request.url, + }); + } +} + +// Register globally in main.ts +app.useGlobalFilters( + new AllExceptionsFilter(app.get(Logger)), + new DomainExceptionFilter(), +); + +// Or via module +@Module({ + providers: [ + { + provide: APP_FILTER, + useClass: AllExceptionsFilter, + }, + ], +}) +export class AppModule {} +``` + +Reference: [NestJS Exception Filters](https://docs.nestjs.com/exception-filters) diff --git a/.agents/skills/nestjs-best-practices/rules/micro-use-health-checks.md b/.agents/skills/nestjs-best-practices/rules/micro-use-health-checks.md new file mode 100644 index 000000000..0b10d926d --- /dev/null +++ b/.agents/skills/nestjs-best-practices/rules/micro-use-health-checks.md @@ -0,0 +1,226 @@ +--- +title: Implement Health Checks for Microservices +impact: MEDIUM-HIGH +impactDescription: Health checks enable orchestrators to manage service lifecycle +tags: microservices, health-checks, terminus, kubernetes +--- + +## Implement Health Checks for Microservices + +Implement liveness and readiness probes using `@nestjs/terminus`. Liveness checks determine if the service should be restarted. Readiness checks determine if the service can accept traffic. Proper health checks enable Kubernetes and load balancers to route traffic correctly. + +**Incorrect (simple ping that doesn't check dependencies):** + +```typescript +// Simple ping that doesn't check dependencies +@Controller('health') +export class HealthController { + @Get() + check(): string { + return 'OK'; // Service might be unhealthy but returns OK + } +} + +// Health check that blocks on slow dependencies +@Controller('health') +export class HealthController { + @Get() + async check(): Promise { + // If database is slow, health check times out + await this.userRepo.findOne({ where: { id: '1' } }); + await this.redis.ping(); + await this.externalApi.healthCheck(); + return 'OK'; + } +} +``` + +**Correct (use @nestjs/terminus for comprehensive health checks):** + +```typescript +// Use @nestjs/terminus for comprehensive health checks +import { + HealthCheckService, + HttpHealthIndicator, + TypeOrmHealthIndicator, + HealthCheck, + DiskHealthIndicator, + MemoryHealthIndicator, +} from '@nestjs/terminus'; + +@Controller('health') +export class HealthController { + constructor( + private health: HealthCheckService, + private http: HttpHealthIndicator, + private db: TypeOrmHealthIndicator, + private disk: DiskHealthIndicator, + private memory: MemoryHealthIndicator, + ) {} + + // Liveness probe - is the service alive? + @Get('live') + @HealthCheck() + liveness() { + return this.health.check([ + // Basic checks only + () => this.memory.checkHeap('memory_heap', 200 * 1024 * 1024), // 200MB + ]); + } + + // Readiness probe - can the service handle traffic? + @Get('ready') + @HealthCheck() + readiness() { + return this.health.check([ + () => this.db.pingCheck('database'), + () => + this.http.pingCheck('redis', 'http://redis:6379', { timeout: 1000 }), + () => + this.disk.checkStorage('disk', { path: '/', thresholdPercent: 0.9 }), + ]); + } + + // Deep health check for debugging + @Get('deep') + @HealthCheck() + deepCheck() { + return this.health.check([ + () => this.db.pingCheck('database'), + () => this.memory.checkHeap('memory_heap', 200 * 1024 * 1024), + () => this.memory.checkRSS('memory_rss', 300 * 1024 * 1024), + () => + this.disk.checkStorage('disk', { path: '/', thresholdPercent: 0.9 }), + () => + this.http.pingCheck('external-api', 'https://api.example.com/health'), + ]); + } +} + +// Custom indicator for business-specific health +@Injectable() +export class QueueHealthIndicator extends HealthIndicator { + constructor(private queueService: QueueService) { + super(); + } + + async isHealthy(key: string): Promise { + const queueStats = await this.queueService.getStats(); + + const isHealthy = queueStats.failedCount < 100; + const result = this.getStatus(key, isHealthy, { + waiting: queueStats.waitingCount, + active: queueStats.activeCount, + failed: queueStats.failedCount, + }); + + if (!isHealthy) { + throw new HealthCheckError('Queue unhealthy', result); + } + + return result; + } +} + +// Redis health indicator +@Injectable() +export class RedisHealthIndicator extends HealthIndicator { + constructor(@InjectRedis() private redis: Redis) { + super(); + } + + async isHealthy(key: string): Promise { + try { + const pong = await this.redis.ping(); + return this.getStatus(key, pong === 'PONG'); + } catch (error) { + throw new HealthCheckError('Redis check failed', this.getStatus(key, false)); + } + } +} + +// Use custom indicators +@Get('ready') +@HealthCheck() +readiness() { + return this.health.check([ + () => this.db.pingCheck('database'), + () => this.redis.isHealthy('redis'), + () => this.queue.isHealthy('job-queue'), + ]); +} + +// Graceful shutdown handling +@Injectable() +export class GracefulShutdownService implements OnApplicationShutdown { + private isShuttingDown = false; + + isShutdown(): boolean { + return this.isShuttingDown; + } + + async onApplicationShutdown(signal: string): Promise { + this.isShuttingDown = true; + console.log(`Shutting down on ${signal}`); + + // Wait for in-flight requests + await new Promise((resolve) => setTimeout(resolve, 5000)); + } +} + +// Health check respects shutdown state +@Get('ready') +@HealthCheck() +readiness() { + if (this.shutdownService.isShutdown()) { + throw new ServiceUnavailableException('Shutting down'); + } + + return this.health.check([ + () => this.db.pingCheck('database'), + ]); +} +``` + +### Kubernetes Configuration + +```yaml +# Kubernetes deployment with probes +apiVersion: apps/v1 +kind: Deployment +metadata: + name: api-service +spec: + template: + spec: + containers: + - name: api + image: api-service:latest + ports: + - containerPort: 3000 + livenessProbe: + httpGet: + path: /health/live + port: 3000 + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health/ready + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + startupProbe: + httpGet: + path: /health/live + port: 3000 + initialDelaySeconds: 0 + periodSeconds: 5 + failureThreshold: 30 +``` + +Reference: [NestJS Terminus](https://docs.nestjs.com/recipes/terminus) diff --git a/.agents/skills/nestjs-best-practices/rules/micro-use-patterns.md b/.agents/skills/nestjs-best-practices/rules/micro-use-patterns.md new file mode 100644 index 000000000..82d7c722d --- /dev/null +++ b/.agents/skills/nestjs-best-practices/rules/micro-use-patterns.md @@ -0,0 +1,167 @@ +--- +title: Use Message and Event Patterns Correctly +impact: MEDIUM +impactDescription: Proper patterns ensure reliable microservice communication +tags: microservices, message-pattern, event-pattern, communication +--- + +## Use Message and Event Patterns Correctly + +NestJS microservices support two communication patterns: request-response (MessagePattern) and event-based (EventPattern). Use MessagePattern when you need a response, and EventPattern for fire-and-forget notifications. Understanding the difference prevents communication bugs. + +**Incorrect (using wrong pattern for use case):** + +```typescript +// Use @MessagePattern for fire-and-forget +@Controller() +export class NotificationsController { + @MessagePattern('user.created') + async handleUserCreated(data: UserCreatedEvent) { + // This WAITS for response, blocking the sender + await this.emailService.sendWelcome(data.email); + // If email fails, sender gets an error (coupling!) + } +} + +// Use @EventPattern expecting a response +@Controller() +export class OrdersController { + @EventPattern('inventory.check') + async checkInventory(data: CheckInventoryDto) { + const available = await this.inventory.check(data); + return available; // This return value is IGNORED with @EventPattern! + } +} + +// Tight coupling in client +@Injectable() +export class UsersService { + async createUser(dto: CreateUserDto): Promise { + const user = await this.repo.save(dto); + + // Blocks until notification service responds + await this.client.send('user.created', user).toPromise(); + // If notification service is down, user creation fails! + + return user; + } +} +``` + +**Correct (use MessagePattern for request-response, EventPattern for fire-and-forget):** + +```typescript +// MessagePattern: Request-Response (when you NEED a response) +@Controller() +export class InventoryController { + @MessagePattern({ cmd: 'check_inventory' }) + async checkInventory(data: CheckInventoryDto): Promise { + const result = await this.inventoryService.check(data.productId, data.quantity); + return result; // Response sent back to caller + } +} + +// Client expects response +@Injectable() +export class OrdersService { + async createOrder(dto: CreateOrderDto): Promise { + // Check inventory - we NEED this response to proceed + const inventory = await firstValueFrom( + this.inventoryClient.send( + { cmd: 'check_inventory' }, + { productId: dto.productId, quantity: dto.quantity }, + ), + ); + + if (!inventory.available) { + throw new BadRequestException('Insufficient inventory'); + } + + return this.repo.save(dto); + } +} + +// EventPattern: Fire-and-Forget (for notifications, side effects) +@Controller() +export class NotificationsController { + @EventPattern('user.created') + async handleUserCreated(data: UserCreatedEvent): Promise { + // No return value needed - just process the event + await this.emailService.sendWelcome(data.email); + await this.analyticsService.track('user_signup', data); + // If this fails, it doesn't affect the sender + } +} + +// Client emits event without waiting +@Injectable() +export class UsersService { + async createUser(dto: CreateUserDto): Promise { + const user = await this.repo.save(dto); + + // Fire and forget - doesn't block, doesn't wait + this.eventClient.emit('user.created', { + userId: user.id, + email: user.email, + timestamp: new Date(), + }); + + return user; // User creation succeeds regardless of event handling + } +} + +// Hybrid pattern for critical events +@Injectable() +export class OrdersService { + async createOrder(dto: CreateOrderDto): Promise { + const order = await this.repo.save(dto); + + // Critical: inventory reservation (use MessagePattern) + const reserved = await firstValueFrom( + this.inventoryClient.send({ cmd: 'reserve_inventory' }, { + orderId: order.id, + items: dto.items, + }), + ); + + if (!reserved.success) { + await this.repo.delete(order.id); + throw new BadRequestException('Could not reserve inventory'); + } + + // Non-critical: notifications (use EventPattern) + this.eventClient.emit('order.created', { + orderId: order.id, + userId: dto.userId, + total: dto.total, + }); + + return order; + } +} + +// Error handling patterns +// MessagePattern errors propagate to caller +@MessagePattern({ cmd: 'get_user' }) +async getUser(userId: string): Promise { + const user = await this.repo.findOne({ where: { id: userId } }); + if (!user) { + throw new RpcException('User not found'); // Received by caller + } + return user; +} + +// EventPattern errors should be handled locally +@EventPattern('order.created') +async handleOrderCreated(data: OrderCreatedEvent): Promise { + try { + await this.processOrder(data); + } catch (error) { + // Log and potentially retry - don't throw + this.logger.error('Failed to process order event', error); + await this.deadLetterQueue.add(data); + } +} +``` + +Reference: [NestJS Microservices](https://docs.nestjs.com/microservices/basics) diff --git a/.agents/skills/nestjs-best-practices/rules/micro-use-queues.md b/.agents/skills/nestjs-best-practices/rules/micro-use-queues.md new file mode 100644 index 000000000..f9bc6725f --- /dev/null +++ b/.agents/skills/nestjs-best-practices/rules/micro-use-queues.md @@ -0,0 +1,252 @@ +--- +title: Use Message Queues for Background Jobs +impact: MEDIUM-HIGH +impactDescription: Queues enable reliable background processing +tags: microservices, queues, bullmq, background-jobs +--- + +## Use Message Queues for Background Jobs + +Use `@nestjs/bullmq` for background job processing. Queues decouple long-running tasks from HTTP requests, enable retry logic, and distribute workload across workers. Use them for emails, file processing, notifications, and any task that shouldn't block user requests. + +**Incorrect (long-running tasks in HTTP handlers):** + +```typescript +// Long-running tasks in HTTP handlers +@Controller('reports') +export class ReportsController { + @Post() + async generate(@Body() dto: GenerateReportDto): Promise { + // This blocks the request for potentially minutes + const data = await this.fetchLargeDataset(dto); + const report = await this.processData(data); // Slow! + await this.sendEmail(dto.email, report); // Can fail! + return report; // Client times out + } +} + +// Fire-and-forget without retry +@Injectable() +export class EmailService { + async sendWelcome(email: string): Promise { + // If this fails, email is never sent + await this.mailer.send({ to: email, template: 'welcome' }); + // No retry, no tracking, no visibility + } +} + +// Use setInterval for scheduled tasks +setInterval(async () => { + await cleanupOldRecords(); +}, 60000); // No error handling, memory leaks +``` + +**Correct (use BullMQ for background processing):** + +```typescript +// Configure BullMQ +import { BullModule } from '@nestjs/bullmq'; + +@Module({ + imports: [ + BullModule.forRoot({ + connection: { + host: 'localhost', + port: 6379, + }, + defaultJobOptions: { + removeOnComplete: 1000, + removeOnFail: 5000, + attempts: 3, + backoff: { + type: 'exponential', + delay: 1000, + }, + }, + }), + BullModule.registerQueue( + { name: 'email' }, + { name: 'reports' }, + { name: 'notifications' }, + ), + ], +}) +export class QueueModule {} + +// Producer: Add jobs to queue +@Injectable() +export class ReportsService { + constructor( + @InjectQueue('reports') private reportsQueue: Queue, + ) {} + + async requestReport(dto: GenerateReportDto): Promise<{ jobId: string }> { + // Return immediately, process in background + const job = await this.reportsQueue.add('generate', dto, { + priority: dto.urgent ? 1 : 10, + delay: dto.scheduledFor ? Date.parse(dto.scheduledFor) - Date.now() : 0, + }); + + return { jobId: job.id }; + } + + async getJobStatus(jobId: string): Promise { + const job = await this.reportsQueue.getJob(jobId); + return { + status: await job.getState(), + progress: job.progress, + result: job.returnvalue, + }; + } +} + +// Consumer: Process jobs +@Processor('reports') +export class ReportsProcessor { + private readonly logger = new Logger(ReportsProcessor.name); + + @Process('generate') + async generateReport(job: Job): Promise { + this.logger.log(`Processing report job ${job.id}`); + + // Update progress + await job.updateProgress(10); + + const data = await this.fetchData(job.data); + await job.updateProgress(50); + + const report = await this.processData(data); + await job.updateProgress(90); + + await this.saveReport(report); + await job.updateProgress(100); + + return report; + } + + @OnQueueActive() + onActive(job: Job) { + this.logger.log(`Processing job ${job.id}`); + } + + @OnQueueCompleted() + onCompleted(job: Job, result: any) { + this.logger.log(`Job ${job.id} completed`); + } + + @OnQueueFailed() + onFailed(job: Job, error: Error) { + this.logger.error(`Job ${job.id} failed: ${error.message}`); + } +} + +// Email queue with retry +@Processor('email') +export class EmailProcessor { + @Process('send') + async sendEmail(job: Job): Promise { + const { to, template, data } = job.data; + + try { + await this.mailer.send({ + to, + template, + context: data, + }); + } catch (error) { + // BullMQ will retry based on job options + throw error; + } + } +} + +// Usage +@Injectable() +export class NotificationService { + constructor(@InjectQueue('email') private emailQueue: Queue) {} + + async sendWelcome(user: User): Promise { + await this.emailQueue.add( + 'send', + { + to: user.email, + template: 'welcome', + data: { name: user.name }, + }, + { + attempts: 5, + backoff: { type: 'exponential', delay: 5000 }, + }, + ); + } +} + +// Scheduled jobs +@Injectable() +export class ScheduledJobsService implements OnModuleInit { + constructor(@InjectQueue('maintenance') private queue: Queue) {} + + async onModuleInit(): Promise { + // Clean up old reports daily at midnight + await this.queue.add( + 'cleanup', + {}, + { + repeat: { cron: '0 0 * * *' }, + jobId: 'daily-cleanup', // Prevent duplicates + }, + ); + + // Send digest every hour + await this.queue.add( + 'digest', + {}, + { + repeat: { every: 60 * 60 * 1000 }, + jobId: 'hourly-digest', + }, + ); + } +} + +@Processor('maintenance') +export class MaintenanceProcessor { + @Process('cleanup') + async cleanup(): Promise { + await this.cleanupOldReports(); + await this.cleanupExpiredSessions(); + } + + @Process('digest') + async sendDigest(): Promise { + const users = await this.getUsersForDigest(); + for (const user of users) { + await this.emailQueue.add('send', { to: user.email, template: 'digest' }); + } + } +} + +// Queue monitoring with Bull Board +import { BullBoardModule } from '@bull-board/nestjs'; +import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'; + +@Module({ + imports: [ + BullBoardModule.forRoot({ + route: '/admin/queues', + adapter: ExpressAdapter, + }), + BullBoardModule.forFeature({ + name: 'email', + adapter: BullMQAdapter, + }), + BullBoardModule.forFeature({ + name: 'reports', + adapter: BullMQAdapter, + }), + ], +}) +export class AdminModule {} +``` + +Reference: [NestJS Queues](https://docs.nestjs.com/techniques/queues) diff --git a/.agents/skills/nestjs-best-practices/rules/perf-async-hooks.md b/.agents/skills/nestjs-best-practices/rules/perf-async-hooks.md new file mode 100644 index 000000000..7ca007710 --- /dev/null +++ b/.agents/skills/nestjs-best-practices/rules/perf-async-hooks.md @@ -0,0 +1,109 @@ +--- +title: Use Async Lifecycle Hooks Correctly +impact: HIGH +impactDescription: Improper async handling blocks application startup +tags: performance, lifecycle, async, hooks +--- + +## Use Async Lifecycle Hooks Correctly + +NestJS lifecycle hooks (`onModuleInit`, `onApplicationBootstrap`, etc.) support async operations. However, misusing them can block application startup or cause race conditions. Understand the lifecycle order and use hooks appropriately. + +**Incorrect (fire-and-forget async without await):** + +```typescript +// Fire-and-forget async without await +@Injectable() +export class DatabaseService implements OnModuleInit { + onModuleInit() { + // This runs but doesn't block - app starts before DB is ready! + this.connect(); + } + + private async connect() { + await this.pool.connect(); + console.log('Database connected'); + } +} + +// Heavy blocking operations in constructor +@Injectable() +export class ConfigService { + private config: Config; + + constructor() { + // BLOCKS entire module instantiation synchronously + this.config = fs.readFileSync('config.json'); + } +} +``` + +**Correct (return promises from async hooks):** + +```typescript +// Return promise from async hooks +@Injectable() +export class DatabaseService implements OnModuleInit { + private pool: Pool; + + async onModuleInit(): Promise { + // NestJS waits for this to complete before continuing + await this.pool.connect(); + console.log('Database connected'); + } + + async onModuleDestroy(): Promise { + // Clean up resources on shutdown + await this.pool.end(); + console.log('Database disconnected'); + } +} + +// Use onApplicationBootstrap for cross-module dependencies +@Injectable() +export class CacheWarmerService implements OnApplicationBootstrap { + constructor( + private cache: CacheService, + private products: ProductsService, + ) {} + + async onApplicationBootstrap(): Promise { + // All modules are initialized, safe to warm cache + const products = await this.products.findPopular(); + await this.cache.warmup(products); + } +} + +// Heavy init in async hooks, not constructor +@Injectable() +export class ConfigService implements OnModuleInit { + private config: Config; + + constructor() { + // Keep constructor synchronous and fast + } + + async onModuleInit(): Promise { + // Async loading in lifecycle hook + this.config = await this.loadConfig(); + } + + private async loadConfig(): Promise { + const file = await fs.promises.readFile('config.json'); + return JSON.parse(file.toString()); + } + + get(key: string): T { + return this.config[key]; + } +} + +// Enable shutdown hooks in main.ts +async function bootstrap() { + const app = await NestFactory.create(AppModule); + app.enableShutdownHooks(); // Enable SIGTERM/SIGINT handling + await app.listen(3000); +} +``` + +Reference: [NestJS Lifecycle Events](https://docs.nestjs.com/fundamentals/lifecycle-events) diff --git a/.agents/skills/nestjs-best-practices/rules/perf-lazy-loading.md b/.agents/skills/nestjs-best-practices/rules/perf-lazy-loading.md new file mode 100644 index 000000000..8bcc5828c --- /dev/null +++ b/.agents/skills/nestjs-best-practices/rules/perf-lazy-loading.md @@ -0,0 +1,121 @@ +--- +title: Use Lazy Loading for Large Modules +impact: MEDIUM +impactDescription: Improves startup time for large applications +tags: performance, lazy-loading, modules, optimization +--- + +## Use Lazy Loading for Large Modules + +NestJS supports lazy-loading modules, which defers initialization until first use. This is valuable for large applications where some features are rarely used, serverless deployments where cold start time matters, or when certain modules have heavy initialization costs. + +**Incorrect (loading everything eagerly):** + +```typescript +// Load everything eagerly in a large app +@Module({ + imports: [ + UsersModule, + OrdersModule, + PaymentsModule, + ReportsModule, // Heavy, rarely used + AnalyticsModule, // Heavy, rarely used + AdminModule, // Only admins use this + LegacyModule, // Migration module, rarely used + BulkImportModule, // Used once a month + ], +}) +export class AppModule {} + +// All modules initialize at startup, even if never used +// Slow cold starts in serverless +// Memory wasted on unused modules +``` + +**Correct (lazy load rarely-used modules):** + +```typescript +// Use LazyModuleLoader for optional modules +import { LazyModuleLoader } from '@nestjs/core'; + +@Injectable() +export class ReportsService { + constructor(private lazyModuleLoader: LazyModuleLoader) {} + + async generateReport(type: string): Promise { + // Load module only when needed + const { ReportsModule } = await import('./reports/reports.module'); + const moduleRef = await this.lazyModuleLoader.load(() => ReportsModule); + + const reportsService = moduleRef.get(ReportsGeneratorService); + return reportsService.generate(type); + } +} + +// Lazy load admin features with caching +@Injectable() +export class AdminService { + private adminModule: ModuleRef | null = null; + + constructor(private lazyModuleLoader: LazyModuleLoader) {} + + private async getAdminModule(): Promise { + if (!this.adminModule) { + const { AdminModule } = await import('./admin/admin.module'); + this.adminModule = await this.lazyModuleLoader.load(() => AdminModule); + } + return this.adminModule; + } + + async runAdminTask(task: string): Promise { + const moduleRef = await this.getAdminModule(); + const taskRunner = moduleRef.get(AdminTaskRunner); + await taskRunner.run(task); + } +} + +// Reusable lazy loader service +@Injectable() +export class ModuleLoaderService { + private loadedModules = new Map(); + + constructor(private lazyModuleLoader: LazyModuleLoader) {} + + async load( + key: string, + importFn: () => Promise<{ default: Type } | Type>, + ): Promise { + if (!this.loadedModules.has(key)) { + const module = await importFn(); + const moduleType = 'default' in module ? module.default : module; + const moduleRef = await this.lazyModuleLoader.load(() => moduleType); + this.loadedModules.set(key, moduleRef); + } + return this.loadedModules.get(key)!; + } +} + +// Preload modules in background after startup +@Injectable() +export class ModulePreloader implements OnApplicationBootstrap { + constructor(private lazyModuleLoader: LazyModuleLoader) {} + + async onApplicationBootstrap(): Promise { + setTimeout(async () => { + await this.preloadModule(() => import('./reports/reports.module')); + }, 5000); // 5 seconds after startup + } + + private async preloadModule(importFn: () => Promise): Promise { + try { + const module = await importFn(); + const moduleType = module.default || Object.values(module)[0]; + await this.lazyModuleLoader.load(() => moduleType); + } catch (error) { + console.warn('Failed to preload module', error); + } + } +} +``` + +Reference: [NestJS Lazy Loading Modules](https://docs.nestjs.com/fundamentals/lazy-loading-modules) diff --git a/.agents/skills/nestjs-best-practices/rules/perf-optimize-database.md b/.agents/skills/nestjs-best-practices/rules/perf-optimize-database.md new file mode 100644 index 000000000..964189f7d --- /dev/null +++ b/.agents/skills/nestjs-best-practices/rules/perf-optimize-database.md @@ -0,0 +1,131 @@ +--- +title: Optimize Database Queries +impact: HIGH +impactDescription: Database queries are typically the largest source of latency +tags: performance, database, queries, optimization +--- + +## Optimize Database Queries + +Select only needed columns, use proper indexes, avoid over-fetching relations, and consider query performance when designing your data access. Most API slowness traces back to inefficient database queries. + +**Incorrect (over-fetching data and missing indexes):** + +```typescript +// Select everything when you need few fields +@Injectable() +export class UsersService { + async findAllEmails(): Promise { + const users = await this.repo.find(); + // Fetches ALL columns for ALL users + return users.map((u) => u.email); + } + + async getUserSummary(id: string): Promise { + const user = await this.repo.findOne({ + where: { id }, + relations: ['posts', 'posts.comments', 'posts.comments.author', 'followers'], + }); + // Over-fetches massive relation tree + return { name: user.name, postCount: user.posts.length }; + } +} + +// No indexes on frequently queried columns +@Entity() +export class Order { + @Column() + userId: string; // No index - full table scan on every lookup + + @Column() + status: string; // No index - slow status filtering +} +``` + +**Correct (select only needed data with proper indexes):** + +```typescript +// Select only needed columns +@Injectable() +export class UsersService { + async findAllEmails(): Promise { + const users = await this.repo.find({ + select: ['email'], // Only fetch email column + }); + return users.map((u) => u.email); + } + + // Use QueryBuilder for complex selections + async getUserSummary(id: string): Promise { + return this.repo + .createQueryBuilder('user') + .select('user.name', 'name') + .addSelect('COUNT(post.id)', 'postCount') + .leftJoin('user.posts', 'post') + .where('user.id = :id', { id }) + .groupBy('user.id') + .getRawOne(); + } + + // Fetch relations only when needed + async getFullProfile(id: string): Promise { + return this.repo.findOne({ + where: { id }, + relations: ['posts'], // Only immediate relation + select: { + id: true, + name: true, + email: true, + posts: { + id: true, + title: true, + }, + }, + }); + } +} + +// Add indexes on frequently queried columns +@Entity() +@Index(['userId']) +@Index(['status']) +@Index(['createdAt']) +@Index(['userId', 'status']) // Composite index for common query pattern +export class Order { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + userId: string; + + @Column() + status: string; + + @CreateDateColumn() + createdAt: Date; +} + +// Always paginate large datasets +@Injectable() +export class OrdersService { + async findAll(page = 1, limit = 20): Promise> { + const [items, total] = await this.repo.findAndCount({ + skip: (page - 1) * limit, + take: limit, + order: { createdAt: 'DESC' }, + }); + + return { + items, + meta: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }; + } +} +``` + +Reference: [TypeORM Query Builder](https://typeorm.io/select-query-builder) diff --git a/.agents/skills/nestjs-best-practices/rules/perf-use-caching.md b/.agents/skills/nestjs-best-practices/rules/perf-use-caching.md new file mode 100644 index 000000000..71706907b --- /dev/null +++ b/.agents/skills/nestjs-best-practices/rules/perf-use-caching.md @@ -0,0 +1,128 @@ +--- +title: Use Caching Strategically +impact: HIGH +impactDescription: Dramatically reduces database load and response times +tags: performance, caching, redis, optimization +--- + +## Use Caching Strategically + +Implement caching for expensive operations, frequently accessed data, and external API calls. Use NestJS CacheModule with appropriate TTLs and cache invalidation strategies. Don't cache everything - focus on high-impact areas. + +**Incorrect (no caching or caching everything):** + +```typescript +// No caching for expensive, repeated queries +@Injectable() +export class ProductsService { + async getPopular(): Promise { + // Runs complex aggregation query EVERY request + return this.productsRepo + .createQueryBuilder('p') + .leftJoin('p.orders', 'o') + .select('p.*, COUNT(o.id) as orderCount') + .groupBy('p.id') + .orderBy('orderCount', 'DESC') + .limit(20) + .getMany(); + } +} + +// Cache everything without thought +@Injectable() +export class UsersService { + @CacheKey('users') + @CacheTTL(3600) + @UseInterceptors(CacheInterceptor) + async findAll(): Promise { + // Caching user list for 1 hour is wrong if data changes frequently + return this.usersRepo.find(); + } +} +``` + +**Correct (strategic caching with proper invalidation):** + +```typescript +// Setup caching module +@Module({ + imports: [ + CacheModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + stores: [ + new KeyvRedis(config.get('REDIS_URL')), + ], + ttl: 60 * 1000, // Default 60s + }), + }), + ], +}) +export class AppModule {} + +// Manual caching for granular control +@Injectable() +export class ProductsService { + constructor( + @Inject(CACHE_MANAGER) private cache: Cache, + private productsRepo: ProductRepository, + ) {} + + async getPopular(): Promise { + const cacheKey = 'products:popular'; + + // Try cache first + const cached = await this.cache.get(cacheKey); + if (cached) return cached; + + // Cache miss - fetch and cache + const products = await this.fetchPopularProducts(); + await this.cache.set(cacheKey, products, 5 * 60 * 1000); // 5 min TTL + return products; + } + + // Invalidate cache on changes + async updateProduct(id: string, dto: UpdateProductDto): Promise { + const product = await this.productsRepo.save({ id, ...dto }); + await this.cache.del('products:popular'); // Invalidate + return product; + } +} + +// Decorator-based caching with auto-interceptor +@Controller('categories') +@UseInterceptors(CacheInterceptor) +export class CategoriesController { + @Get() + @CacheTTL(30 * 60 * 1000) // 30 minutes - categories rarely change + findAll(): Promise { + return this.categoriesService.findAll(); + } + + @Get(':id') + @CacheTTL(60 * 1000) // 1 minute + @CacheKey('category') + findOne(@Param('id') id: string): Promise { + return this.categoriesService.findOne(id); + } +} + +// Event-based cache invalidation +@Injectable() +export class CacheInvalidationService { + constructor(@Inject(CACHE_MANAGER) private cache: Cache) {} + + @OnEvent('product.created') + @OnEvent('product.updated') + @OnEvent('product.deleted') + async invalidateProductCaches(event: ProductEvent) { + await Promise.all([ + this.cache.del('products:popular'), + this.cache.del(`product:${event.productId}`), + ]); + } +} +``` + +Reference: [NestJS Caching](https://docs.nestjs.com/techniques/caching) diff --git a/.agents/skills/nestjs-best-practices/rules/security-auth-jwt.md b/.agents/skills/nestjs-best-practices/rules/security-auth-jwt.md new file mode 100644 index 000000000..a0d1ff031 --- /dev/null +++ b/.agents/skills/nestjs-best-practices/rules/security-auth-jwt.md @@ -0,0 +1,146 @@ +--- +title: Implement Secure JWT Authentication +impact: CRITICAL +impactDescription: Essential for secure APIs +tags: security, jwt, authentication, tokens +--- + +## Implement Secure JWT Authentication + +Use `@nestjs/jwt` with `@nestjs/passport` for authentication. Store secrets securely, use appropriate token lifetimes, implement refresh tokens, and validate tokens properly. Never expose sensitive data in JWT payloads. + +**Incorrect (insecure JWT implementation):** + +```typescript +// Hardcode secrets +@Module({ + imports: [ + JwtModule.register({ + secret: 'my-secret-key', // Exposed in code + signOptions: { expiresIn: '7d' }, // Too long + }), + ], +}) +export class AuthModule {} + +// Store sensitive data in JWT +async login(user: User): Promise<{ accessToken: string }> { + const payload = { + sub: user.id, + email: user.email, + password: user.password, // NEVER include password! + ssn: user.ssn, // NEVER include sensitive data! + isAdmin: user.isAdmin, // Can be tampered if not verified + }; + return { accessToken: this.jwtService.sign(payload) }; +} + +// Skip token validation +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor() { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: 'my-secret', + }); + } + + async validate(payload: any): Promise { + return payload; // No validation of user existence + } +} +``` + +**Correct (secure JWT with refresh tokens):** + +```typescript +// Secure JWT configuration +@Module({ + imports: [ + JwtModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + secret: config.get('JWT_SECRET'), + signOptions: { + expiresIn: '15m', // Short-lived access tokens + issuer: config.get('JWT_ISSUER'), + audience: config.get('JWT_AUDIENCE'), + }, + }), + }), + PassportModule.register({ defaultStrategy: 'jwt' }), + ], +}) +export class AuthModule {} + +// Minimal JWT payload +@Injectable() +export class AuthService { + async login(user: User): Promise { + // Only include necessary, non-sensitive data + const payload: JwtPayload = { + sub: user.id, + email: user.email, + roles: user.roles, + iat: Math.floor(Date.now() / 1000), + }; + + const accessToken = this.jwtService.sign(payload); + const refreshToken = await this.createRefreshToken(user.id); + + return { accessToken, refreshToken, expiresIn: 900 }; + } + + private async createRefreshToken(userId: string): Promise { + const token = randomBytes(32).toString('hex'); + const hashedToken = await bcrypt.hash(token, 10); + + await this.refreshTokenRepo.save({ + userId, + token: hashedToken, + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days + }); + + return token; + } +} + +// Proper JWT strategy with validation +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor( + private config: ConfigService, + private usersService: UsersService, + ) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: config.get('JWT_SECRET'), + ignoreExpiration: false, + issuer: config.get('JWT_ISSUER'), + audience: config.get('JWT_AUDIENCE'), + }); + } + + async validate(payload: JwtPayload): Promise { + // Verify user still exists and is active + const user = await this.usersService.findById(payload.sub); + + if (!user || !user.isActive) { + throw new UnauthorizedException('User not found or inactive'); + } + + // Verify token wasn't issued before password change + if (user.passwordChangedAt) { + const tokenIssuedAt = new Date(payload.iat * 1000); + if (tokenIssuedAt < user.passwordChangedAt) { + throw new UnauthorizedException('Token invalidated by password change'); + } + } + + return user; + } +} +``` + +Reference: [NestJS Authentication](https://docs.nestjs.com/security/authentication) diff --git a/.agents/skills/nestjs-best-practices/rules/security-rate-limiting.md b/.agents/skills/nestjs-best-practices/rules/security-rate-limiting.md new file mode 100644 index 000000000..7d39e9c8c --- /dev/null +++ b/.agents/skills/nestjs-best-practices/rules/security-rate-limiting.md @@ -0,0 +1,125 @@ +--- +title: Implement Rate Limiting +impact: HIGH +impactDescription: Protects against abuse and ensures fair resource usage +tags: security, rate-limiting, throttler, protection +--- + +## Implement Rate Limiting + +Use `@nestjs/throttler` to limit request rates per client. Apply different limits for different endpoints - stricter for auth endpoints, more relaxed for read operations. Consider using Redis for distributed rate limiting in clustered deployments. + +**Incorrect (no rate limiting on sensitive endpoints):** + +```typescript +// No rate limiting on sensitive endpoints +@Controller('auth') +export class AuthController { + @Post('login') + async login(@Body() dto: LoginDto): Promise { + // Attackers can brute-force credentials + return this.authService.login(dto); + } + + @Post('forgot-password') + async forgotPassword(@Body() dto: ForgotPasswordDto): Promise { + // Can be abused to spam users with emails + return this.authService.sendResetEmail(dto.email); + } +} + +// Same limits for all endpoints +@UseGuards(ThrottlerGuard) +@Controller('api') +export class ApiController { + @Get('public-data') + async getPublic() {} // Should allow more requests + + @Post('process-payment') + async payment() {} // Should be more restrictive +} +``` + +**Correct (configured throttler with endpoint-specific limits):** + +```typescript +// Configure throttler globally with multiple limits +import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; + +@Module({ + imports: [ + ThrottlerModule.forRoot([ + { + name: 'short', + ttl: 1000, // 1 second + limit: 3, // 3 requests per second + }, + { + name: 'medium', + ttl: 10000, // 10 seconds + limit: 20, // 20 requests per 10 seconds + }, + { + name: 'long', + ttl: 60000, // 1 minute + limit: 100, // 100 requests per minute + }, + ]), + ], + providers: [ + { + provide: APP_GUARD, + useClass: ThrottlerGuard, + }, + ], +}) +export class AppModule {} + +// Override limits per endpoint +@Controller('auth') +export class AuthController { + @Post('login') + @Throttle({ short: { limit: 5, ttl: 60000 } }) // 5 attempts per minute + async login(@Body() dto: LoginDto): Promise { + return this.authService.login(dto); + } + + @Post('forgot-password') + @Throttle({ short: { limit: 3, ttl: 3600000 } }) // 3 per hour + async forgotPassword(@Body() dto: ForgotPasswordDto): Promise { + return this.authService.sendResetEmail(dto.email); + } +} + +// Skip throttling for certain routes +@Controller('health') +export class HealthController { + @Get() + @SkipThrottle() + check(): string { + return 'OK'; + } +} + +// Custom throttle per user type +@Injectable() +export class CustomThrottlerGuard extends ThrottlerGuard { + protected async getTracker(req: Request): Promise { + // Use user ID if authenticated, IP otherwise + return req.user?.id || req.ip; + } + + protected async getLimit(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + + // Higher limits for authenticated users + if (request.user) { + return request.user.isPremium ? 1000 : 200; + } + + return 50; // Anonymous users + } +} +``` + +Reference: [NestJS Throttler](https://docs.nestjs.com/security/rate-limiting) diff --git a/.agents/skills/nestjs-best-practices/rules/security-sanitize-output.md b/.agents/skills/nestjs-best-practices/rules/security-sanitize-output.md new file mode 100644 index 000000000..78e3d396a --- /dev/null +++ b/.agents/skills/nestjs-best-practices/rules/security-sanitize-output.md @@ -0,0 +1,139 @@ +--- +title: Sanitize Output to Prevent XSS +impact: HIGH +impactDescription: XSS vulnerabilities can compromise user sessions and data +tags: security, xss, sanitization, html +--- + +## Sanitize Output to Prevent XSS + +While NestJS APIs typically return JSON (which browsers don't execute), XSS risks exist when rendering HTML, storing user content, or when frontend frameworks improperly handle API responses. Sanitize user-generated content before storage and use proper Content-Type headers. + +**Incorrect (storing raw HTML without sanitization):** + +```typescript +// Store raw HTML from users +@Injectable() +export class CommentsService { + async create(dto: CreateCommentDto): Promise { + // User can inject: + return this.repo.save({ + content: dto.content, // Raw, unsanitized + authorId: dto.authorId, + }); + } +} + +// Return HTML without sanitization +@Controller('pages') +export class PagesController { + @Get(':slug') + @Header('Content-Type', 'text/html') + async getPage(@Param('slug') slug: string): Promise { + const page = await this.pagesService.findBySlug(slug); + // If page.content contains user input, XSS is possible + return `${page.content}`; + } +} + +// Reflect user input in errors +@Get(':id') +async findOne(@Param('id') id: string): Promise { + const user = await this.repo.findOne({ where: { id } }); + if (!user) { + // XSS if id contains malicious content and error is rendered + throw new NotFoundException(`User ${id} not found`); + } + return user; +} +``` + +**Correct (sanitize content and use proper headers):** + +```typescript +// Sanitize HTML content before storage +import * as sanitizeHtml from 'sanitize-html'; + +@Injectable() +export class CommentsService { + private readonly sanitizeOptions: sanitizeHtml.IOptions = { + allowedTags: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'], + allowedAttributes: { + a: ['href', 'title'], + }, + allowedSchemes: ['http', 'https', 'mailto'], + }; + + async create(dto: CreateCommentDto): Promise { + return this.repo.save({ + content: sanitizeHtml(dto.content, this.sanitizeOptions), + authorId: dto.authorId, + }); + } +} + +// Use validation pipe to strip HTML +import { Transform } from 'class-transformer'; + +export class CreatePostDto { + @IsString() + @MaxLength(1000) + @Transform(({ value }) => sanitizeHtml(value, { allowedTags: [] })) + title: string; + + @IsString() + @Transform(({ value }) => + sanitizeHtml(value, { + allowedTags: ['p', 'br', 'b', 'i', 'a'], + allowedAttributes: { a: ['href'] }, + }), + ) + content: string; +} + +// Set proper Content-Type headers +@Controller('api') +export class ApiController { + @Get('data') + @Header('Content-Type', 'application/json') + async getData(): Promise { + // JSON response - browser won't execute scripts + return this.service.getData(); + } +} + +// Sanitize error messages +@Get(':id') +async findOne(@Param('id', ParseUUIDPipe) id: string): Promise { + const user = await this.repo.findOne({ where: { id } }); + if (!user) { + // UUID validation ensures safe format + throw new NotFoundException('User not found'); + } + return user; +} + +// Use Helmet for CSP headers +import helmet from 'helmet'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + app.use( + helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"], + imgSrc: ["'self'", 'data:', 'https:'], + }, + }, + }), + ); + + await app.listen(3000); +} +``` + +Reference: [OWASP XSS Prevention](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html) diff --git a/.agents/skills/nestjs-best-practices/rules/security-use-guards.md b/.agents/skills/nestjs-best-practices/rules/security-use-guards.md new file mode 100644 index 000000000..fb1359c4a --- /dev/null +++ b/.agents/skills/nestjs-best-practices/rules/security-use-guards.md @@ -0,0 +1,135 @@ +--- +title: Use Guards for Authentication and Authorization +impact: HIGH +impactDescription: Enforces access control before handlers execute +tags: security, guards, authentication, authorization +--- + +## Use Guards for Authentication and Authorization + +Guards determine whether a request should be handled based on authentication state, roles, permissions, or other conditions. They run after middleware but before pipes and interceptors, making them ideal for access control. Use guards instead of manual checks in controllers. + +**Incorrect (manual auth checks in every handler):** + +```typescript +// Manual auth checks in every handler +@Controller('admin') +export class AdminController { + @Get('users') + async getUsers(@Request() req) { + if (!req.user) { + throw new UnauthorizedException(); + } + if (!req.user.roles.includes('admin')) { + throw new ForbiddenException(); + } + return this.adminService.getUsers(); + } + + @Delete('users/:id') + async deleteUser(@Request() req, @Param('id') id: string) { + if (!req.user) { + throw new UnauthorizedException(); + } + if (!req.user.roles.includes('admin')) { + throw new ForbiddenException(); + } + return this.adminService.deleteUser(id); + } +} +``` + +**Correct (guards with declarative decorators):** + +```typescript +// JWT Auth Guard +@Injectable() +export class JwtAuthGuard implements CanActivate { + constructor( + private jwtService: JwtService, + private reflector: Reflector, + ) {} + + async canActivate(context: ExecutionContext): Promise { + // Check for @Public() decorator + const isPublic = this.reflector.getAllAndOverride('isPublic', [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) return true; + + const request = context.switchToHttp().getRequest(); + const token = this.extractToken(request); + + if (!token) { + throw new UnauthorizedException('No token provided'); + } + + try { + request.user = await this.jwtService.verifyAsync(token); + return true; + } catch { + throw new UnauthorizedException('Invalid token'); + } + } + + private extractToken(request: Request): string | undefined { + const [type, token] = request.headers.authorization?.split(' ') ?? []; + return type === 'Bearer' ? token : undefined; + } +} + +// Roles Guard +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredRoles = this.reflector.getAllAndOverride('roles', [ + context.getHandler(), + context.getClass(), + ]); + + if (!requiredRoles) return true; + + const { user } = context.switchToHttp().getRequest(); + return requiredRoles.some((role) => user.roles?.includes(role)); + } +} + +// Decorators +export const Public = () => SetMetadata('isPublic', true); +export const Roles = (...roles: Role[]) => SetMetadata('roles', roles); + +// Register guards globally +@Module({ + providers: [ + { provide: APP_GUARD, useClass: JwtAuthGuard }, + { provide: APP_GUARD, useClass: RolesGuard }, + ], +}) +export class AppModule {} + +// Clean controller +@Controller('admin') +@Roles(Role.Admin) // Applied to all routes +export class AdminController { + @Get('users') + getUsers(): Promise { + return this.adminService.getUsers(); + } + + @Delete('users/:id') + deleteUser(@Param('id') id: string): Promise { + return this.adminService.deleteUser(id); + } + + @Public() // Override: no auth required + @Get('health') + health() { + return { status: 'ok' }; + } +} +``` + +Reference: [NestJS Guards](https://docs.nestjs.com/guards) diff --git a/.agents/skills/nestjs-best-practices/rules/security-validate-all-input.md b/.agents/skills/nestjs-best-practices/rules/security-validate-all-input.md new file mode 100644 index 000000000..f489d064e --- /dev/null +++ b/.agents/skills/nestjs-best-practices/rules/security-validate-all-input.md @@ -0,0 +1,150 @@ +--- +title: Validate All Input with DTOs and Pipes +impact: HIGH +impactDescription: First line of defense against attacks +tags: security, validation, dto, pipes +--- + +## Validate All Input with DTOs and Pipes + +Always validate incoming data using class-validator decorators on DTOs and the global ValidationPipe. Never trust user input. Validate all request bodies, query parameters, and route parameters before processing. + +**Incorrect (trust raw input without validation):** + +```typescript +// Trust raw input without validation +@Controller('users') +export class UsersController { + @Post() + create(@Body() body: any) { + // body could contain anything - SQL injection, XSS, etc. + return this.usersService.create(body); + } + + @Get() + findAll(@Query() query: any) { + // query.limit could be "'; DROP TABLE users; --" + return this.usersService.findAll(query.limit); + } +} + +// DTOs without validation decorators +export class CreateUserDto { + name: string; // No validation + email: string; // Could be "not-an-email" + age: number; // Could be "abc" or -999 +} +``` + +**Correct (validated DTOs with global ValidationPipe):** + +```typescript +// Enable ValidationPipe globally in main.ts +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, // Strip unknown properties + forbidNonWhitelisted: true, // Throw on unknown properties + transform: true, // Auto-transform to DTO types + transformOptions: { + enableImplicitConversion: true, + }, + }), + ); + + await app.listen(3000); +} + +// Create well-validated DTOs +import { + IsString, + IsEmail, + IsInt, + Min, + Max, + IsOptional, + MinLength, + MaxLength, + Matches, + IsNotEmpty, +} from 'class-validator'; +import { Transform, Type } from 'class-transformer'; + +export class CreateUserDto { + @IsString() + @IsNotEmpty() + @MinLength(2) + @MaxLength(100) + @Transform(({ value }) => value?.trim()) + name: string; + + @IsEmail() + @Transform(({ value }) => value?.toLowerCase().trim()) + email: string; + + @IsInt() + @Min(0) + @Max(150) + age: number; + + @IsString() + @MinLength(8) + @MaxLength(100) + @Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, { + message: 'Password must contain uppercase, lowercase, and number', + }) + password: string; +} + +// Query DTO with defaults and transformation +export class FindUsersQueryDto { + @IsOptional() + @IsString() + @MaxLength(100) + search?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit: number = 20; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + offset: number = 0; +} + +// Param validation +export class UserIdParamDto { + @IsUUID('4') + id: string; +} + +@Controller('users') +export class UsersController { + @Post() + create(@Body() dto: CreateUserDto): Promise { + // dto is guaranteed to be valid + return this.usersService.create(dto); + } + + @Get() + findAll(@Query() query: FindUsersQueryDto): Promise { + // query.limit is a number, query.search is sanitized + return this.usersService.findAll(query); + } + + @Get(':id') + findOne(@Param() params: UserIdParamDto): Promise { + // params.id is a valid UUID + return this.usersService.findById(params.id); + } +} +``` + +Reference: [NestJS Validation](https://docs.nestjs.com/techniques/validation) diff --git a/.agents/skills/nestjs-best-practices/rules/test-e2e-supertest.md b/.agents/skills/nestjs-best-practices/rules/test-e2e-supertest.md new file mode 100644 index 000000000..426551385 --- /dev/null +++ b/.agents/skills/nestjs-best-practices/rules/test-e2e-supertest.md @@ -0,0 +1,178 @@ +--- +title: Use Supertest for E2E Testing +impact: HIGH +impactDescription: Validates the full request/response cycle +tags: testing, e2e, supertest, integration +--- + +## Use Supertest for E2E Testing + +End-to-end tests use Supertest to make real HTTP requests against your NestJS application. They test the full stack including middleware, guards, pipes, and interceptors. E2E tests catch integration issues that unit tests miss. + +**Incorrect (no proper E2E setup or teardown):** + +```typescript +// Only unit test controllers +describe('UsersController', () => { + it('should return users', async () => { + const service = { findAll: jest.fn().mockResolvedValue([]) }; + const controller = new UsersController(service as any); + + const result = await controller.findAll(); + + expect(result).toEqual([]); + // Doesn't test: routes, guards, pipes, serialization + }); +}); + +// E2E tests without proper setup/teardown +describe('Users API', () => { + it('should create user', async () => { + const app = await NestFactory.create(AppModule); + // No proper initialization + // No cleanup after test + // Hits real database + }); +}); +``` + +**Correct (proper E2E setup with Supertest):** + +```typescript +// Proper E2E test setup +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import * as request from 'supertest'; +import { AppModule } from '../src/app.module'; + +describe('UsersController (e2e)', () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + + // Apply same config as production + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: true, + }), + ); + + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('/users (POST)', () => { + it('should create a user', () => { + return request(app.getHttpServer()) + .post('/users') + .send({ name: 'John', email: 'john@test.com' }) + .expect(201) + .expect((res) => { + expect(res.body).toHaveProperty('id'); + expect(res.body.name).toBe('John'); + expect(res.body.email).toBe('john@test.com'); + }); + }); + + it('should return 400 for invalid email', () => { + return request(app.getHttpServer()) + .post('/users') + .send({ name: 'John', email: 'invalid-email' }) + .expect(400) + .expect((res) => { + expect(res.body.message).toContain('email'); + }); + }); + }); + + describe('/users/:id (GET)', () => { + it('should return 404 for non-existent user', () => { + return request(app.getHttpServer()) + .get('/users/non-existent-id') + .expect(404); + }); + }); +}); + +// Testing with authentication +describe('Protected Routes (e2e)', () => { + let app: INestApplication; + let authToken: string; + + beforeAll(async () => { + const moduleFixture = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ whitelist: true })); + await app.init(); + + // Get auth token + const loginResponse = await request(app.getHttpServer()) + .post('/auth/login') + .send({ email: 'test@test.com', password: 'password' }); + + authToken = loginResponse.body.accessToken; + }); + + it('should return 401 without token', () => { + return request(app.getHttpServer()) + .get('/users/me') + .expect(401); + }); + + it('should return user profile with valid token', () => { + return request(app.getHttpServer()) + .get('/users/me') + .set('Authorization', `Bearer ${authToken}`) + .expect(200) + .expect((res) => { + expect(res.body.email).toBe('test@test.com'); + }); + }); +}); + +// Database isolation for E2E tests +describe('Orders API (e2e)', () => { + let app: INestApplication; + let dataSource: DataSource; + + beforeAll(async () => { + const moduleFixture = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + envFilePath: '.env.test', // Test database config + }), + AppModule, + ], + }).compile(); + + app = moduleFixture.createNestApplication(); + dataSource = moduleFixture.get(DataSource); + await app.init(); + }); + + beforeEach(async () => { + // Clean database between tests + await dataSource.synchronize(true); + }); + + afterAll(async () => { + await dataSource.destroy(); + await app.close(); + }); +}); +``` + +Reference: [NestJS E2E Testing](https://docs.nestjs.com/fundamentals/testing#end-to-end-testing) diff --git a/.agents/skills/nestjs-best-practices/rules/test-mock-external-services.md b/.agents/skills/nestjs-best-practices/rules/test-mock-external-services.md new file mode 100644 index 000000000..e29b595ad --- /dev/null +++ b/.agents/skills/nestjs-best-practices/rules/test-mock-external-services.md @@ -0,0 +1,179 @@ +--- +title: Mock External Services in Tests +impact: HIGH +impactDescription: Ensures fast, reliable, deterministic tests +tags: testing, mocking, external-services, jest +--- + +## Mock External Services in Tests + +Never call real external services (APIs, databases, message queues) in unit tests. Mock them to ensure tests are fast, deterministic, and don't incur costs. Use realistic mock data and test edge cases like timeouts and errors. + +**Incorrect (calling real APIs and databases):** + +```typescript +// Call real APIs in tests +describe('PaymentService', () => { + it('should process payment', async () => { + const service = new PaymentService(new StripeClient(realApiKey)); + // Hits real Stripe API! + const result = await service.charge('tok_visa', 1000); + // Slow, costs money, flaky + }); +}); + +// Use real database +describe('UsersService', () => { + beforeEach(async () => { + await connection.query('DELETE FROM users'); // Modifies real DB + }); + + it('should create user', async () => { + const user = await service.create({ email: 'test@test.com' }); + // Side effects on shared database + }); +}); + +// Incomplete mocks +const mockHttpService = { + get: jest.fn().mockResolvedValue({ data: {} }), + // Missing error scenarios, missing other methods +}; +``` + +**Correct (mock all external dependencies):** + +```typescript +// Mock HTTP service properly +describe('WeatherService', () => { + let service: WeatherService; + let httpService: jest.Mocked; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + providers: [ + WeatherService, + { + provide: HttpService, + useValue: { + get: jest.fn(), + post: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(WeatherService); + httpService = module.get(HttpService); + }); + + it('should return weather data', async () => { + const mockResponse = { + data: { temperature: 72, humidity: 45 }, + status: 200, + statusText: 'OK', + headers: {}, + config: {}, + }; + + httpService.get.mockReturnValue(of(mockResponse)); + + const result = await service.getWeather('NYC'); + + expect(result).toEqual({ temperature: 72, humidity: 45 }); + }); + + it('should handle API timeout', async () => { + httpService.get.mockReturnValue( + throwError(() => new Error('ETIMEDOUT')), + ); + + await expect(service.getWeather('NYC')).rejects.toThrow('Weather service unavailable'); + }); + + it('should handle rate limiting', async () => { + httpService.get.mockReturnValue( + throwError(() => ({ + response: { status: 429, data: { message: 'Rate limited' } }, + })), + ); + + await expect(service.getWeather('NYC')).rejects.toThrow(TooManyRequestsException); + }); +}); + +// Mock repository instead of database +describe('UsersService', () => { + let service: UsersService; + let repo: jest.Mocked>; + + beforeEach(async () => { + const mockRepo = { + find: jest.fn(), + findOne: jest.fn(), + save: jest.fn(), + delete: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const module = await Test.createTestingModule({ + providers: [ + UsersService, + { provide: getRepositoryToken(User), useValue: mockRepo }, + ], + }).compile(); + + service = module.get(UsersService); + repo = module.get(getRepositoryToken(User)); + }); + + it('should find user by id', async () => { + const mockUser = { id: '1', name: 'John', email: 'john@test.com' }; + repo.findOne.mockResolvedValue(mockUser); + + const result = await service.findById('1'); + + expect(result).toEqual(mockUser); + expect(repo.findOne).toHaveBeenCalledWith({ where: { id: '1' } }); + }); +}); + +// Create mock factory for complex SDKs +function createMockStripe(): jest.Mocked { + return { + paymentIntents: { + create: jest.fn(), + retrieve: jest.fn(), + confirm: jest.fn(), + cancel: jest.fn(), + }, + customers: { + create: jest.fn(), + retrieve: jest.fn(), + }, + } as any; +} + +// Mock time for time-dependent tests +describe('TokenService', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2024-01-15')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should expire token after 1 hour', async () => { + const token = await service.createToken(); + + // Fast-forward time + jest.advanceTimersByTime(61 * 60 * 1000); + + expect(await service.isValid(token)).toBe(false); + }); +}); +``` + +Reference: [Jest Mocking](https://jestjs.io/docs/mock-functions) diff --git a/.agents/skills/nestjs-best-practices/rules/test-use-testing-module.md b/.agents/skills/nestjs-best-practices/rules/test-use-testing-module.md new file mode 100644 index 000000000..de256f57d --- /dev/null +++ b/.agents/skills/nestjs-best-practices/rules/test-use-testing-module.md @@ -0,0 +1,153 @@ +--- +title: Use Testing Module for Unit Tests +impact: HIGH +impactDescription: Enables proper isolated testing with mocked dependencies +tags: testing, unit-tests, mocking, jest +--- + +## Use Testing Module for Unit Tests + +Use `@nestjs/testing` module to create isolated test environments with mocked dependencies. This ensures your tests run fast, don't depend on external services, and properly test your business logic in isolation. + +**Incorrect (manual instantiation bypassing DI):** + +```typescript +// Instantiate services manually without DI +describe('UsersService', () => { + it('should create user', async () => { + // Manual instantiation bypasses DI + const repo = new UserRepository(); // Real repo! + const service = new UsersService(repo); + + const user = await service.create({ name: 'Test' }); + // This hits the real database! + }); +}); + +// Test implementation details +describe('UsersController', () => { + it('should call service', async () => { + const service = { create: jest.fn() }; + const controller = new UsersController(service as any); + + await controller.create({ name: 'Test' }); + + expect(service.create).toHaveBeenCalled(); // Tests implementation, not behavior + }); +}); +``` + +**Correct (use Test.createTestingModule with mocked dependencies):** + +```typescript +// Use Test.createTestingModule for proper DI +import { Test, TestingModule } from '@nestjs/testing'; + +describe('UsersService', () => { + let service: UsersService; + let repo: jest.Mocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UsersService, + { + provide: UserRepository, + useValue: { + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(UsersService); + repo = module.get(UserRepository); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('create', () => { + it('should save and return user', async () => { + const dto = { name: 'John', email: 'john@test.com' }; + const expectedUser = { id: '1', ...dto }; + + repo.save.mockResolvedValue(expectedUser); + + const result = await service.create(dto); + + expect(result).toEqual(expectedUser); + expect(repo.save).toHaveBeenCalledWith(dto); + }); + + it('should throw on duplicate email', async () => { + repo.findOne.mockResolvedValue({ id: '1', email: 'test@test.com' }); + + await expect( + service.create({ name: 'Test', email: 'test@test.com' }), + ).rejects.toThrow(ConflictException); + }); + }); + + describe('findById', () => { + it('should return user when found', async () => { + const user = { id: '1', name: 'John' }; + repo.findOne.mockResolvedValue(user); + + const result = await service.findById('1'); + + expect(result).toEqual(user); + }); + + it('should throw NotFoundException when not found', async () => { + repo.findOne.mockResolvedValue(null); + + await expect(service.findById('999')).rejects.toThrow(NotFoundException); + }); + }); +}); + +// Testing guards and interceptors +describe('RolesGuard', () => { + let guard: RolesGuard; + let reflector: Reflector; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + providers: [RolesGuard, Reflector], + }).compile(); + + guard = module.get(RolesGuard); + reflector = module.get(Reflector); + }); + + it('should allow when no roles required', () => { + const context = createMockExecutionContext({ user: { roles: [] } }); + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(undefined); + + expect(guard.canActivate(context)).toBe(true); + }); + + it('should allow admin for admin-only route', () => { + const context = createMockExecutionContext({ user: { roles: ['admin'] } }); + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(['admin']); + + expect(guard.canActivate(context)).toBe(true); + }); +}); + +function createMockExecutionContext(request: Partial): ExecutionContext { + return { + switchToHttp: () => ({ + getRequest: () => request, + }), + getHandler: () => jest.fn(), + getClass: () => jest.fn(), + } as ExecutionContext; +} +``` + +Reference: [NestJS Testing](https://docs.nestjs.com/fundamentals/testing) diff --git a/.claude/skills/nestjs-best-practices b/.claude/skills/nestjs-best-practices new file mode 120000 index 000000000..ed0955adb --- /dev/null +++ b/.claude/skills/nestjs-best-practices @@ -0,0 +1 @@ +../../.agents/skills/nestjs-best-practices \ No newline at end of file diff --git a/.prettierignore b/.prettierignore index d04427c52..ff97b3cbc 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,5 @@ /.agents/skills/angular-developer +/.agents/skills/nestjs-best-practices /.nx/cache /.nx/self-healing /.nx/workspace-data diff --git a/CHANGELOG.md b/CHANGELOG.md index 4202b4868..93102f212 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Added `nestjs-best-practices` skills + ### Changed - Migrated various components from `NgClass` to class bindings diff --git a/skills-lock.json b/skills-lock.json index a15c56b59..0ecf51f96 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -6,6 +6,12 @@ "sourceType": "github", "skillPath": "angular-developer/SKILL.md", "computedHash": "28eb592b92e5a24c4e3a1c0229a854069f0b8c49bed7b8d2bf6b852812dbe214" + }, + "nestjs-best-practices": { + "source": "kadajett/agent-nestjs-skills", + "sourceType": "github", + "skillPath": "SKILL.md", + "computedHash": "1b6f82e889d19d305e38e35594de08eca0242321f353cafa4cf5e61dd3aa1a73" } } } From d400152360d6a4ffa12a521382a436530675a01c Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 14 May 2026 07:15:56 +0300 Subject: [PATCH 048/132] Task/upgrade @internationalized/number to version 3.6.6 (#6818) * Upgrade @internationalized/number to version 3.6.6 * Update changelog --- CHANGELOG.md | 1 + package-lock.json | 8 ++++---- package.json | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93102f212..b16721638 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Migrated various components from `NgClass` to class bindings - Refreshed the cryptocurrencies list +- Upgraded `@internationalized/number` from version `3.6.5` to `3.6.6` - Upgraded `@ionic/angular` from version `8.8.1` to `8.8.5` - Upgraded `@openrouter/ai-sdk-provider` from version `0.7.2` to `2.9.0` - Upgraded `ai` from version `4.3.16` to `6.0.174` diff --git a/package-lock.json b/package-lock.json index c93593880..56b65c55a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ "@bull-board/nestjs": "7.0.0", "@codewithdan/observable-store": "2.2.15", "@date-fns/utc": "2.1.1", - "@internationalized/number": "3.6.5", + "@internationalized/number": "3.6.6", "@ionic/angular": "8.8.5", "@keyv/redis": "4.4.0", "@nestjs/bull": "11.0.4", @@ -4984,9 +4984,9 @@ } }, "node_modules/@internationalized/number": { - "version": "3.6.5", - "resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.5.tgz", - "integrity": "sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g==", + "version": "3.6.6", + "resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.6.tgz", + "integrity": "sha512-iFgmQaXHE0vytNfpLZWOC2mEJCBRzcUxt53Xf/yCXG93lRvqas237i3r7X4RKMwO3txiyZD4mQjKAByFv6UGSQ==", "license": "Apache-2.0", "dependencies": { "@swc/helpers": "^0.5.0" diff --git a/package.json b/package.json index 7190ff92d..b7643e33b 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,7 @@ "@bull-board/nestjs": "7.0.0", "@codewithdan/observable-store": "2.2.15", "@date-fns/utc": "2.1.1", - "@internationalized/number": "3.6.5", + "@internationalized/number": "3.6.6", "@ionic/angular": "8.8.5", "@keyv/redis": "4.4.0", "@nestjs/bull": "11.0.4", From 15ba37e20c7c8fc78b8aa94fd06b7d44cff71255 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 14 May 2026 07:19:43 +0300 Subject: [PATCH 049/132] Task/refactor deprecated portfolio position attributes to asset profile in AI service (#6774) Refactor deprecated portfolio position attributes to asset profile --- apps/api/src/app/endpoints/ai/ai.service.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/api/src/app/endpoints/ai/ai.service.ts b/apps/api/src/app/endpoints/ai/ai.service.ts index 362f4a728..d0ef17844 100644 --- a/apps/api/src/app/endpoints/ai/ai.service.ts +++ b/apps/api/src/app/endpoints/ai/ai.service.ts @@ -101,11 +101,13 @@ export class AiService { .map( ({ allocationInPercentage, - assetClass, - assetSubClass, - currency, - name: label, - symbol + assetProfile: { + assetClass, + assetSubClass, + currency, + name: label, + symbol + } }) => { return AiService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.reduce( (row, { key, name }) => { From 348496d5b82677ced991f75faa9ac181b3b4be51 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 14 May 2026 08:42:08 +0300 Subject: [PATCH 050/132] Task/clean up webpack bundle analyzer setup (#6821) * Clean up Webpack Bundle Analyzer setup * Update changelog --- CHANGELOG.md | 1 + package-lock.json | 134 +--------------------------------------------- package.json | 4 +- 3 files changed, 3 insertions(+), 136 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b16721638..5e53d4dbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Migrated various components from `NgClass` to class bindings - Refreshed the cryptocurrencies list +- Cleaned up the _Webpack Bundle Analyzer_ setup - Upgraded `@internationalized/number` from version `3.6.5` to `3.6.6` - Upgraded `@ionic/angular` from version `8.8.1` to `8.8.5` - Upgraded `@openrouter/ai-sdk-provider` from version `0.7.2` to `2.9.0` diff --git a/package-lock.json b/package-lock.json index 56b65c55a..0b8911b43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -161,8 +161,7 @@ "ts-jest": "29.4.0", "ts-node": "10.9.2", "tslib": "2.8.1", - "typescript": "5.9.2", - "webpack-bundle-analyzer": "4.10.2" + "typescript": "5.9.2" }, "engines": { "node": ">=22.18.0" @@ -11316,13 +11315,6 @@ "url": "https://opencollective.com/pkgr" } }, - "node_modules/@polka/url": { - "version": "1.0.0-next.29", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", - "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", - "dev": true, - "license": "MIT" - }, "node_modules/@prisma/adapter-pg": { "version": "7.7.0", "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.7.0.tgz", @@ -20572,13 +20564,6 @@ "optional": true, "peer": true }, - "node_modules/debounce": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", - "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==", - "dev": true, - "license": "MIT" - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -21065,13 +21050,6 @@ "node": ">= 0.4" } }, - "node_modules/duplexer": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", - "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", - "dev": true, - "license": "MIT" - }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -23744,22 +23722,6 @@ "node": ">=10" } }, - "node_modules/gzip-size": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", - "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "duplexer": "^0.1.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/hachure-fill": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", @@ -35888,21 +35850,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/sirv": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", - "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@polka/url": "^1.0.0-next.24", - "mrmime": "^2.0.0", - "totalist": "^3.0.0" - }, - "engines": { - "node": ">= 10" - } - }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -37317,16 +37264,6 @@ "url": "https://github.com/sponsors/Borewit" } }, - "node_modules/totalist": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", - "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/tough-cookie": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", @@ -38933,75 +38870,6 @@ } } }, - "node_modules/webpack-bundle-analyzer": { - "version": "4.10.2", - "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.2.tgz", - "integrity": "sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@discoveryjs/json-ext": "0.5.7", - "acorn": "^8.0.4", - "acorn-walk": "^8.0.0", - "commander": "^7.2.0", - "debounce": "^1.2.1", - "escape-string-regexp": "^4.0.0", - "gzip-size": "^6.0.0", - "html-escaper": "^2.0.2", - "opener": "^1.5.2", - "picocolors": "^1.0.0", - "sirv": "^2.0.3", - "ws": "^7.3.1" - }, - "bin": { - "webpack-bundle-analyzer": "lib/bin/analyzer.js" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/webpack-bundle-analyzer/node_modules/@discoveryjs/json-ext": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", - "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/webpack-bundle-analyzer/node_modules/commander": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/webpack-bundle-analyzer/node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/webpack-dev-middleware": { "version": "7.4.5", "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-7.4.5.tgz", diff --git a/package.json b/package.json index b7643e33b..f748d0baf 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,6 @@ "affected:libs": "nx affected:libs", "affected:lint": "nx affected:lint", "affected:test": "nx affected:test", - "analyze:client": "nx run client:build:production --stats-json && webpack-bundle-analyzer -p 1234 dist/apps/client/en/stats.json", "angular": "node --max_old_space_size=32768 ./node_modules/@angular/cli/bin/ng", "build:production": "nx run api:copy-assets && nx run api:build:production && nx run client:copy-assets && nx run client:build:production && nx run ui:build-storybook && npm run replace-placeholders-in-build", "build:storybook": "nx run ui:build-storybook", @@ -206,8 +205,7 @@ "ts-jest": "29.4.0", "ts-node": "10.9.2", "tslib": "2.8.1", - "typescript": "5.9.2", - "webpack-bundle-analyzer": "4.10.2" + "typescript": "5.9.2" }, "engines": { "node": ">=22.18.0" From d720651f786c0729166a82eed3ea3acd13ae8dc9 Mon Sep 17 00:00:00 2001 From: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com> Date: Thu, 14 May 2026 15:39:02 +0700 Subject: [PATCH 051/132] Bugfix/resolve styling issues by splitting space-separated class bindings (#6848) * fix(ui): large size not working in value component * fix(ui): historical market data style not applied --- ...istorical-market-data-editor.component.html | 18 ++++++++++-------- libs/ui/src/lib/value/value.component.html | 14 ++++++-------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.html b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.html index 93fd8d97f..065e62de1 100644 --- a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.html +++ b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.html @@ -6,14 +6,16 @@ @for (day of days; track day) {
    @if (value === null) { ***** @@ -60,10 +59,9 @@ @if (isString) {
    {{ formattedValue }}
    From a146fd8d92eaeca081b669a1190719e13343d98a Mon Sep 17 00:00:00 2001 From: Jorge Date: Thu, 14 May 2026 10:53:02 +0200 Subject: [PATCH 052/132] Task/improve language localization for ES (#6852) * Update translations * Update changelog --- CHANGELOG.md | 1 + apps/client/src/locales/messages.es.xlf | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e53d4dbb..cc03ee417 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Migrated various components from `NgClass` to class bindings - Refreshed the cryptocurrencies list +- Improved the language localization for Spanish (`es`) - Cleaned up the _Webpack Bundle Analyzer_ setup - Upgraded `@internationalized/number` from version `3.6.5` to `3.6.6` - Upgraded `@ionic/angular` from version `8.8.1` to `8.8.5` diff --git a/apps/client/src/locales/messages.es.xlf b/apps/client/src/locales/messages.es.xlf index 62befb2ae..69aefb793 100644 --- a/apps/client/src/locales/messages.es.xlf +++ b/apps/client/src/locales/messages.es.xlf @@ -1612,7 +1612,7 @@ Duration - Duration + Duración apps/client/src/app/components/admin-overview/admin-overview.html 172 @@ -2760,7 +2760,7 @@ Code - Code + Código apps/client/src/app/components/admin-overview/admin-overview.html 159 @@ -3756,7 +3756,7 @@ Loan - Loan + Préstamo libs/ui/src/lib/i18n.ts 59 @@ -5012,7 +5012,7 @@ saying no to spreadsheets in - diciendo no a las hojas de cálculo en + en contra de las las hojas de cálculo en apps/client/src/app/pages/landing/landing-page.html 209 @@ -6959,7 +6959,7 @@ has been copied to the clipboard - has been copied to the clipboard + ha sido copiado al portapapeles apps/client/src/app/components/admin-overview/admin-overview.component.ts 377 From c6fa3c9e1331b8fa37ec8f2a2524eece343116fc Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 14 May 2026 12:05:14 +0300 Subject: [PATCH 053/132] Task/migrate ion-icon to self-closing tag in holding detail dialog (#6814) Migrate to self-closing tag --- .../holding-detail-dialog/holding-detail-dialog.html | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html index dbdaf0c69..7c1906ee5 100644 --- a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html +++ b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html @@ -434,11 +434,10 @@ mat-stroked-button (click)="onCloseHolding()" > - Close Holding + Close Holding } @if ( From 9438e3134bc743e90bda6294bb083c369bc8a12c Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 14 May 2026 12:21:20 +0300 Subject: [PATCH 054/132] Task/upgrade prisma to version 7.8.0 (#6830) * Upgrade prisma to version 7.8.0 * Update changelog --- CHANGELOG.md | 1 + package-lock.json | 265 +++++++++++++++++----------------------------- package.json | 6 +- 3 files changed, 102 insertions(+), 170 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc03ee417..0aa0078b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Upgraded `fuse.js` from version `7.1.0` to `7.3.0` - Upgraded `Nx` from version `22.6.5` to `22.7.1` - Upgraded `papaparse` from version `5.3.1` to `5.5.3` +- Upgraded `prisma` from version `7.7.0` to `7.8.0` ### Fixed diff --git a/package-lock.json b/package-lock.json index 0b8911b43..f48d1d4dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,8 +41,8 @@ "@nestjs/schedule": "6.1.3", "@nestjs/serve-static": "5.0.5", "@openrouter/ai-sdk-provider": "2.9.0", - "@prisma/adapter-pg": "7.7.0", - "@prisma/client": "7.7.0", + "@prisma/adapter-pg": "7.8.0", + "@prisma/client": "7.8.0", "@simplewebauthn/browser": "13.2.2", "@simplewebauthn/server": "13.2.2", "ai": "6.0.174", @@ -152,7 +152,7 @@ "nx": "22.7.1", "prettier": "3.8.3", "prettier-plugin-organize-attributes": "1.0.0", - "prisma": "7.7.0", + "prisma": "7.8.0", "react": "18.2.0", "react-dom": "18.2.0", "replace-in-file": "8.4.0", @@ -11316,24 +11316,24 @@ } }, "node_modules/@prisma/adapter-pg": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.7.0.tgz", - "integrity": "sha512-q33Ta8sKbgzEpAy0lx45tAq//yMv0qcb+8nj+TCA3P4wiAY+OBFEFk/NDkZncAfHaNJeGo5WJpJdpbL+ijYx8g==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.8.0.tgz", + "integrity": "sha512-ygb3UkerK3v8MDpXVgCISdRNDozpxh6+JVJgiIGbSr5KBgz10LLf5ejUskPGoXlsIjxsOu6nuy1JVQr2EKGSlg==", "license": "Apache-2.0", "dependencies": { - "@prisma/driver-adapter-utils": "7.7.0", + "@prisma/driver-adapter-utils": "7.8.0", "@types/pg": "^8.16.0", "pg": "^8.16.3", "postgres-array": "3.0.4" } }, "node_modules/@prisma/client": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.7.0.tgz", - "integrity": "sha512-5Ar4OsZpJ54s21sy5oDNNW9gQtd4NuxCaiM7+JDTOU07D6VvlpLjYzAVCMB1+JzokN+08dAVomlx+b7bhJd3ww==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.8.0.tgz", + "integrity": "sha512-HFp3Dawv/3sU3JtlPha90IB+48lS7zHiH4LKZPjmcE8YH5P9DOXGPvo8dqOtO7MqLDd1p2hOWMcFlRT1DMblHw==", "license": "Apache-2.0", "dependencies": { - "@prisma/client-runtime-utils": "7.7.0" + "@prisma/client-runtime-utils": "7.8.0" }, "engines": { "node": "^20.19 || ^22.12 || >=24.0" @@ -11352,28 +11352,28 @@ } }, "node_modules/@prisma/client-runtime-utils": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.7.0.tgz", - "integrity": "sha512-BLyd0UpFYOtyJFTHm7jS9vesHW7P83abibodQMiIofqjBKzDHQ1VAsQkdfvXyYDkPlONPfOTz7/rv3x/+CQqvQ==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.8.0.tgz", + "integrity": "sha512-5NQZztQ0oY/ADFkmd9gPuweH5A1/CCY8YQPorLLO0Mu6a87mY5gsnDkzmFmIHs9NFaLnZojzgddFVN4RpKYrdw==", "license": "Apache-2.0" }, "node_modules/@prisma/config": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.7.0.tgz", - "integrity": "sha512-hmPI3tKLO2aP0Y5vugbjcnA9qqlfJndiT6ds4tw28U5hNHLWg+mHJEWAhjsSPgxjtmxhJ/EDIeIlyh+3Us0OPg==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.8.0.tgz", + "integrity": "sha512-HFESzd9rx2ZQxlK+TL7tu1HPvCqrHiL6LCxYykI2c34mvaUuIVVl3lYuicJD/MNnzgPnyeBEMlK4WTomJCV5jw==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "c12": "3.1.0", + "c12": "3.3.4", "deepmerge-ts": "7.1.5", "effect": "3.20.0", "empathic": "2.0.0" } }, "node_modules/@prisma/debug": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.7.0.tgz", - "integrity": "sha512-12J62XdqCmpiwJHhHdQxZeY3ckVCWIFmcJP8hg5dPTceeiQ0wiojXGFYTluKqFQfu46fRLgb/rLALZMAx3+dTA==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.8.0.tgz", + "integrity": "sha512-p+QZReysDUqXC+mk17q9a+Y/qzh4c2KYliDK30buYUyfrGeTGSyfmc0AIrJRhZJrLHhRiJa9Au/J72h3C+szvA==", "license": "Apache-2.0" }, "node_modules/@prisma/dev": { @@ -11403,65 +11403,65 @@ } }, "node_modules/@prisma/driver-adapter-utils": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.7.0.tgz", - "integrity": "sha512-gZXREeu6mOk7zXfGFJgh86p7Vhj0sXNKp+4Cg1tWYo7V2dfncP2qxS2BiTmbIIha8xPqItkl0WSw38RuSq1HoQ==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.8.0.tgz", + "integrity": "sha512-/Q13o0ZT0rjc1Xk0Q9KhZYwuq2EW/vSbWUBKfgEKkaCuB/Sg6bqnjmTZqC5cD4d6y1vfFAEwBRzfzoSMIVJ55A==", "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.7.0" + "@prisma/debug": "7.8.0" } }, "node_modules/@prisma/engines": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.7.0.tgz", - "integrity": "sha512-7fmcbT7HHXBq/b+3h/dO1JI3fd8l8q7erf7xP7pRprh58hmSSnG8mg9K3yjW3h9WaHWUwngVFpSxxxivaitQ2w==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.8.0.tgz", + "integrity": "sha512-jx3rCnNNrt5uzbkKlegtQ2GZHxSlihMCzutgT/BP6UIDF1r9tDI39hV/0T/cHZgzJ3ELbuQPXlVZy+Y1n0pcgw==", "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.7.0", - "@prisma/engines-version": "7.6.0-1.75cbdc1eb7150937890ad5465d861175c6624711", - "@prisma/fetch-engine": "7.7.0", - "@prisma/get-platform": "7.7.0" + "@prisma/debug": "7.8.0", + "@prisma/engines-version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", + "@prisma/fetch-engine": "7.8.0", + "@prisma/get-platform": "7.8.0" } }, "node_modules/@prisma/engines-version": { - "version": "7.6.0-1.75cbdc1eb7150937890ad5465d861175c6624711", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.6.0-1.75cbdc1eb7150937890ad5465d861175c6624711.tgz", - "integrity": "sha512-r51DLcJ8bDRSrBEJF3J4cinoWyGA7rfP2mG6lD90VqIbGNOkbfcLcXalSVjq5Y6brQS3vcjrq4GbyUb1Cb7vkw==", + "version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a.tgz", + "integrity": "sha512-fJPQxCkLgA5EayWaW8eArgCvjJ+N+Kz3VyeNKMEeYiQC4alNkxRKFVAGxv/ZUzuJISKqdw+zGeDbS6mn6RCPOA==", "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.7.0.tgz", - "integrity": "sha512-MEUNzvKxvYnJ7kgvd6oNRnMmmiGNS9TYLB2weMeIXplnHdL/UWEGnvavYGnN7KLJ2n0iI4dDAyzSkHI3c7AscQ==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", + "integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.7.0" + "@prisma/debug": "7.8.0" } }, "node_modules/@prisma/fetch-engine": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.7.0.tgz", - "integrity": "sha512-TfyzveBQoK4xALzsTpVhB/0KG1N8zOK0ap+RnBMkzGUu3f98fnQ4QtXa2wlKPhsO2X8a3N5ugFQgcKNoHGmDfw==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.8.0.tgz", + "integrity": "sha512-gwB0Euiz/DDRyxFRpLXYlK3RfaZUj1c5dAYMuhZYfApg7arknJlcb9bIsOHDppJmbqYaVA+yBIiFMDBfprsNPQ==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.7.0", - "@prisma/engines-version": "7.6.0-1.75cbdc1eb7150937890ad5465d861175c6624711", - "@prisma/get-platform": "7.7.0" + "@prisma/debug": "7.8.0", + "@prisma/engines-version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", + "@prisma/get-platform": "7.8.0" } }, "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.7.0.tgz", - "integrity": "sha512-MEUNzvKxvYnJ7kgvd6oNRnMmmiGNS9TYLB2weMeIXplnHdL/UWEGnvavYGnN7KLJ2n0iI4dDAyzSkHI3c7AscQ==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", + "integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.7.0" + "@prisma/debug": "7.8.0" } }, "node_modules/@prisma/get-platform": { @@ -17532,27 +17532,27 @@ } }, "node_modules/c12": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", - "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz", + "integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==", "devOptional": true, "license": "MIT", "dependencies": { - "chokidar": "^4.0.3", - "confbox": "^0.2.2", - "defu": "^6.1.4", - "dotenv": "^16.6.1", - "exsolve": "^1.0.7", - "giget": "^2.0.0", - "jiti": "^2.4.2", + "chokidar": "^5.0.0", + "confbox": "^0.2.4", + "defu": "^6.1.6", + "dotenv": "^17.3.1", + "exsolve": "^1.0.8", + "giget": "^3.2.0", + "jiti": "^2.6.1", "ohash": "^2.0.11", "pathe": "^2.0.3", - "perfect-debounce": "^1.0.0", - "pkg-types": "^2.2.0", - "rc9": "^2.1.2" + "perfect-debounce": "^2.1.0", + "pkg-types": "^2.3.0", + "rc9": "^3.0.1" }, "peerDependencies": { - "magicast": "^0.3.5" + "magicast": "*" }, "peerDependenciesMeta": { "magicast": { @@ -17560,22 +17560,6 @@ } } }, - "node_modules/c12/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/c12/node_modules/confbox": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", @@ -17584,9 +17568,9 @@ "license": "MIT" }, "node_modules/c12/node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", "devOptional": true, "license": "BSD-2-Clause", "engines": { @@ -17596,30 +17580,26 @@ "url": "https://dotenvx.com" } }, - "node_modules/c12/node_modules/pkg-types": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", - "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "node_modules/c12/node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "devOptional": true, "license": "MIT", - "dependencies": { - "confbox": "^0.2.2", - "exsolve": "^1.0.7", - "pathe": "^2.0.3" + "bin": { + "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/c12/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "node_modules/c12/node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", "devOptional": true, "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" } }, "node_modules/cacache": { @@ -18154,16 +18134,6 @@ "node": ">=8" } }, - "node_modules/citty": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", - "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "consola": "^3.2.3" - } - }, "node_modules/cjs-module-lexer": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", @@ -23404,19 +23374,11 @@ "license": "MIT" }, "node_modules/giget": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", - "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-3.2.0.tgz", + "integrity": "sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==", "devOptional": true, "license": "MIT", - "dependencies": { - "citty": "^0.1.6", - "consola": "^3.4.0", - "defu": "^6.1.4", - "node-fetch-native": "^1.6.6", - "nypm": "^0.6.0", - "pathe": "^2.0.3" - }, "bin": { "giget": "dist/cli.mjs" } @@ -30643,13 +30605,6 @@ } } }, - "node_modules/node-fetch-native": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", - "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", - "devOptional": true, - "license": "MIT" - }, "node_modules/node-fetch/node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -31519,31 +31474,6 @@ "node": ">=12" } }, - "node_modules/nypm": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", - "integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "citty": "^0.2.0", - "pathe": "^2.0.3", - "tinyexec": "^1.0.2" - }, - "bin": { - "nypm": "dist/cli.mjs" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/nypm/node_modules/citty": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", - "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", - "devOptional": true, - "license": "MIT" - }, "node_modules/oauth": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", @@ -32493,9 +32423,9 @@ "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" }, "node_modules/perfect-debounce": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", - "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", "devOptional": true, "license": "MIT" }, @@ -33229,16 +33159,16 @@ } }, "node_modules/prisma": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.7.0.tgz", - "integrity": "sha512-HlgwRBt1uEFB9LStHL4HLYDvoi4BNu1rYA0hPG0zCAEyK9SaZBqp7E5Rjpc3Qh8Lex/ye/svoHZ0OWoFNhWxuQ==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.8.0.tgz", + "integrity": "sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw==", "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@prisma/config": "7.7.0", + "@prisma/config": "7.8.0", "@prisma/dev": "0.24.3", - "@prisma/engines": "7.7.0", + "@prisma/engines": "7.8.0", "@prisma/studio-core": "0.27.3", "mysql2": "3.15.3", "postgres": "3.4.7" @@ -33516,14 +33446,14 @@ } }, "node_modules/rc9": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", - "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz", + "integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==", "devOptional": true, "license": "MIT", "dependencies": { - "defu": "^6.1.4", - "destr": "^2.0.3" + "defu": "^6.1.6", + "destr": "^2.0.5" } }, "node_modules/react": { @@ -37143,8 +37073,9 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", - "devOptional": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=18" } diff --git a/package.json b/package.json index f748d0baf..59a6ccb35 100644 --- a/package.json +++ b/package.json @@ -85,8 +85,8 @@ "@nestjs/schedule": "6.1.3", "@nestjs/serve-static": "5.0.5", "@openrouter/ai-sdk-provider": "2.9.0", - "@prisma/adapter-pg": "7.7.0", - "@prisma/client": "7.7.0", + "@prisma/adapter-pg": "7.8.0", + "@prisma/client": "7.8.0", "@simplewebauthn/browser": "13.2.2", "@simplewebauthn/server": "13.2.2", "ai": "6.0.174", @@ -196,7 +196,7 @@ "nx": "22.7.1", "prettier": "3.8.3", "prettier-plugin-organize-attributes": "1.0.0", - "prisma": "7.7.0", + "prisma": "7.8.0", "react": "18.2.0", "react-dom": "18.2.0", "replace-in-file": "8.4.0", From bbd25ff618d7a38a2afbd796ae8867978c3cb67b Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 14 May 2026 19:00:11 +0300 Subject: [PATCH 055/132] Feature/deactivate delisted asset profiles in FMP service (#6808) * Deactivate delisted asset profiles * Update changelog --- CHANGELOG.md | 1 + .../financial-modeling-prep.service.ts | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0aa0078b6..359ade060 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Deactivated asset profiles automatically on delisting in the _Financial Modeling Prep_ service - Migrated various components from `NgClass` to class bindings - Refreshed the cryptocurrencies list - Improved the language localization for Spanish (`es`) diff --git a/apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts b/apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts index 27391130e..d9a43fc50 100644 --- a/apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts +++ b/apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts @@ -1,5 +1,6 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; +import { AssetProfileDelistedError } from '@ghostfolio/api/services/data-provider/errors/asset-profile-delisted.error'; import { DataProviderInterface, GetAssetProfileParams, @@ -122,7 +123,9 @@ export class FinancialModelingPrepService ).then((res) => res.json()); if (!assetProfile) { - throw new Error(`${symbol} not found`); + throw new AssetProfileDelistedError( + `No data found, ${symbol} (${this.getName()}) may be delisted` + ); } const { assetClass, assetSubClass } = From a8f14a3d2a16712ad61b2c825d0a1d04192cd61d Mon Sep 17 00:00:00 2001 From: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com> Date: Thu, 14 May 2026 23:44:36 +0700 Subject: [PATCH 056/132] Task/migrate to unified mat.theme mixin and add native color-scheme support (#6849) * feat(client): implement mat.theme * feat(client): implement color-scheme * docs: update changelog --- CHANGELOG.md | 1 + apps/client/src/styles/theme.scss | 12 ++++++------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 359ade060..6eb4852e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Synchronized the native browser elements with the theme to improve the dark mode - Fixed a visual regression in the bottom navigation bar on mobile ## 3.2.0 - 2026-05-03 diff --git a/apps/client/src/styles/theme.scss b/apps/client/src/styles/theme.scss index 296270e7f..3cec572cc 100644 --- a/apps/client/src/styles/theme.scss +++ b/apps/client/src/styles/theme.scss @@ -150,7 +150,9 @@ $gf-typography: ( ); .theme-light { - $gf-theme-default: mat.define-theme( + color-scheme: light; + + @include mat.theme( ( color: ( primary: $_primary, @@ -164,8 +166,6 @@ $gf-typography: ( ) ); - @include mat.all-component-themes($gf-theme-default); - @include mat.autocomplete-overrides( ( background-color: var(--light-background) @@ -233,7 +233,9 @@ $gf-typography: ( } .theme-dark { - $gf-theme-dark: mat.define-theme( + color-scheme: dark; + + @include mat.theme( ( color: ( primary: $_primary, @@ -247,8 +249,6 @@ $gf-typography: ( ) ); - @include mat.all-component-themes($gf-theme-dark); - @include mat.button-overrides( ( outlined-label-text-color: var(--light-primary-text), From 1cc1e63b3b4fda82876508b76d7cac4d08894222 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 14 May 2026 19:50:00 +0300 Subject: [PATCH 057/132] Release 3.3.0 (#6860) --- CHANGELOG.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6eb4852e0..f23e1af95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## Unreleased +## 3.3.0 - 2026-05-14 ### Added diff --git a/package-lock.json b/package-lock.json index f48d1d4dc..6cdce7353 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ghostfolio", - "version": "3.2.0", + "version": "3.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ghostfolio", - "version": "3.2.0", + "version": "3.3.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/package.json b/package.json index 59a6ccb35..69c695889 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostfolio", - "version": "3.2.0", + "version": "3.3.0", "homepage": "https://ghostfol.io", "license": "AGPL-3.0", "repository": "https://github.com/ghostfolio/ghostfolio", From fb5716c8a799718d7ac380b804db4902e2ebe94b Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 15 May 2026 10:58:54 +0300 Subject: [PATCH 058/132] Task/refactor deprecated currency to asset profile in public controller (#6775) Refactor deprecated currency to asset profile --- apps/api/src/app/endpoints/public/public.controller.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/src/app/endpoints/public/public.controller.ts b/apps/api/src/app/endpoints/public/public.controller.ts index 33c6052a6..1d6eb6b0b 100644 --- a/apps/api/src/app/endpoints/public/public.controller.ts +++ b/apps/api/src/app/endpoints/public/public.controller.ts @@ -151,11 +151,11 @@ export class PublicController { }; const totalValue = getSum( - Object.values(holdings).map(({ currency, marketPrice, quantity }) => { + Object.values(holdings).map(({ assetProfile, marketPrice, quantity }) => { return new Big( this.exchangeRateDataService.toCurrency( quantity * marketPrice, - currency, + assetProfile.currency, this.request.user?.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY ) From 9ab28d3f56f909886e5bf713861abc4060ed2d76 Mon Sep 17 00:00:00 2001 From: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com> Date: Fri, 15 May 2026 15:05:32 +0700 Subject: [PATCH 059/132] Task/improve type safety in investment chart component (#6859) * fix(client): resolve errors * feat(client): enforce encapsulation * feat(client): use viewChild signals * feat(client): enforce immutability --- .../investment-chart.component.ts | 240 +++++++++--------- 1 file changed, 124 insertions(+), 116 deletions(-) diff --git a/apps/client/src/app/components/investment-chart/investment-chart.component.ts b/apps/client/src/app/components/investment-chart/investment-chart.component.ts index 21a7ac85a..691133009 100644 --- a/apps/client/src/app/components/investment-chart/investment-chart.component.ts +++ b/apps/client/src/app/components/investment-chart/investment-chart.component.ts @@ -24,7 +24,7 @@ import { Input, OnChanges, OnDestroy, - ViewChild + viewChild } from '@angular/core'; import { BarController, @@ -55,20 +55,21 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; templateUrl: './investment-chart.component.html' }) export class GfInvestmentChartComponent implements OnChanges, OnDestroy { - @Input() benchmarkDataItems: InvestmentItem[] = []; - @Input() benchmarkDataLabel = ''; - @Input() colorScheme: ColorScheme; - @Input() currency: string; - @Input() groupBy: GroupBy; - @Input() historicalDataItems: LineChartItem[] = []; - @Input() isInPercentage = false; - @Input() isLoading = false; - @Input() locale = getLocale(); - @Input() savingsRate = 0; + @Input() public readonly benchmarkDataItems: InvestmentItem[] = []; + @Input() public readonly benchmarkDataLabel = ''; + @Input() public readonly colorScheme: ColorScheme; + @Input() public readonly currency: string; + @Input() public readonly groupBy: GroupBy; + @Input() public readonly historicalDataItems: LineChartItem[] = []; + @Input() public readonly isInPercentage = false; + @Input() public readonly isLoading = false; + @Input() public readonly locale = getLocale(); + @Input() public readonly savingsRate = 0; - @ViewChild('chartCanvas') chartCanvas: ElementRef; + private readonly chartCanvas = + viewChild.required>('chartCanvas'); - public chart: Chart<'bar' | 'line'>; + private chart: Chart<'bar' | 'line'>; private investments: InvestmentItem[]; private values: LineChartItem[]; @@ -118,7 +119,7 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy { borderWidth: this.groupBy ? 0 : 1, data: this.investments.map(({ date, investment }) => { return { - x: parseDate(date).getTime(), + x: parseDate(date)?.getTime() ?? null, y: this.isInPercentage ? investment * 100 : investment }; }), @@ -138,7 +139,7 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy { borderWidth: 2, data: this.values.map(({ date, value }) => { return { - x: parseDate(date).getTime(), + x: parseDate(date)?.getTime() ?? null, y: this.isInPercentage ? value * 100 : value }; }), @@ -165,123 +166,126 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy { this.getTooltipPluginConfiguration(); const annotations = this.chart.options.plugins.annotation - .annotations as Record>; + ?.annotations as Record>; if (this.savingsRate && annotations.savingsRate) { annotations.savingsRate.value = this.savingsRate; } this.chart.update(); } else { - this.chart = new Chart(this.chartCanvas.nativeElement, { - data: chartData, - options: { - animation: false, - elements: { - line: { - tension: 0 + this.chart = new Chart<'bar' | 'line'>( + this.chartCanvas().nativeElement, + { + data: chartData, + options: { + animation: false, + elements: { + line: { + tension: 0 + }, + point: { + hoverBackgroundColor: getBackgroundColor(this.colorScheme), + hoverRadius: 2, + radius: 0 + } }, - point: { - hoverBackgroundColor: getBackgroundColor(this.colorScheme), - hoverRadius: 2, - radius: 0 - } - }, - interaction: { intersect: false, mode: 'index' }, - maintainAspectRatio: true, - plugins: { - annotation: { - annotations: { - savingsRate: this.savingsRate - ? { - borderColor: `rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b}, 0.75)`, - borderWidth: 1, - label: { - backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`, - borderRadius: 2, - color: 'white', - content: $localize`Savings Rate`, - display: true, - font: { size: 10, weight: 'normal' }, - padding: { - x: 4, - y: 2 + interaction: { intersect: false, mode: 'index' }, + maintainAspectRatio: true, + plugins: { + annotation: { + annotations: { + savingsRate: this.savingsRate + ? { + borderColor: `rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b}, 0.75)`, + borderWidth: 1, + label: { + backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`, + borderRadius: 2, + color: 'white', + content: $localize`Savings Rate`, + display: true, + font: { size: 10, weight: 'normal' }, + padding: { + x: 4, + y: 2 + }, + position: 'start' }, - position: 'start' - }, - scaleID: 'y', - type: 'line', - value: this.savingsRate - } - : undefined, - yAxis: { - borderColor: `rgba(${getTextColor(this.colorScheme)}, 0.1)`, - borderWidth: 1, - scaleID: 'y', - type: 'line', - value: 0 + scaleID: 'y', + type: 'line', + value: this.savingsRate + } + : undefined, + yAxis: { + borderColor: `rgba(${getTextColor(this.colorScheme)}, 0.1)`, + borderWidth: 1, + scaleID: 'y', + type: 'line', + value: 0 + } } - } - }, - legend: { - display: false - }, - tooltip: this.getTooltipPluginConfiguration(), - verticalHoverLine: { - color: `rgba(${getTextColor(this.colorScheme)}, 0.1)` - } - }, - responsive: true, - scales: { - x: { - border: { - color: `rgba(${getTextColor(this.colorScheme)}, 0.1)`, - width: this.groupBy ? 0 : 1 }, - display: true, - grid: { + legend: { display: false }, - type: 'time', - time: { - tooltipFormat: getDateFormatString(this.locale), - unit: 'year' + tooltip: this.getTooltipPluginConfiguration(), + verticalHoverLine: { + color: `rgba(${getTextColor(this.colorScheme)}, 0.1)` } }, - y: { - border: { - display: false - }, - display: !this.isInPercentage, - grid: { - color: ({ scale, tick }) => { - if ( - tick.value === 0 || - tick.value === scale.max || - tick.value === scale.min - ) { - return `rgba(${getTextColor(this.colorScheme)}, 0.1)`; - } - - return 'transparent'; + responsive: true, + scales: { + x: { + border: { + color: `rgba(${getTextColor(this.colorScheme)}, 0.1)`, + width: this.groupBy ? 0 : 1 + }, + display: true, + grid: { + display: false + }, + type: 'time', + time: { + tooltipFormat: getDateFormatString(this.locale), + unit: 'year' } }, - position: 'right', - ticks: { - callback: (value: number) => { - return transformTickToAbbreviation(value); + y: { + border: { + display: false }, - display: true, - mirror: true, - z: 1 + display: !this.isInPercentage, + grid: { + color: ({ scale, tick }) => { + if ( + tick.value === 0 || + tick.value === scale.max || + tick.value === scale.min + ) { + return `rgba(${getTextColor(this.colorScheme)}, 0.1)`; + } + + return 'transparent'; + } + }, + position: 'right', + ticks: { + callback: (value: number) => { + return transformTickToAbbreviation(value); + }, + display: true, + mirror: true, + z: 1 + } } } - } - }, - plugins: [ - getVerticalHoverLinePlugin(this.chartCanvas, this.colorScheme) - ], - type: this.groupBy ? 'bar' : 'line' - }); + }, + plugins: [ + getVerticalHoverLinePlugin(this.chartCanvas(), this.colorScheme) + ], + type: this.groupBy ? 'bar' : 'line' + } + ); } } } @@ -305,8 +309,12 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy { } private isInFuture(aContext: ScriptableLineSegmentContext, aValue: T) { - return isAfter(new Date(aContext?.p1?.parsed?.x), new Date()) - ? aValue - : undefined; + const xValue = aContext?.p1?.parsed?.x; + + if (xValue == null) { + return undefined; + } + + return isAfter(new Date(xValue), new Date()) ? aValue : undefined; } } From 21ed03f5ba6ba3496f1833f5ec84d65db7921e46 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 15 May 2026 11:15:24 +0300 Subject: [PATCH 060/132] Task/refactor deprecated portfolio position attributes in X-ray rules (#6776) Refactor deprecated portfolio position attributes to asset profile --- apps/api/src/models/rule.ts | 8 ++++---- .../rules/asset-class-cluster-risk/equity.ts | 3 ++- .../rules/asset-class-cluster-risk/fixed-income.ts | 3 ++- .../base-currency-current-investment.ts | 2 +- .../currency-cluster-risk/current-investment.ts | 2 +- libs/common/src/lib/helper.ts | 14 -------------- 6 files changed, 10 insertions(+), 22 deletions(-) diff --git a/apps/api/src/models/rule.ts b/apps/api/src/models/rule.ts index 5603964c5..66cda1d78 100644 --- a/apps/api/src/models/rule.ts +++ b/apps/api/src/models/rule.ts @@ -1,6 +1,5 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; -import { groupBy } from '@ghostfolio/common/helper'; import { PortfolioPosition, PortfolioReportRule, @@ -9,6 +8,7 @@ import { } from '@ghostfolio/common/interfaces'; import { Big } from 'big.js'; +import { groupBy } from 'lodash'; import { EvaluationResult } from './interfaces/evaluation-result.interface'; import { RuleInterface } from './interfaces/rule.interface'; @@ -41,10 +41,10 @@ export abstract class Rule implements RuleInterface { public groupCurrentHoldingsByAttribute( holdings: PortfolioPosition[], - attribute: keyof PortfolioPosition, + attribute: `assetProfile.${Extract}`, baseCurrency: string ) { - return Array.from(groupBy(attribute, holdings).entries()).map( + return Object.entries(groupBy(holdings, attribute)).map( ([attributeValue, objs]) => ({ groupKey: attributeValue, investment: objs.reduce( @@ -59,7 +59,7 @@ export abstract class Rule implements RuleInterface { new Big(currentValue.quantity) .mul(currentValue.marketPrice ?? 0) .toNumber(), - currentValue.currency, + currentValue.assetProfile.currency, baseCurrency ), 0 diff --git a/apps/api/src/models/rules/asset-class-cluster-risk/equity.ts b/apps/api/src/models/rules/asset-class-cluster-risk/equity.ts index 372b0bb06..12303fd92 100644 --- a/apps/api/src/models/rules/asset-class-cluster-risk/equity.ts +++ b/apps/api/src/models/rules/asset-class-cluster-risk/equity.ts @@ -27,9 +27,10 @@ export class AssetClassClusterRiskEquity extends Rule { public evaluate(ruleSettings: Settings) { const holdingsGroupedByAssetClass = this.groupCurrentHoldingsByAttribute( this.holdings, - 'assetClass', + 'assetProfile.assetClass', ruleSettings.baseCurrency ); + let totalValue = 0; const equityValueInBaseCurrency = diff --git a/apps/api/src/models/rules/asset-class-cluster-risk/fixed-income.ts b/apps/api/src/models/rules/asset-class-cluster-risk/fixed-income.ts index 404b4cd32..fd7c00f11 100644 --- a/apps/api/src/models/rules/asset-class-cluster-risk/fixed-income.ts +++ b/apps/api/src/models/rules/asset-class-cluster-risk/fixed-income.ts @@ -27,9 +27,10 @@ export class AssetClassClusterRiskFixedIncome extends Rule { public evaluate(ruleSettings: Settings) { const holdingsGroupedByAssetClass = this.groupCurrentHoldingsByAttribute( this.holdings, - 'assetClass', + 'assetProfile.assetClass', ruleSettings.baseCurrency ); + let totalValue = 0; const fixedIncomeValueInBaseCurrency = diff --git a/apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts b/apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts index 83c72eeb8..6890fecd6 100644 --- a/apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts +++ b/apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts @@ -27,7 +27,7 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule { public evaluate(ruleSettings: Settings) { const holdingsGroupedByCurrency = this.groupCurrentHoldingsByAttribute( this.holdings, - 'currency', + 'assetProfile.currency', ruleSettings.baseCurrency ); diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts index 4db1fcf2d..2d8cd90e4 100644 --- a/libs/common/src/lib/helper.ts +++ b/libs/common/src/lib/helper.ts @@ -342,20 +342,6 @@ export function getYesterday() { return subDays(new Date(Date.UTC(year, month, day)), 1); } -export function groupBy( - key: K, - arr: T[] -): Map { - const map = new Map(); - arr.forEach((t) => { - if (!map.has(t[key])) { - map.set(t[key], []); - } - map.get(t[key])!.push(t); - }); - return map; -} - export function interpolate(template: string, context: any) { return template?.replace(/[$]{([^}]+)}/g, (_, objectPath) => { const properties = objectPath.split('.'); From 5fd9eff8a77448d10f75d96e60eb65ccccfa0f45 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 15 May 2026 11:16:45 +0300 Subject: [PATCH 061/132] Task/refactor deprecated portfolio position attributes in treemap chart component (#6779) Refactor deprecated portfolio position attributes to asset profile --- .../lib/treemap-chart/treemap-chart.component.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts b/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts index 7069cabb0..910914230 100644 --- a/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts +++ b/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts @@ -280,8 +280,8 @@ export class GfTreemapChartComponent ); } - const name = raw._data.name; - const symbol = raw._data.symbol; + const name = raw._data.assetProfile.name; + const symbol = raw._data.assetProfile.symbol; return [ isUUID(symbol) ? (name ?? symbol) : symbol, @@ -322,8 +322,10 @@ export class GfTreemapChartComponent ['desc'] ) as PortfolioPosition[]; - const dataSource: DataSource = dataset[dataIndex].dataSource; - const symbol: string = dataset[dataIndex].symbol; + const dataSource: DataSource = + dataset[dataIndex].assetProfile.dataSource; + + const symbol: string = dataset[dataIndex].assetProfile.symbol; this.treemapChartClicked.emit({ dataSource, symbol }); } catch {} @@ -357,10 +359,12 @@ export class GfTreemapChartComponent callbacks: { label: ({ raw }: GfTreemapTooltipItem) => { const allocationInPercentage = `${(raw._data.allocationInPercentage * 100).toFixed(2)}%`; - const name = raw._data.name; + const name = raw._data.assetProfile.name; + const sign = raw._data.netPerformancePercentWithCurrencyEffect > 0 ? '+' : ''; - const symbol = raw._data.symbol; + + const symbol = raw._data.assetProfile.symbol; const netPerformanceInPercentageWithSign = `${sign}${(raw._data.netPerformancePercentWithCurrencyEffect * 100).toFixed(2)}%`; From f82fbcfc5885f9e4992d43a0dcb33cf3cac4aaa1 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 15 May 2026 11:22:35 +0300 Subject: [PATCH 062/132] Bugfix/exception in portfolio details endpoint for unmatched asset profile (#6861) * Resolve exception in portfolio details endpoint when an asset profile is unmatched * Use asset profile identifier for value uniqueness * Update changelog --- CHANGELOG.md | 6 ++++ .../src/app/portfolio/current-rate.service.ts | 21 +++++++++--- .../src/app/portfolio/portfolio.service.ts | 34 ++++++++++++++++--- .../data-provider/data-provider.service.ts | 4 +++ 4 files changed, 55 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f23e1af95..be6630ae1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Fixed + +- Resolved an exception in the portfolio details endpoint when an asset profile is unmatched + ## 3.3.0 - 2026-05-14 ### Added diff --git a/apps/api/src/app/portfolio/current-rate.service.ts b/apps/api/src/app/portfolio/current-rate.service.ts index b454b01cd..f0a451975 100644 --- a/apps/api/src/app/portfolio/current-rate.service.ts +++ b/apps/api/src/app/portfolio/current-rate.service.ts @@ -2,7 +2,10 @@ import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.ser import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; -import { resetHours } from '@ghostfolio/common/helper'; +import { + getAssetProfileIdentifier, + resetHours +} from '@ghostfolio/common/helper'; import { AssetProfileIdentifier, DataProviderInfo, @@ -114,8 +117,8 @@ export class CurrentRateService { errors: quoteErrors.map(({ dataSource, symbol }) => { return { dataSource, symbol }; }), - values: uniqBy(values, ({ date, symbol }) => { - return `${date}-${symbol}`; + values: uniqBy(values, ({ dataSource, date, symbol }) => { + return `${date}-${getAssetProfileIdentifier({ dataSource, symbol })}`; }) }; @@ -124,7 +127,11 @@ export class CurrentRateService { try { // If missing quote, fallback to the latest available historical market price let value: GetValueObject = response.values.find((currentValue) => { - return currentValue.symbol === symbol && isToday(currentValue.date); + return ( + currentValue.dataSource === dataSource && + currentValue.symbol === symbol && + isToday(currentValue.date) + ); }); if (!value) { @@ -147,7 +154,11 @@ export class CurrentRateService { const [latestValue] = response.values .filter((currentValue) => { - return currentValue.symbol === symbol && currentValue.marketPrice; + return ( + currentValue.dataSource === dataSource && + currentValue.marketPrice && + currentValue.symbol === symbol + ); }) .sort((a, b) => { if (a.date < b.date) { diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 60b413cf9..ade683d41 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -36,7 +36,12 @@ import { TAG_ID_EXCLUDE_FROM_ANALYSIS, UNKNOWN_KEY } from '@ghostfolio/common/config'; -import { DATE_FORMAT, getSum, parseDate } from '@ghostfolio/common/helper'; +import { + DATE_FORMAT, + getAssetProfileIdentifier, + getSum, + parseDate +} from '@ghostfolio/common/helper'; import { AccountsResponse, Activity, @@ -64,7 +69,7 @@ import { } from '@ghostfolio/common/types'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, Logger } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { Account, @@ -558,9 +563,17 @@ export class PortfolioService { const cashSymbolProfiles = this.getCashSymbolProfiles(cashDetails); symbolProfiles.push(...cashSymbolProfiles); - const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {}; + const symbolProfileMap: { + [assetProfileIdentifier: string]: EnhancedSymbolProfile; + } = {}; + for (const symbolProfile of symbolProfiles) { - symbolProfileMap[symbolProfile.symbol] = symbolProfile; + symbolProfileMap[ + getAssetProfileIdentifier({ + dataSource: symbolProfile.dataSource, + symbol: symbolProfile.symbol + }) + ] = symbolProfile; } const portfolioItemsNow: { [symbol: string]: TimelinePosition } = {}; @@ -571,6 +584,7 @@ export class PortfolioService { for (const { activitiesCount, currency, + dataSource, dateOfFirstActivity, dividend, grossPerformance, @@ -600,7 +614,17 @@ export class PortfolioService { } } - const assetProfile = symbolProfileMap[symbol]; + const assetProfile = + symbolProfileMap[getAssetProfileIdentifier({ dataSource, symbol })]; + + if (!assetProfile) { + Logger.warn( + `Asset profile not found for ${symbol} (${dataSource})`, + 'PortfolioService' + ); + + continue; + } let markets: PortfolioPosition['markets']; let marketsAdvanced: PortfolioPosition['marketsAdvanced']; diff --git a/apps/api/src/services/data-provider/data-provider.service.ts b/apps/api/src/services/data-provider/data-provider.service.ts index e7c7e8a2e..5f0a6928a 100644 --- a/apps/api/src/services/data-provider/data-provider.service.ts +++ b/apps/api/src/services/data-provider/data-provider.service.ts @@ -82,6 +82,7 @@ export class DataProviderService implements OnModuleInit { return false; } + // TODO: Change symbol in response to assetProfileIdentifier public async getAssetProfiles(items: AssetProfileIdentifier[]): Promise<{ [symbol: string]: Partial; }> { @@ -330,6 +331,7 @@ export class DataProviderService implements OnModuleInit { }); } + // TODO: Change symbol in response to assetProfileIdentifier public async getHistorical( aItems: AssetProfileIdentifier[], aGranularity: Granularity = 'month', @@ -395,6 +397,7 @@ export class DataProviderService implements OnModuleInit { } } + // TODO: Change symbol in response to assetProfileIdentifier public async getHistoricalRaw({ assetProfileIdentifiers, from, @@ -508,6 +511,7 @@ export class DataProviderService implements OnModuleInit { return result; } + // TODO: Change symbol in response to assetProfileIdentifier public async getQuotes({ items, requestTimeout, From f2d4fabdd281a7e089840248cc9374f72cfa1215 Mon Sep 17 00:00:00 2001 From: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com> Date: Fri, 15 May 2026 15:35:34 +0700 Subject: [PATCH 063/132] Task/improve type safety in import activities dialog component (#6858) * feat(client): resolve errors * feat(client): enforce encapsulation * feat(client): enforce immutability * feat(client): replace constructor based DI with inject function * fix(client): remove device detector service --- .../import-activities-dialog.component.ts | 137 +++++++++--------- 1 file changed, 67 insertions(+), 70 deletions(-) diff --git a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts index 10df36857..31ee82310 100644 --- a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts @@ -22,11 +22,11 @@ import { ChangeDetectorRef, Component, DestroyRef, - Inject + inject } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { - FormBuilder, + FormControl, FormGroup, ReactiveFormsModule, Validators @@ -52,7 +52,6 @@ import { addIcons } from 'ionicons'; import { cloudUploadOutline, warningOutline } from 'ionicons/icons'; import { isArray, sortBy } from 'lodash'; import ms from 'ms'; -import { DeviceDetectorService } from 'ngx-device-detector'; import { ImportStep } from './enums/import-step'; import { ImportActivitiesDialogParams } from './interfaces/interfaces'; @@ -82,51 +81,48 @@ import { ImportActivitiesDialogParams } from './interfaces/interfaces'; templateUrl: 'import-activities-dialog.html' }) export class GfImportActivitiesDialogComponent { - public accounts: CreateAccountWithBalancesDto[] = []; - public activities: Activity[] = []; - public assetProfileForm: FormGroup; - public assetProfiles: CreateAssetProfileWithMarketDataDto[] = []; - public dataSource: MatTableDataSource; - public details: any[] = []; - public deviceType: string; - public dialogTitle = $localize`Import Activities`; - public errorMessages: string[] = []; - public holdings: PortfolioPosition[] = []; - public importStep: ImportStep = ImportStep.UPLOAD_FILE; - public isLoading = false; - public mode: 'DIVIDEND'; - public pageIndex = 0; - public pageSize = 8; - public selectedActivities: Activity[] = []; - public sortColumn = 'date'; - public sortDirection: SortDirection = 'desc'; - public stepperOrientation: StepperOrientation; - public tags: CreateTagDto[] = []; - public totalItems: number; - - public constructor( - private changeDetectorRef: ChangeDetectorRef, - @Inject(MAT_DIALOG_DATA) public data: ImportActivitiesDialogParams, - private dataService: DataService, - private destroyRef: DestroyRef, - private deviceDetectorService: DeviceDetectorService, - private formBuilder: FormBuilder, - public dialogRef: MatDialogRef, - private importActivitiesService: ImportActivitiesService, - private snackBar: MatSnackBar - ) { + protected readonly assetProfileForm = new FormGroup({ + assetProfileIdentifier: new FormControl(null, { + validators: [Validators.required] + }) + }); + protected readonly data = + inject(MAT_DIALOG_DATA); + protected dataSource: MatTableDataSource; + protected details: any[] = []; + protected dialogTitle = $localize`Import Activities`; + protected errorMessages: string[] = []; + protected holdings: PortfolioPosition[] = []; + protected importStep: ImportStep = ImportStep.UPLOAD_FILE; + protected isLoading = false; + protected mode: 'DIVIDEND'; + protected pageIndex = 0; + protected readonly pageSize = 8; + protected selectedActivities: Activity[] = []; + protected readonly sortColumn = 'date'; + protected readonly sortDirection: SortDirection = 'desc'; + protected readonly stepperOrientation: StepperOrientation = + this.data.deviceType === 'mobile' ? 'vertical' : 'horizontal'; + protected totalItems: number; + + private accounts: CreateAccountWithBalancesDto[] = []; + private activities: Activity[] = []; + private assetProfiles: CreateAssetProfileWithMarketDataDto[] = []; + private tags: CreateTagDto[] = []; + + private readonly changeDetectorRef = inject(ChangeDetectorRef); + private readonly dataService = inject(DataService); + private readonly destroyRef = inject(DestroyRef); + private readonly dialogRef = + inject>(MatDialogRef); + private readonly importActivitiesService = inject(ImportActivitiesService); + private readonly snackBar = inject(MatSnackBar); + + public constructor() { addIcons({ cloudUploadOutline, warningOutline }); } public ngOnInit() { - this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; - this.stepperOrientation = - this.deviceType === 'mobile' ? 'vertical' : 'horizontal'; - - this.assetProfileForm = this.formBuilder.group({ - assetProfileIdentifier: [undefined, Validators.required] - }); - if ( this.data?.activityTypes?.length === 1 && this.data?.activityTypes?.[0] === 'DIVIDEND' @@ -135,7 +131,7 @@ export class GfImportActivitiesDialogComponent { this.dialogTitle = $localize`Import Dividends`; this.mode = 'DIVIDEND'; - this.assetProfileForm.get('assetProfileIdentifier').disable(); + this.assetProfileForm.controls.assetProfileIdentifier.disable(); this.dataService .fetchPortfolioHoldings({ @@ -156,7 +152,7 @@ export class GfImportActivitiesDialogComponent { this.holdings = sortBy(holdings, ({ name }) => { return name.toLowerCase(); }); - this.assetProfileForm.get('assetProfileIdentifier').enable(); + this.assetProfileForm.controls.assetProfileIdentifier.enable(); this.isLoading = false; @@ -165,11 +161,11 @@ export class GfImportActivitiesDialogComponent { } } - public onCancel() { + protected onCancel() { this.dialogRef.close(); } - public async onImportActivities() { + protected async onImportActivities() { try { this.snackBar.open('⏳ ' + $localize`Importing data...`); @@ -202,7 +198,7 @@ export class GfImportActivitiesDialogComponent { } } - public onFilesDropped({ + protected onFilesDropped({ files, stepper }: { @@ -216,7 +212,7 @@ export class GfImportActivitiesDialogComponent { this.handleFile({ stepper, file: files[0] }); } - public onImportStepChange(event: StepperSelectionEvent) { + protected onImportStepChange(event: StepperSelectionEvent) { if (event.selectedIndex === ImportStep.UPLOAD_FILE) { this.importStep = ImportStep.UPLOAD_FILE; } else if (event.selectedIndex === ImportStep.SELECT_ACTIVITIES) { @@ -224,12 +220,15 @@ export class GfImportActivitiesDialogComponent { } } - public onLoadDividends(aStepper: MatStepper) { - this.assetProfileForm.get('assetProfileIdentifier').disable(); + protected onLoadDividends(aStepper: MatStepper) { + this.assetProfileForm.controls.assetProfileIdentifier.disable(); - const { dataSource, symbol } = this.assetProfileForm.get( - 'assetProfileIdentifier' - ).value; + const { dataSource, symbol } = + this.assetProfileForm.controls.assetProfileIdentifier.value ?? {}; + + if (!dataSource || !symbol) { + return; + } this.dataService .fetchDividendsImport({ @@ -249,47 +248,45 @@ export class GfImportActivitiesDialogComponent { }); } - public onPageChanged({ pageIndex }: PageEvent) { + protected onPageChanged({ pageIndex }: PageEvent) { this.pageIndex = pageIndex; } - public onReset(aStepper: MatStepper) { + protected onReset(aStepper: MatStepper) { this.details = []; this.errorMessages = []; this.importStep = ImportStep.SELECT_ACTIVITIES; this.pageIndex = 0; - this.assetProfileForm.get('assetProfileIdentifier').enable(); + this.assetProfileForm.controls.assetProfileIdentifier.enable(); aStepper.reset(); } - public onSelectFile(stepper: MatStepper) { + protected onSelectFile(stepper: MatStepper) { const input = document.createElement('input'); + input.accept = 'application/JSON, .csv'; input.type = 'file'; input.onchange = (event) => { // Getting the file reference - const file = (event.target as HTMLInputElement).files[0]; - this.handleFile({ file, stepper }); + const file = (event.target as HTMLInputElement).files?.[0]; + + if (file) { + this.handleFile({ file, stepper }); + } }; input.click(); } - public updateSelection(activities: Activity[]) { + protected updateSelection(activities: Activity[]) { this.selectedActivities = activities.filter(({ error }) => { return !error; }); } - private async handleFile({ - file, - stepper - }: { - file: File; - stepper: MatStepper; - }): Promise { + private handleFile({ file, stepper }: { file: File; stepper: MatStepper }) { this.snackBar.open('⏳ ' + $localize`Validating data...`); // Setting up the reader @@ -297,7 +294,7 @@ export class GfImportActivitiesDialogComponent { reader.readAsText(file, 'UTF-8'); reader.onload = async (readerEvent) => { - const fileContent = readerEvent.target.result as string; + const fileContent = readerEvent.target?.result as string; const fileExtension = file.name.split('.').pop()?.toLowerCase(); try { From 53ce901b083dfeae39d834e68c406d1e61f0bcf2 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 15 May 2026 11:36:10 +0300 Subject: [PATCH 064/132] Task/refactor deprecated portfolio position attributes in top holdings component (#6778) Refactor deprecated portfolio position attributes to asset profile --- libs/ui/src/lib/top-holdings/top-holdings.component.html | 7 ++++++- libs/ui/src/lib/top-holdings/top-holdings.component.ts | 4 ++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/libs/ui/src/lib/top-holdings/top-holdings.component.html b/libs/ui/src/lib/top-holdings/top-holdings.component.html index 71e94ff4e..1f9418230 100644 --- a/libs/ui/src/lib/top-holdings/top-holdings.component.html +++ b/libs/ui/src/lib/top-holdings/top-holdings.component.html @@ -121,7 +121,12 @@ *matRowDef="let row; columns: displayedColumns" mat-row [class.cursor-pointer]="row.position" - (click)="onClickHolding(row.position)" + (click)=" + onClickHolding({ + dataSource: row.position.assetProfile.dataSource, + symbol: row.position.assetProfile.symbol + }) + " > Date: Fri, 15 May 2026 12:01:21 +0300 Subject: [PATCH 065/132] Task/refactor deprecated portfolio position attributes in import activities dialog (#6777) Refactor deprecated portfolio position attributes to asset profile --- .../import-activities-dialog.component.ts | 5 +++-- .../import-activities-dialog.html | 12 ++++++------ 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts index 31ee82310..7796939fd 100644 --- a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts @@ -149,9 +149,10 @@ export class GfImportActivitiesDialogComponent { }) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ holdings }) => { - this.holdings = sortBy(holdings, ({ name }) => { - return name.toLowerCase(); + this.holdings = sortBy(holdings, ({ assetProfile }) => { + return assetProfile.name.toLowerCase(); }); + this.assetProfileForm.controls.assetProfileIdentifier.enable(); this.isLoading = false; diff --git a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html index 508fdd753..85fb73ba2 100644 --- a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html +++ b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html @@ -38,18 +38,18 @@ {{ holding.name }}{{ holding.assetProfile.name }}
    {{ holding.symbol | gfSymbol }} · - {{ holding.currency }}{{ holding.assetProfile.symbol | gfSymbol }} · + {{ holding.assetProfile.currency }}
    } From 29e9336baf5fcec963e90e3fe981b3a8e2ede713 Mon Sep 17 00:00:00 2001 From: Chris Ian Fiel Date: Fri, 15 May 2026 20:21:25 +0800 Subject: [PATCH 066/132] Bugfix/account cash balance calculation for SELL activities with fees (#6862) * Fix account cash balance calculation for SELL activities with fees * Update changelog --- CHANGELOG.md | 1 + apps/api/src/app/activities/activities.service.ts | 11 +++++------ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be6630ae1..d7676efee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Resolved an issue with the cash balance calculation of an account for `SELL` activities to ensure fees are correctly subtracted - Resolved an exception in the portfolio details endpoint when an asset profile is unmatched ## 3.3.0 - 2026-05-14 diff --git a/apps/api/src/app/activities/activities.service.ts b/apps/api/src/app/activities/activities.service.ts index 58b9c11a4..821185e11 100644 --- a/apps/api/src/app/activities/activities.service.ts +++ b/apps/api/src/app/activities/activities.service.ts @@ -214,19 +214,18 @@ export class ActivitiesService { }); if (updateAccountBalance === true) { - let amount = new Big(data.unitPrice) - .mul(data.quantity) - .plus(data.fee) - .toNumber(); + let amount = new Big(data.unitPrice).mul(data.quantity); if (['BUY', 'FEE'].includes(data.type)) { - amount = new Big(amount).mul(-1).toNumber(); + amount = amount.mul(-1); } + amount = amount.minus(data.fee); + await this.accountService.updateAccountBalance({ accountId, - amount, userId, + amount: amount.toNumber(), currency: data.SymbolProfile.connectOrCreate.create.currency, date: data.date as Date }); From ceea375227e8ef3adbc4a01d92f11f40bf481110 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 15 May 2026 15:30:23 +0300 Subject: [PATCH 067/132] Task/extract page tabs to dedicated component (#6797) * Extract page tabs * Update changelog --- CHANGELOG.md | 4 ++ .../app/pages/about/about-page.component.ts | 29 +++----- .../src/app/pages/about/about-page.html | 31 +------- .../app/pages/admin/admin-page.component.ts | 16 ++--- .../src/app/pages/admin/admin-page.html | 31 +------- .../src/app/pages/faq/faq-page.component.ts | 26 +++---- apps/client/src/app/pages/faq/faq-page.html | 31 +------- .../src/app/pages/home/home-page.component.ts | 18 ++--- apps/client/src/app/pages/home/home-page.html | 31 +------- .../portfolio/portfolio-page.component.ts | 26 +++---- .../app/pages/portfolio/portfolio-page.html | 31 +------- .../resources/resources-page.component.ts | 22 +++--- .../app/pages/resources/resources-page.html | 31 +------- .../user-account-page.component.ts | 29 +++----- .../pages/user-account/user-account-page.html | 31 +------- .../src/app/pages/zen/zen-page.component.ts | 27 +++---- apps/client/src/app/pages/zen/zen-page.html | 31 +------- apps/client/src/styles.scss | 49 ------------- apps/client/src/styles/theme.scss | 27 ------- libs/common/src/lib/interfaces/index.ts | 2 - libs/ui/src/lib/page-tabs/index.ts | 2 + .../lib/page-tabs/interfaces/interfaces.ts} | 0 .../lib/page-tabs/page-tabs.component.html | 30 ++++++++ .../lib/page-tabs/page-tabs.component.scss | 70 +++++++++++++++++++ .../src/lib/page-tabs/page-tabs.component.ts | 30 ++++++++ 25 files changed, 204 insertions(+), 451 deletions(-) create mode 100644 libs/ui/src/lib/page-tabs/index.ts rename libs/{common/src/lib/interfaces/tab-configuration.interface.ts => ui/src/lib/page-tabs/interfaces/interfaces.ts} (100%) create mode 100644 libs/ui/src/lib/page-tabs/page-tabs.component.html create mode 100644 libs/ui/src/lib/page-tabs/page-tabs.component.scss create mode 100644 libs/ui/src/lib/page-tabs/page-tabs.component.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d7676efee..e8a33af2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Changed + +- Extracted the page tabs to a reusable component + ### Fixed - Resolved an issue with the cash balance calculation of an account for `SELL` activities to ensure fees are correctly subtracted diff --git a/apps/client/src/app/pages/about/about-page.component.ts b/apps/client/src/app/pages/about/about-page.component.ts index b23377f3c..fb632a586 100644 --- a/apps/client/src/app/pages/about/about-page.component.ts +++ b/apps/client/src/app/pages/about/about-page.component.ts @@ -1,20 +1,15 @@ import { UserService } from '@ghostfolio/client/services/user/user.service'; -import { TabConfiguration, User } from '@ghostfolio/common/interfaces'; +import { User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { publicRoutes } from '@ghostfolio/common/routes/routes'; +import { + GfPageTabsComponent, + TabConfiguration +} from '@ghostfolio/ui/page-tabs'; import { DataService } from '@ghostfolio/ui/services'; -import { - ChangeDetectorRef, - Component, - CUSTOM_ELEMENTS_SCHEMA, - DestroyRef, - OnInit -} from '@angular/core'; +import { ChangeDetectorRef, Component, DestroyRef } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { MatTabsModule } from '@angular/material/tabs'; -import { RouterModule } from '@angular/router'; -import { IonIcon } from '@ionic/angular/standalone'; import { addIcons } from 'ionicons'; import { documentTextOutline, @@ -24,18 +19,15 @@ import { shieldCheckmarkOutline, sparklesOutline } from 'ionicons/icons'; -import { DeviceDetectorService } from 'ngx-device-detector'; @Component({ host: { class: 'page has-tabs' }, - imports: [IonIcon, MatTabsModule, RouterModule], - schemas: [CUSTOM_ELEMENTS_SCHEMA], + imports: [GfPageTabsComponent], selector: 'gf-about-page', styleUrls: ['./about-page.scss'], templateUrl: './about-page.html' }) -export class AboutPageComponent implements OnInit { - public deviceType: string; +export class AboutPageComponent { public hasPermissionForSubscription: boolean; public tabs: TabConfiguration[] = []; public user: User; @@ -44,7 +36,6 @@ export class AboutPageComponent implements OnInit { private changeDetectorRef: ChangeDetectorRef, private dataService: DataService, private destroyRef: DestroyRef, - private deviceDetectorService: DeviceDetectorService, private userService: UserService ) { const { globalPermissions } = this.dataService.fetchInfo(); @@ -112,8 +103,4 @@ export class AboutPageComponent implements OnInit { sparklesOutline }); } - - public ngOnInit() { - this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; - } } diff --git a/apps/client/src/app/pages/about/about-page.html b/apps/client/src/app/pages/about/about-page.html index bd1338774..5d1bdce9b 100644 --- a/apps/client/src/app/pages/about/about-page.html +++ b/apps/client/src/app/pages/about/about-page.html @@ -1,30 +1 @@ - - - - -
    + diff --git a/apps/client/src/app/pages/admin/admin-page.component.ts b/apps/client/src/app/pages/admin/admin-page.component.ts index 8ec094915..6b653efb0 100644 --- a/apps/client/src/app/pages/admin/admin-page.component.ts +++ b/apps/client/src/app/pages/admin/admin-page.component.ts @@ -1,10 +1,10 @@ -import { TabConfiguration } from '@ghostfolio/common/interfaces'; import { internalRoutes } from '@ghostfolio/common/routes/routes'; +import { + GfPageTabsComponent, + TabConfiguration +} from '@ghostfolio/ui/page-tabs'; import { Component, OnInit } from '@angular/core'; -import { MatTabsModule } from '@angular/material/tabs'; -import { RouterModule } from '@angular/router'; -import { IonIcon } from '@ionic/angular/standalone'; import { addIcons } from 'ionicons'; import { flashOutline, @@ -13,20 +13,18 @@ import { serverOutline, settingsOutline } from 'ionicons/icons'; -import { DeviceDetectorService } from 'ngx-device-detector'; @Component({ host: { class: 'page has-tabs' }, - imports: [IonIcon, MatTabsModule, RouterModule], + imports: [GfPageTabsComponent], selector: 'gf-admin-page', styleUrls: ['./admin-page.scss'], templateUrl: './admin-page.html' }) export class AdminPageComponent implements OnInit { - public deviceType: string; public tabs: TabConfiguration[] = []; - public constructor(private deviceDetectorService: DeviceDetectorService) { + public constructor() { addIcons({ flashOutline, peopleOutline, @@ -37,8 +35,6 @@ export class AdminPageComponent implements OnInit { } public ngOnInit() { - this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; - this.tabs = [ { iconName: 'reader-outline', diff --git a/apps/client/src/app/pages/admin/admin-page.html b/apps/client/src/app/pages/admin/admin-page.html index a5c5ed7e9..5d1bdce9b 100644 --- a/apps/client/src/app/pages/admin/admin-page.html +++ b/apps/client/src/app/pages/admin/admin-page.html @@ -1,30 +1 @@ - - - - - + diff --git a/apps/client/src/app/pages/faq/faq-page.component.ts b/apps/client/src/app/pages/faq/faq-page.component.ts index 8ae074bee..6dabdb5e2 100644 --- a/apps/client/src/app/pages/faq/faq-page.component.ts +++ b/apps/client/src/app/pages/faq/faq-page.component.ts @@ -1,33 +1,27 @@ -import { TabConfiguration } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { publicRoutes } from '@ghostfolio/common/routes/routes'; +import { + GfPageTabsComponent, + TabConfiguration +} from '@ghostfolio/ui/page-tabs'; import { DataService } from '@ghostfolio/ui/services'; -import { CUSTOM_ELEMENTS_SCHEMA, Component, OnInit } from '@angular/core'; -import { MatTabsModule } from '@angular/material/tabs'; -import { RouterModule } from '@angular/router'; -import { IonIcon } from '@ionic/angular/standalone'; +import { Component } from '@angular/core'; import { addIcons } from 'ionicons'; import { cloudyOutline, readerOutline, serverOutline } from 'ionicons/icons'; -import { DeviceDetectorService } from 'ngx-device-detector'; @Component({ host: { class: 'page has-tabs' }, - imports: [IonIcon, MatTabsModule, RouterModule], - schemas: [CUSTOM_ELEMENTS_SCHEMA], + imports: [GfPageTabsComponent], selector: 'gf-faq-page', styleUrls: ['./faq-page.scss'], templateUrl: './faq-page.html' }) -export class GfFaqPageComponent implements OnInit { - public deviceType: string; +export class GfFaqPageComponent { public hasPermissionForSubscription: boolean; public tabs: TabConfiguration[] = []; - public constructor( - private dataService: DataService, - private deviceDetectorService: DeviceDetectorService - ) { + public constructor(private dataService: DataService) { const { globalPermissions } = this.dataService.fetchInfo(); this.hasPermissionForSubscription = hasPermission( @@ -56,8 +50,4 @@ export class GfFaqPageComponent implements OnInit { addIcons({ cloudyOutline, readerOutline, serverOutline }); } - - public ngOnInit() { - this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; - } } diff --git a/apps/client/src/app/pages/faq/faq-page.html b/apps/client/src/app/pages/faq/faq-page.html index bd1338774..5d1bdce9b 100644 --- a/apps/client/src/app/pages/faq/faq-page.html +++ b/apps/client/src/app/pages/faq/faq-page.html @@ -1,30 +1 @@ - - - - -
    + diff --git a/apps/client/src/app/pages/home/home-page.component.ts b/apps/client/src/app/pages/home/home-page.component.ts index 958428331..453a79a52 100644 --- a/apps/client/src/app/pages/home/home-page.component.ts +++ b/apps/client/src/app/pages/home/home-page.component.ts @@ -1,20 +1,20 @@ import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; -import { TabConfiguration, User } from '@ghostfolio/common/interfaces'; +import { User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { internalRoutes } from '@ghostfolio/common/routes/routes'; +import { + GfPageTabsComponent, + TabConfiguration +} from '@ghostfolio/ui/page-tabs'; import { ChangeDetectorRef, Component, - CUSTOM_ELEMENTS_SCHEMA, DestroyRef, OnInit } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { MatTabsModule } from '@angular/material/tabs'; -import { RouterModule } from '@angular/router'; -import { IonIcon } from '@ionic/angular/standalone'; import { addIcons } from 'ionicons'; import { albumsOutline, @@ -23,18 +23,15 @@ import { newspaperOutline, readerOutline } from 'ionicons/icons'; -import { DeviceDetectorService } from 'ngx-device-detector'; @Component({ host: { class: 'page has-tabs' }, - imports: [IonIcon, MatTabsModule, RouterModule], - schemas: [CUSTOM_ELEMENTS_SCHEMA], + imports: [GfPageTabsComponent], selector: 'gf-home-page', styleUrls: ['./home-page.scss'], templateUrl: './home-page.html' }) export class GfHomePageComponent implements OnInit { - public deviceType: string; public hasImpersonationId: boolean; public tabs: TabConfiguration[] = []; public user: User; @@ -42,7 +39,6 @@ export class GfHomePageComponent implements OnInit { public constructor( private changeDetectorRef: ChangeDetectorRef, private destroyRef: DestroyRef, - private deviceDetectorService: DeviceDetectorService, private impersonationStorageService: ImpersonationStorageService, private userService: UserService ) { @@ -104,8 +100,6 @@ export class GfHomePageComponent implements OnInit { } public ngOnInit() { - this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; - this.impersonationStorageService .onChangeHasImpersonation() .pipe(takeUntilDestroyed(this.destroyRef)) diff --git a/apps/client/src/app/pages/home/home-page.html b/apps/client/src/app/pages/home/home-page.html index bd1338774..5d1bdce9b 100644 --- a/apps/client/src/app/pages/home/home-page.html +++ b/apps/client/src/app/pages/home/home-page.html @@ -1,30 +1 @@ - - - - - + diff --git a/apps/client/src/app/pages/portfolio/portfolio-page.component.ts b/apps/client/src/app/pages/portfolio/portfolio-page.component.ts index dca39c4a6..00fb3242b 100644 --- a/apps/client/src/app/pages/portfolio/portfolio-page.component.ts +++ b/apps/client/src/app/pages/portfolio/portfolio-page.component.ts @@ -1,16 +1,13 @@ import { UserService } from '@ghostfolio/client/services/user/user.service'; -import { TabConfiguration, User } from '@ghostfolio/common/interfaces'; +import { User } from '@ghostfolio/common/interfaces'; import { internalRoutes } from '@ghostfolio/common/routes/routes'; - import { - ChangeDetectorRef, - Component, - DestroyRef, - OnInit -} from '@angular/core'; + GfPageTabsComponent, + TabConfiguration +} from '@ghostfolio/ui/page-tabs'; + +import { ChangeDetectorRef, Component, DestroyRef } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { MatTabsModule } from '@angular/material/tabs'; -import { RouterModule } from '@angular/router'; import { addIcons } from 'ionicons'; import { analyticsOutline, @@ -19,24 +16,21 @@ import { scanOutline, swapVerticalOutline } from 'ionicons/icons'; -import { DeviceDetectorService } from 'ngx-device-detector'; @Component({ host: { class: 'page has-tabs' }, - imports: [MatTabsModule, RouterModule], + imports: [GfPageTabsComponent], selector: 'gf-portfolio-page', styleUrls: ['./portfolio-page.scss'], templateUrl: './portfolio-page.html' }) -export class PortfolioPageComponent implements OnInit { - public deviceType: string; +export class PortfolioPageComponent { public tabs: TabConfiguration[] = []; public user: User; public constructor( private changeDetectorRef: ChangeDetectorRef, private destroyRef: DestroyRef, - private deviceDetectorService: DeviceDetectorService, private userService: UserService ) { this.userService.stateChanged @@ -86,8 +80,4 @@ export class PortfolioPageComponent implements OnInit { swapVerticalOutline }); } - - public ngOnInit() { - this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; - } } diff --git a/apps/client/src/app/pages/portfolio/portfolio-page.html b/apps/client/src/app/pages/portfolio/portfolio-page.html index bd1338774..5d1bdce9b 100644 --- a/apps/client/src/app/pages/portfolio/portfolio-page.html +++ b/apps/client/src/app/pages/portfolio/portfolio-page.html @@ -1,30 +1 @@ - - - - - + diff --git a/apps/client/src/app/pages/resources/resources-page.component.ts b/apps/client/src/app/pages/resources/resources-page.component.ts index 016f92fa8..faba06f28 100644 --- a/apps/client/src/app/pages/resources/resources-page.component.ts +++ b/apps/client/src/app/pages/resources/resources-page.component.ts @@ -1,10 +1,10 @@ -import { TabConfiguration } from '@ghostfolio/common/interfaces'; import { publicRoutes } from '@ghostfolio/common/routes/routes'; +import { + GfPageTabsComponent, + TabConfiguration +} from '@ghostfolio/ui/page-tabs'; -import { Component, OnInit } from '@angular/core'; -import { MatTabsModule } from '@angular/material/tabs'; -import { RouterModule } from '@angular/router'; -import { IonIcon } from '@ionic/angular/standalone'; +import { Component } from '@angular/core'; import { addIcons } from 'ionicons'; import { bookOutline, @@ -12,17 +12,15 @@ import { newspaperOutline, readerOutline } from 'ionicons/icons'; -import { DeviceDetectorService } from 'ngx-device-detector'; @Component({ host: { class: 'page has-tabs' }, - imports: [IonIcon, MatTabsModule, RouterModule], + imports: [GfPageTabsComponent], selector: 'gf-resources-page', styleUrls: ['./resources-page.scss'], templateUrl: './resources-page.html' }) -export class ResourcesPageComponent implements OnInit { - public deviceType: string; +export class ResourcesPageComponent { public tabs: TabConfiguration[] = [ { iconName: 'reader-outline', @@ -46,11 +44,7 @@ export class ResourcesPageComponent implements OnInit { } ]; - public constructor(private deviceDetectorService: DeviceDetectorService) { + public constructor() { addIcons({ bookOutline, libraryOutline, newspaperOutline, readerOutline }); } - - public ngOnInit() { - this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; - } } diff --git a/apps/client/src/app/pages/resources/resources-page.html b/apps/client/src/app/pages/resources/resources-page.html index bd1338774..5d1bdce9b 100644 --- a/apps/client/src/app/pages/resources/resources-page.html +++ b/apps/client/src/app/pages/resources/resources-page.html @@ -1,30 +1 @@ - - - - - + diff --git a/apps/client/src/app/pages/user-account/user-account-page.component.ts b/apps/client/src/app/pages/user-account/user-account-page.component.ts index 7d5af5423..71b93b2e4 100644 --- a/apps/client/src/app/pages/user-account/user-account-page.component.ts +++ b/apps/client/src/app/pages/user-account/user-account-page.component.ts @@ -1,39 +1,30 @@ import { UserService } from '@ghostfolio/client/services/user/user.service'; -import { TabConfiguration, User } from '@ghostfolio/common/interfaces'; +import { User } from '@ghostfolio/common/interfaces'; import { internalRoutes } from '@ghostfolio/common/routes/routes'; - import { - ChangeDetectorRef, - Component, - CUSTOM_ELEMENTS_SCHEMA, - DestroyRef, - OnInit -} from '@angular/core'; + GfPageTabsComponent, + TabConfiguration +} from '@ghostfolio/ui/page-tabs'; + +import { ChangeDetectorRef, Component, DestroyRef } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { MatTabsModule } from '@angular/material/tabs'; -import { RouterModule } from '@angular/router'; -import { IonIcon } from '@ionic/angular/standalone'; import { addIcons } from 'ionicons'; import { diamondOutline, keyOutline, settingsOutline } from 'ionicons/icons'; -import { DeviceDetectorService } from 'ngx-device-detector'; @Component({ host: { class: 'page has-tabs' }, - imports: [IonIcon, MatTabsModule, RouterModule], - schemas: [CUSTOM_ELEMENTS_SCHEMA], + imports: [GfPageTabsComponent], selector: 'gf-user-account-page', styleUrls: ['./user-account-page.scss'], templateUrl: './user-account-page.html' }) -export class GfUserAccountPageComponent implements OnInit { - public deviceType: string; +export class GfUserAccountPageComponent { public tabs: TabConfiguration[] = []; public user: User; public constructor( private changeDetectorRef: ChangeDetectorRef, private destroyRef: DestroyRef, - private deviceDetectorService: DeviceDetectorService, private userService: UserService ) { this.userService.stateChanged @@ -68,8 +59,4 @@ export class GfUserAccountPageComponent implements OnInit { addIcons({ diamondOutline, keyOutline, settingsOutline }); } - - public ngOnInit() { - this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; - } } diff --git a/apps/client/src/app/pages/user-account/user-account-page.html b/apps/client/src/app/pages/user-account/user-account-page.html index bd1338774..5d1bdce9b 100644 --- a/apps/client/src/app/pages/user-account/user-account-page.html +++ b/apps/client/src/app/pages/user-account/user-account-page.html @@ -1,30 +1 @@ - - - - - + diff --git a/apps/client/src/app/pages/zen/zen-page.component.ts b/apps/client/src/app/pages/zen/zen-page.component.ts index b3c14b4e0..df3684fd2 100644 --- a/apps/client/src/app/pages/zen/zen-page.component.ts +++ b/apps/client/src/app/pages/zen/zen-page.component.ts @@ -1,37 +1,30 @@ import { UserService } from '@ghostfolio/client/services/user/user.service'; -import { TabConfiguration, User } from '@ghostfolio/common/interfaces'; +import { User } from '@ghostfolio/common/interfaces'; import { internalRoutes } from '@ghostfolio/common/routes/routes'; - import { - ChangeDetectorRef, - Component, - DestroyRef, - OnInit -} from '@angular/core'; + GfPageTabsComponent, + TabConfiguration +} from '@ghostfolio/ui/page-tabs'; + +import { ChangeDetectorRef, Component, DestroyRef } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { MatTabsModule } from '@angular/material/tabs'; -import { RouterModule } from '@angular/router'; -import { IonIcon } from '@ionic/angular/standalone'; import { addIcons } from 'ionicons'; import { albumsOutline, analyticsOutline } from 'ionicons/icons'; -import { DeviceDetectorService } from 'ngx-device-detector'; @Component({ host: { class: 'page has-tabs' }, - imports: [IonIcon, MatTabsModule, RouterModule], + imports: [GfPageTabsComponent], selector: 'gf-zen-page', styleUrls: ['./zen-page.scss'], templateUrl: './zen-page.html' }) -export class GfZenPageComponent implements OnInit { - public deviceType: string; +export class GfZenPageComponent { public tabs: TabConfiguration[] = []; public user: User; public constructor( private changeDetectorRef: ChangeDetectorRef, private destroyRef: DestroyRef, - private deviceDetectorService: DeviceDetectorService, private userService: UserService ) { this.userService.stateChanged @@ -58,8 +51,4 @@ export class GfZenPageComponent implements OnInit { addIcons({ albumsOutline, analyticsOutline }); } - - public ngOnInit() { - this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; - } } diff --git a/apps/client/src/app/pages/zen/zen-page.html b/apps/client/src/app/pages/zen/zen-page.html index bd1338774..5d1bdce9b 100644 --- a/apps/client/src/app/pages/zen/zen-page.html +++ b/apps/client/src/app/pages/zen/zen-page.html @@ -1,30 +1 @@ - - - - - + diff --git a/apps/client/src/styles.scss b/apps/client/src/styles.scss index 4138d9d9b..83fd69c91 100644 --- a/apps/client/src/styles.scss +++ b/apps/client/src/styles.scss @@ -265,16 +265,6 @@ body { } } - .page { - &.has-tabs { - @media (min-width: 576px) { - .mat-mdc-tab-header { - background-color: rgba(var(--palette-foreground-base-dark), 0.02); - } - } - } - } - .svgMap-tooltip { background: var(--dark-background); @@ -489,7 +479,6 @@ ngx-skeleton-loader { .page { display: flex; - flex-direction: column; overflow-y: auto; padding-bottom: env(safe-area-inset-bottom); padding-bottom: constant(safe-area-inset-bottom); @@ -506,44 +495,6 @@ ngx-skeleton-loader { padding: 2rem 0; } } - - &.has-tabs { - height: calc(100svh - var(--mat-toolbar-standard-height)); - - .fab-container { - @media (max-width: 575.98px) { - bottom: 5rem; - } - } - - .mat-mdc-tab-nav-panel { - padding: 2rem 0; - } - - @include mat.tabs-overrides( - ( - divider-height: 0 - ) - ); - - @media (min-width: 576px) { - flex-direction: row-reverse; - - .mat-mdc-tab-header { - background-color: rgba(var(--palette-foreground-base), 0.02); - padding: 2rem 0; - width: 14rem; - - .mat-mdc-tab-links { - flex-direction: column; - - .mat-mdc-tab-link { - justify-content: flex-start; - } - } - } - } - } } .svgMap-tooltip { diff --git a/apps/client/src/styles/theme.scss b/apps/client/src/styles/theme.scss index 3cec572cc..f8a194651 100644 --- a/apps/client/src/styles/theme.scss +++ b/apps/client/src/styles/theme.scss @@ -293,24 +293,6 @@ $gf-typography: ( container-shape: 4px ) ); - - .page.has-tabs { - @include mat.tabs-overrides( - ( - container-height: 3rem - ) - ); - } - } - - @media (min-width: 576px) { - .page.has-tabs { - @include mat.tabs-overrides( - ( - container-height: 2rem - ) - ); - } } @include mat.badge-overrides( @@ -436,15 +418,6 @@ $gf-typography: ( ) ); } - - .page.has-tabs { - @include mat.tabs-overrides( - ( - active-indicator-height: 0, - label-text-tracking: normal - ) - ); - } } :root { diff --git a/libs/common/src/lib/interfaces/index.ts b/libs/common/src/lib/interfaces/index.ts index 89874da60..536fc0feb 100644 --- a/libs/common/src/lib/interfaces/index.ts +++ b/libs/common/src/lib/interfaces/index.ts @@ -91,7 +91,6 @@ import type { SubscriptionOffer } from './subscription-offer.interface'; import type { SymbolItem } from './symbol-item.interface'; import type { SymbolMetrics } from './symbol-metrics.interface'; import type { SystemMessage } from './system-message.interface'; -import type { TabConfiguration } from './tab-configuration.interface'; import type { ToggleOption } from './toggle-option.interface'; import type { UserItem } from './user-item.interface'; import type { UserSettings } from './user-settings.interface'; @@ -186,7 +185,6 @@ export { SymbolItem, SymbolMetrics, SystemMessage, - TabConfiguration, ToggleOption, User, UserItem, diff --git a/libs/ui/src/lib/page-tabs/index.ts b/libs/ui/src/lib/page-tabs/index.ts new file mode 100644 index 000000000..a7b3468fd --- /dev/null +++ b/libs/ui/src/lib/page-tabs/index.ts @@ -0,0 +1,2 @@ +export * from './interfaces/interfaces'; +export * from './page-tabs.component'; diff --git a/libs/common/src/lib/interfaces/tab-configuration.interface.ts b/libs/ui/src/lib/page-tabs/interfaces/interfaces.ts similarity index 100% rename from libs/common/src/lib/interfaces/tab-configuration.interface.ts rename to libs/ui/src/lib/page-tabs/interfaces/interfaces.ts diff --git a/libs/ui/src/lib/page-tabs/page-tabs.component.html b/libs/ui/src/lib/page-tabs/page-tabs.component.html new file mode 100644 index 000000000..fa9af9b11 --- /dev/null +++ b/libs/ui/src/lib/page-tabs/page-tabs.component.html @@ -0,0 +1,30 @@ + + + + + diff --git a/libs/ui/src/lib/page-tabs/page-tabs.component.scss b/libs/ui/src/lib/page-tabs/page-tabs.component.scss new file mode 100644 index 000000000..bed3596bb --- /dev/null +++ b/libs/ui/src/lib/page-tabs/page-tabs.component.scss @@ -0,0 +1,70 @@ +@use '@angular/material' as mat; + +:host { + display: flex; + flex-direction: column; + height: calc(100svh - var(--mat-toolbar-standard-height)); + width: 100%; + + @include mat.tabs-overrides( + ( + active-indicator-height: 0, + divider-height: 0, + label-text-tracking: normal + ) + ); + + .fab-container { + @media (max-width: 575.98px) { + bottom: 5rem; + } + } + + ::ng-deep { + .mat-mdc-tab-nav-panel { + padding: 2rem 0; + } + } + + @media (max-width: 575.98px) { + @include mat.tabs-overrides( + ( + container-height: 3rem + ) + ); + } + + @media (min-width: 576px) { + flex-direction: row-reverse; + + @include mat.tabs-overrides( + ( + container-height: 2rem + ) + ); + + ::ng-deep { + .mat-mdc-tab-header { + background-color: rgba(var(--palette-foreground-base), 0.02); + padding: 2rem 0; + width: 14rem; + + .mat-mdc-tab-links { + flex-direction: column; + + .mat-mdc-tab-link { + justify-content: flex-start; + } + } + } + } + } +} + +:host-context(.theme-dark) { + @media (min-width: 576px) { + .mat-mdc-tab-header { + background-color: rgba(var(--palette-foreground-base-dark), 0.02); + } + } +} diff --git a/libs/ui/src/lib/page-tabs/page-tabs.component.ts b/libs/ui/src/lib/page-tabs/page-tabs.component.ts new file mode 100644 index 000000000..61c2caf05 --- /dev/null +++ b/libs/ui/src/lib/page-tabs/page-tabs.component.ts @@ -0,0 +1,30 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + input +} from '@angular/core'; +import { MatTabsModule } from '@angular/material/tabs'; +import { RouterModule } from '@angular/router'; +import { IonIcon } from '@ionic/angular/standalone'; +import { DeviceDetectorService } from 'ngx-device-detector'; + +import { TabConfiguration } from './interfaces/interfaces'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [IonIcon, MatTabsModule, RouterModule], + selector: 'gf-page-tabs', + styleUrls: ['./page-tabs.component.scss'], + templateUrl: './page-tabs.component.html' +}) +export class GfPageTabsComponent { + public deviceType: string; + public readonly tabs = input.required(); + + private readonly deviceService = inject(DeviceDetectorService); + + public constructor() { + this.deviceType = this.deviceService.getDeviceInfo().deviceType; + } +} From 30643862ceccc0e3722c60973317257517e5243a Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 16 May 2026 17:47:19 +0300 Subject: [PATCH 068/132] Task/upgrade @bull-board dependencies to version 7.1.5 (#6877) * Upgrade @bull-board to version 7.1.5 * Update changelog --- CHANGELOG.md | 1 + package-lock.json | 40 ++++++++++++++++++++-------------------- package.json | 6 +++--- 3 files changed, 24 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8a33af2a..fb221493e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Extracted the page tabs to a reusable component +- Upgraded `bull-board` from version `7.0.0` to `7.1.5` ### Fixed diff --git a/package-lock.json b/package-lock.json index 6cdce7353..f45993a67 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,9 +21,9 @@ "@angular/platform-browser-dynamic": "21.2.7", "@angular/router": "21.2.7", "@angular/service-worker": "21.2.7", - "@bull-board/api": "7.0.0", - "@bull-board/express": "7.0.0", - "@bull-board/nestjs": "7.0.0", + "@bull-board/api": "7.1.5", + "@bull-board/express": "7.1.5", + "@bull-board/nestjs": "7.1.5", "@codewithdan/observable-store": "2.2.15", "@date-fns/utc": "2.1.1", "@internationalized/number": "3.6.6", @@ -3522,25 +3522,25 @@ "license": "(Apache-2.0 AND BSD-3-Clause)" }, "node_modules/@bull-board/api": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@bull-board/api/-/api-7.0.0.tgz", - "integrity": "sha512-ISNspLHVmUWUSq/eLw+wd1FuBBUnqpLbYP2xUNmehpfKhS+NoZWMbBvqjUYVeE/HLfUkRcR1edzMKpl5n9zlSw==", + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/@bull-board/api/-/api-7.1.5.tgz", + "integrity": "sha512-EW0sbTtGIysu9vipdVpPQeToPqOpPgVZTt+pn1Ut3gbSS/GLWbEgIfFtMmSQDUoSL9WH00RzjgUY5K+43nWh0A==", "license": "MIT", "dependencies": { "redis-info": "^3.1.0" }, "peerDependencies": { - "@bull-board/ui": "7.0.0" + "@bull-board/ui": "7.1.5" } }, "node_modules/@bull-board/express": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@bull-board/express/-/express-7.0.0.tgz", - "integrity": "sha512-3Tc/EyU5PQMTcTzcafFSrmRDiEbJBEU/EaVQ5OVYcuJ7DZCp5Pkvm0/2VtaCe2uywdtwn0ZaynlSIpB27FKX6A==", + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/@bull-board/express/-/express-7.1.5.tgz", + "integrity": "sha512-kp4SzhVjZlykryiQwcOhJjDhiLbBnZoAMoSgEstzqQ0raLw+jERRC6ryJ0MIQO+SO+Jv9EjjxrXCR8O2YSP/eg==", "license": "MIT", "dependencies": { - "@bull-board/api": "7.0.0", - "@bull-board/ui": "7.0.0", + "@bull-board/api": "7.1.5", + "@bull-board/ui": "7.1.5", "ejs": "^5.0.2", "express": "^5.2.1" } @@ -3558,12 +3558,12 @@ } }, "node_modules/@bull-board/nestjs": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@bull-board/nestjs/-/nestjs-7.0.0.tgz", - "integrity": "sha512-ypXm0eJHIMQzjN+3fjf84cVxugBg/K4Bpo0eYcV4u/AsteR/dnr6e7F79ICRgg1WWoczqmSMl0JhlmykpyhAMg==", + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/@bull-board/nestjs/-/nestjs-7.1.5.tgz", + "integrity": "sha512-1y+HkjnDaZoSCXJRsiYfBNBVx+PX3I8x3Uv+SSJuSpt2vHifMRwFbChO3XDxeWXetT1eR+yqPVq6ub5eJwNOYQ==", "license": "MIT", "peerDependencies": { - "@bull-board/api": "^7.0.0", + "@bull-board/api": "^7.1.5", "@nestjs/bull-shared": "^10.0.0 || ^11.0.0", "@nestjs/common": "^9.0.0 || ^10.0.0 || ^11.0.0", "@nestjs/core": "^9.0.0 || ^10.0.0 || ^11.0.0", @@ -3572,12 +3572,12 @@ } }, "node_modules/@bull-board/ui": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@bull-board/ui/-/ui-7.0.0.tgz", - "integrity": "sha512-AnKeklpDn0iMFgu4ukDU6uTNmw4oudl07G4k2Fh95SknKDrXSiWRV0N1TGUawMqyfG1Yi5P/W/8d7raBq/Uw6w==", + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/@bull-board/ui/-/ui-7.1.5.tgz", + "integrity": "sha512-2IkatKwNRx/1M9/lAZIptcxS1FPNq6icpp2M46Upwd4olVxs/ujF9Kvs+Ff9ExtIO/OgYfwx7mG2IprGZ+nQCg==", "license": "MIT", "dependencies": { - "@bull-board/api": "7.0.0" + "@bull-board/api": "7.1.5" } }, "node_modules/@cacheable/utils": { diff --git a/package.json b/package.json index 69c695889..dbbe0c3af 100644 --- a/package.json +++ b/package.json @@ -65,9 +65,9 @@ "@angular/platform-browser-dynamic": "21.2.7", "@angular/router": "21.2.7", "@angular/service-worker": "21.2.7", - "@bull-board/api": "7.0.0", - "@bull-board/express": "7.0.0", - "@bull-board/nestjs": "7.0.0", + "@bull-board/api": "7.1.5", + "@bull-board/express": "7.1.5", + "@bull-board/nestjs": "7.1.5", "@codewithdan/observable-store": "2.2.15", "@date-fns/utc": "2.1.1", "@internationalized/number": "3.6.6", From 5fcdd7fff87650f8b45c41f04811afee993c663d Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 16 May 2026 17:48:06 +0300 Subject: [PATCH 069/132] Task/improve border style on pricing page (#6875) Improve border style --- apps/client/src/app/pages/pricing/pricing-page.scss | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/client/src/app/pages/pricing/pricing-page.scss b/apps/client/src/app/pages/pricing/pricing-page.scss index 64d4bba80..dc9317c81 100644 --- a/apps/client/src/app/pages/pricing/pricing-page.scss +++ b/apps/client/src/app/pages/pricing/pricing-page.scss @@ -18,8 +18,12 @@ border-color 0.5s ease, box-shadow 0.5s ease; - &:hover, &.active { + border-color: currentColor; + box-shadow: 0 0 0 1px currentColor; + } + + &:hover { border-color: rgba(var(--palette-primary-500), 1); box-shadow: 0 0 0 1px rgba(var(--palette-primary-500), 1); } From 9ed4c679b2410907381ed388a4fd789b3d293375 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 16 May 2026 17:49:13 +0300 Subject: [PATCH 070/132] Task/enable Bull Dashboard in admin control panel without environment variable (#6876) * Enable Bull Dashboard without environment variable * Update changelog --- CHANGELOG.md | 1 + apps/api/src/app/app.module.ts | 42 +++++++++---------- apps/api/src/app/user/user.service.ts | 2 +- .../configuration/configuration.service.ts | 1 - .../interfaces/environment.interface.ts | 1 - .../data-gathering/data-gathering.module.ts | 20 ++++----- .../portfolio-snapshot.module.ts | 20 ++++----- .../statistics-gathering.module.ts | 2 +- 8 files changed, 38 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb221493e..228e9c377 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Enabled the _Bull Dashboard_ in the admin control panel without requiring an environment variable (experimental) - Extracted the page tabs to a reusable component - Upgraded `bull-board` from version `7.0.0` to `7.1.5` diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 3316f9ce4..4857c7e14 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -74,29 +74,25 @@ import { UserModule } from './user/user.module'; AuthDeviceModule, AuthModule, BenchmarksModule, - ...(process.env.ENABLE_FEATURE_BULL_BOARD === 'true' - ? [ - BullBoardModule.forRoot({ - adapter: ExpressAdapter, - boardOptions: { - uiConfig: { - boardLogo: { - height: 0, - path: '', - width: 0 - }, - boardTitle: 'Job Queues', - favIcon: { - alternative: '/assets/favicon-32x32.png', - default: '/assets/favicon-32x32.png' - } - } - }, - middleware: BullBoardAuthMiddleware, - route: BULL_BOARD_ROUTE - }) - ] - : []), + BullBoardModule.forRoot({ + adapter: ExpressAdapter, + boardOptions: { + uiConfig: { + boardLogo: { + height: 0, + path: '', + width: 0 + }, + boardTitle: 'Job Queues', + favIcon: { + alternative: '/assets/favicon-32x32.png', + default: '/assets/favicon-32x32.png' + } + } + }, + middleware: BullBoardAuthMiddleware, + route: BULL_BOARD_ROUTE + }), BullModule.forRoot({ redis: { db: parseInt(process.env.REDIS_DB ?? '0', 10), diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 0c73833f7..4ad22a043 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -532,7 +532,7 @@ export class UserService { } if (hasRole(user, Role.ADMIN)) { - if (this.configurationService.get('ENABLE_FEATURE_BULL_BOARD')) { + if ((user.settings.settings as UserSettings).isExperimentalFeatures) { currentPermissions.push(permissions.accessAdminControlBullBoard); } diff --git a/apps/api/src/services/configuration/configuration.service.ts b/apps/api/src/services/configuration/configuration.service.ts index ad8e84a99..b19508d3e 100644 --- a/apps/api/src/services/configuration/configuration.service.ts +++ b/apps/api/src/services/configuration/configuration.service.ts @@ -44,7 +44,6 @@ export class ConfigurationService { ENABLE_FEATURE_AUTH_GOOGLE: bool({ default: false }), ENABLE_FEATURE_AUTH_OIDC: bool({ default: false }), ENABLE_FEATURE_AUTH_TOKEN: bool({ default: true }), - ENABLE_FEATURE_BULL_BOARD: bool({ default: false }), ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }), ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: bool({ default: true }), ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }), diff --git a/apps/api/src/services/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts index 9664ae144..eb3ac86a3 100644 --- a/apps/api/src/services/interfaces/environment.interface.ts +++ b/apps/api/src/services/interfaces/environment.interface.ts @@ -20,7 +20,6 @@ export interface Environment extends CleanedEnvAccessors { ENABLE_FEATURE_AUTH_GOOGLE: boolean; ENABLE_FEATURE_AUTH_OIDC: boolean; ENABLE_FEATURE_AUTH_TOKEN: boolean; - ENABLE_FEATURE_BULL_BOARD: boolean; ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean; ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: boolean; ENABLE_FEATURE_READ_ONLY_MODE: boolean; diff --git a/apps/api/src/services/queues/data-gathering/data-gathering.module.ts b/apps/api/src/services/queues/data-gathering/data-gathering.module.ts index d163f0d29..5672df5e8 100644 --- a/apps/api/src/services/queues/data-gathering/data-gathering.module.ts +++ b/apps/api/src/services/queues/data-gathering/data-gathering.module.ts @@ -19,18 +19,14 @@ import { DataGatheringProcessor } from './data-gathering.processor'; @Module({ imports: [ - ...(process.env.ENABLE_FEATURE_BULL_BOARD === 'true' - ? [ - BullBoardModule.forFeature({ - adapter: BullAdapter, - name: DATA_GATHERING_QUEUE, - options: { - displayName: 'Data Gathering', - readOnlyMode: process.env.BULL_BOARD_IS_READ_ONLY !== 'false' - } - }) - ] - : []), + BullBoardModule.forFeature({ + adapter: BullAdapter, + name: DATA_GATHERING_QUEUE, + options: { + displayName: 'Data Gathering', + readOnlyMode: process.env.BULL_BOARD_IS_READ_ONLY !== 'false' + } + }), BullModule.registerQueue({ limiter: { duration: ms('4 seconds'), diff --git a/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts index 1260f1cf0..c90f826f6 100644 --- a/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts +++ b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts @@ -25,18 +25,14 @@ import { PortfolioSnapshotProcessor } from './portfolio-snapshot.processor'; imports: [ AccountBalanceModule, ActivitiesModule, - ...(process.env.ENABLE_FEATURE_BULL_BOARD === 'true' - ? [ - BullBoardModule.forFeature({ - adapter: BullAdapter, - name: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE, - options: { - displayName: 'Portfolio Snapshot Computation', - readOnlyMode: process.env.BULL_BOARD_IS_READ_ONLY !== 'false' - } - }) - ] - : []), + BullBoardModule.forFeature({ + adapter: BullAdapter, + name: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE, + options: { + displayName: 'Portfolio Snapshot Computation', + readOnlyMode: process.env.BULL_BOARD_IS_READ_ONLY !== 'false' + } + }), BullModule.registerQueue({ name: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE, settings: { diff --git a/apps/api/src/services/queues/statistics-gathering/statistics-gathering.module.ts b/apps/api/src/services/queues/statistics-gathering/statistics-gathering.module.ts index 1818dd4ec..60b963c69 100644 --- a/apps/api/src/services/queues/statistics-gathering/statistics-gathering.module.ts +++ b/apps/api/src/services/queues/statistics-gathering/statistics-gathering.module.ts @@ -13,7 +13,7 @@ import { StatisticsGatheringService } from './statistics-gathering.service'; @Module({ exports: [BullModule, StatisticsGatheringService], imports: [ - ...(process.env.ENABLE_FEATURE_BULL_BOARD === 'true' + ...(process.env.ENABLE_FEATURE_STATISTICS === 'true' ? [ BullBoardModule.forFeature({ adapter: BullAdapter, From 3a95d8e7dcb26e40d8012a5395ca20dbd89c0b1e Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 16 May 2026 19:11:24 +0300 Subject: [PATCH 071/132] Task/add monitor id check for uptime statistics gathering (#6873) Add monitor id check --- .../statistics-gathering.processor.ts | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts b/apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts index 07fc32585..1312d49ea 100644 --- a/apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts +++ b/apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts @@ -93,12 +93,25 @@ export class StatisticsGatheringProcessor { @Process(GATHER_STATISTICS_UPTIME_PROCESS_JOB_NAME) public async gatherUptimeStatistics() { + const monitorId = await this.propertyService.getByKey( + PROPERTY_BETTER_UPTIME_MONITOR_ID + ); + + if (!monitorId) { + Logger.log( + `Uptime statistics gathering has been skipped as no ${PROPERTY_BETTER_UPTIME_MONITOR_ID} is configured`, + 'StatisticsGatheringProcessor' + ); + + return; + } + Logger.log( 'Uptime statistics gathering has been started', 'StatisticsGatheringProcessor' ); - const uptime = await this.getUptime(); + const uptime = await this.getUptime(monitorId); await this.propertyService.put({ key: PROPERTY_UPTIME, @@ -179,12 +192,8 @@ export class StatisticsGatheringProcessor { } } - private async getUptime(): Promise { + private async getUptime(monitorId: string): Promise { try { - const monitorId = await this.propertyService.getByKey( - PROPERTY_BETTER_UPTIME_MONITOR_ID - ); - const { data } = await fetch( `https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format( subDays(new Date(), 90), From 703ea3492ce1ef7254ca5ed13f3744b4ff31d7a0 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 16 May 2026 19:51:43 +0300 Subject: [PATCH 072/132] Task/extract page tabs to dedicated component (part 2) (#6881) Fix fab container style on mobile --- libs/ui/src/lib/page-tabs/page-tabs.component.scss | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/libs/ui/src/lib/page-tabs/page-tabs.component.scss b/libs/ui/src/lib/page-tabs/page-tabs.component.scss index bed3596bb..182d2eca1 100644 --- a/libs/ui/src/lib/page-tabs/page-tabs.component.scss +++ b/libs/ui/src/lib/page-tabs/page-tabs.component.scss @@ -14,13 +14,13 @@ ) ); - .fab-container { - @media (max-width: 575.98px) { - bottom: 5rem; + ::ng-deep { + .fab-container { + @media (max-width: 575.98px) { + bottom: 5rem; + } } - } - ::ng-deep { .mat-mdc-tab-nav-panel { padding: 2rem 0; } From 859aa8b39b6d0393d5878e702c65bbe81605dfbb Mon Sep 17 00:00:00 2001 From: lil-goat <167018448+lil-goat@users.noreply.github.com> Date: Sun, 17 May 2026 02:07:53 +0800 Subject: [PATCH 073/132] Task/improve pagination for activities in holding detail dialog (#6874) * Improve pagination * Update changelog --- CHANGELOG.md | 1 + .../holding-detail-dialog.component.ts | 52 +++++++++++++------ .../holding-detail-dialog.html | 3 ++ 3 files changed, 40 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 228e9c377..d46d92da9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Improved the pagination in the activities table of the holding detail dialog - Enabled the _Bull Dashboard_ in the admin control panel without requiring an environment variable (experimental) - Extracted the page tabs to a reusable component - Upgraded `bull-board` from version `7.0.0` to `7.1.5` diff --git a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts index bf4955e45..8c42e37ea 100644 --- a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts +++ b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts @@ -1,5 +1,6 @@ import { UserService } from '@ghostfolio/client/services/user/user.service'; import { + DEFAULT_PAGE_SIZE, NUMERICAL_PRECISION_THRESHOLD_3_FIGURES, NUMERICAL_PRECISION_THRESHOLD_5_FIGURES, NUMERICAL_PRECISION_THRESHOLD_6_FIGURES @@ -48,6 +49,7 @@ import { MatDialogRef } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; +import { PageEvent } from '@angular/material/paginator'; import { SortDirection } from '@angular/material/sort'; import { MatTableDataSource } from '@angular/material/table'; import { MatTabsModule } from '@angular/material/tabs'; @@ -140,6 +142,8 @@ export class GfHoldingDetailDialogComponent implements OnInit { public netPerformancePercentWithCurrencyEffectPrecision = 2; public netPerformanceWithCurrencyEffect: number; public netPerformanceWithCurrencyEffectPrecision = 2; + public pageIndex = 0; + public pageSize = DEFAULT_PAGE_SIZE; public quantity: number; public quantityPrecision = 2; public reportDataGlitchMail: string; @@ -178,10 +182,7 @@ export class GfHoldingDetailDialogComponent implements OnInit { } public ngOnInit() { - const filters: Filter[] = [ - { id: this.data.dataSource, type: 'DATA_SOURCE' }, - { id: this.data.symbol, type: 'SYMBOL' } - ]; + const filters = this.getActivityFilters(); this.holdingForm = this.formBuilder.group({ tags: [] as string[] @@ -240,18 +241,7 @@ export class GfHoldingDetailDialogComponent implements OnInit { this.changeDetectorRef.markForCheck(); }); - this.dataService - .fetchActivities({ - filters, - sortColumn: this.sortColumn, - sortDirection: this.sortDirection - }) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(({ activities }) => { - this.dataSource = new MatTableDataSource(activities); - - this.changeDetectorRef.markForCheck(); - }); + this.fetchActivities(filters); this.dataService .fetchHoldingDetail({ @@ -543,6 +533,12 @@ export class GfHoldingDetailDialogComponent implements OnInit { }); } + public onChangePage(page: PageEvent) { + this.pageIndex = page.pageIndex; + + this.fetchActivities(); + } + public onCloneActivity(aActivity: Activity) { this.router.navigate( internalRoutes.portfolio.subRoutes.activities.routerLink, @@ -626,6 +622,23 @@ export class GfHoldingDetailDialogComponent implements OnInit { this.dialogRef.close(); } + private fetchActivities(filters: Filter[] = this.getActivityFilters()) { + this.dataService + .fetchActivities({ + filters, + skip: this.pageIndex * this.pageSize, + sortColumn: this.sortColumn, + sortDirection: this.sortDirection, + take: this.pageSize + }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(({ activities }) => { + this.dataSource = new MatTableDataSource(activities); + + this.changeDetectorRef.markForCheck(); + }); + } + private fetchMarketData() { this.dataService .fetchMarketDataBySymbol({ @@ -648,4 +661,11 @@ export class GfHoldingDetailDialogComponent implements OnInit { this.changeDetectorRef.markForCheck(); }); } + + private getActivityFilters(): Filter[] { + return [ + { id: this.data.dataSource, type: 'DATA_SOURCE' }, + { id: this.data.symbol, type: 'SYMBOL' } + ]; + } } diff --git a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html index 7c1906ee5..4b04a0986 100644 --- a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html +++ b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html @@ -353,6 +353,8 @@ [hasPermissionToFilter]="false" [hasPermissionToOpenDetails]="false" [locale]="data.locale" + [pageIndex]="pageIndex" + [pageSize]="pageSize" [showActions]=" !data.hasImpersonationId && data.hasPermissionToCreateActivity && @@ -367,6 +369,7 @@ (activityToClone)="onCloneActivity($event)" (activityToUpdate)="onUpdateActivity($event)" (export)="onExport()" + (pageChanged)="onChangePage($event)" /> From bd2ca4aacdf715f53acb0950f30d69531a7e80a2 Mon Sep 17 00:00:00 2001 From: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com> Date: Sun, 17 May 2026 01:49:16 +0700 Subject: [PATCH 074/132] Task/support DIRECT_URL for Prisma CLI and seeding to support connection poolers (#6882) * Add support for DIRECT_URL * Update changelog --- .config/prisma.ts | 2 +- CHANGELOG.md | 4 ++++ README.md | 41 +++++++++++++++++++++-------------------- prisma/seed.mts | 2 +- 4 files changed, 27 insertions(+), 22 deletions(-) diff --git a/.config/prisma.ts b/.config/prisma.ts index a9e7bbf64..4556ba305 100644 --- a/.config/prisma.ts +++ b/.config/prisma.ts @@ -7,7 +7,7 @@ expand(config({ quiet: true })); export default defineConfig({ datasource: { - url: process.env.DATABASE_URL ?? '' + url: process.env.DIRECT_URL ?? process.env.DATABASE_URL }, migrations: { path: join(__dirname, '..', 'prisma', 'migrations'), diff --git a/CHANGELOG.md b/CHANGELOG.md index d46d92da9..1d696039b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Added support for the `DIRECT_URL` environment variable to enable direct database connections + ### Changed - Improved the pagination in the activities table of the holding detail dialog diff --git a/README.md b/README.md index 87710d610..8557d4330 100644 --- a/README.md +++ b/README.md @@ -85,26 +85,27 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c ### Supported Environment Variables -| Name | Type | Default Value | Description | -| --------------------------- | --------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------- | -| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens | -| `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key | -| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key | -| `DATABASE_URL` | `string` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}` | -| `ENABLE_FEATURE_AUTH_TOKEN` | `boolean` (optional) | `true` | Enables authentication via security token | -| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on | -| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) | -| `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` | -| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on | -| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database | -| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database | -| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database | -| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ | -| `REDIS_HOST` | `string` | | The host where _Redis_ is running | -| `REDIS_PASSWORD` | `string` | | The password of _Redis_ | -| `REDIS_PORT` | `number` | | The port where _Redis_ is running | -| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds | -| `ROOT_URL` | `string` (optional) | `http://0.0.0.0:3333` | The root URL of the Ghostfolio application, used for generating callback URLs and external links. | +| Name | Type | Default Value | Description | +| --------------------------- | --------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens | +| `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key | +| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key | +| `DATABASE_URL` | `string` | | The database connection URL. If using a connection pooler, use the pooled connection URL here. e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}` | +| `DIRECT_URL` | `string` (optional) | | The direct database connection URL used by the _Prisma CLI_ (e.g. for schema migrations) and seeding, bypassing any connection poolers (falls back to `DATABASE_URL`) | +| `ENABLE_FEATURE_AUTH_TOKEN` | `boolean` (optional) | `true` | Enables authentication via security token | +| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on | +| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) | +| `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` | +| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on | +| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database | +| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database | +| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database | +| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ | +| `REDIS_HOST` | `string` | | The host where _Redis_ is running | +| `REDIS_PASSWORD` | `string` | | The password of _Redis_ | +| `REDIS_PORT` | `number` | | The port where _Redis_ is running | +| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds | +| `ROOT_URL` | `string` (optional) | `http://0.0.0.0:3333` | The root URL of the Ghostfolio application, used for generating callback URLs and external links. | #### OpenID Connect OIDC (Experimental) diff --git a/prisma/seed.mts b/prisma/seed.mts index 4819e241e..81f34d05a 100644 --- a/prisma/seed.mts +++ b/prisma/seed.mts @@ -2,7 +2,7 @@ import { PrismaPg } from '@prisma/adapter-pg'; import { PrismaClient } from '@prisma/client'; const adapter = new PrismaPg({ - connectionString: process.env.DATABASE_URL + connectionString: process.env.DIRECT_URL ?? process.env.DATABASE_URL }); const prisma = new PrismaClient({ adapter }); From e1e6ae7b5712619db74ee7dd00da2708357adf05 Mon Sep 17 00:00:00 2001 From: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com> Date: Sun, 17 May 2026 13:52:00 +0700 Subject: [PATCH 075/132] Task/improve type safety in admin market data component (#6885) Improve type safety --- .../admin-market-data.component.ts | 152 ++++++++---------- libs/common/src/lib/config.ts | 3 + .../treemap-chart.component.stories.ts | 4 +- 3 files changed, 75 insertions(+), 84 deletions(-) diff --git a/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts b/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts index 0abe9ea0f..72a3c337a 100644 --- a/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts +++ b/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts @@ -1,7 +1,8 @@ import { UserService } from '@ghostfolio/client/services/user/user.service'; import { + DEFAULT_COLOR_SCHEME, DEFAULT_PAGE_SIZE, - ghostfolioScraperApiSymbolPrefix + locale } from '@ghostfolio/common/config'; import { getDateFormatString } from '@ghostfolio/common/helper'; import { @@ -26,9 +27,11 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, + computed, DestroyRef, + inject, OnInit, - ViewChild + viewChild } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatButtonModule } from '@angular/material/button'; @@ -97,11 +100,8 @@ import { CreateAssetProfileDialogParams } from './create-asset-profile-dialog/in templateUrl: './admin-market-data.html' }) export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { - @ViewChild(MatPaginator) paginator: MatPaginator; - @ViewChild(MatSort) sort: MatSort; - - public activeFilters: Filter[] = []; - public allFilters: Filter[] = [ + protected readonly adminMarketDataService = inject(AdminMarketDataService); + protected readonly allFilters: Filter[] = [ ...Object.keys(AssetSubClass) .filter((assetSubClass) => { return assetSubClass !== 'CASH'; @@ -146,37 +146,39 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { type: 'PRESET_ID' as Filter['type'] } ]; - public benchmarks: Partial[]; - public currentDataSource: DataSource; - public currentSymbol: string; - public dataSource = new MatTableDataSource(); - public defaultDateFormat: string; - public deviceType: string; - public displayedColumns: string[] = []; - public filters$ = new Subject(); - public ghostfolioScraperApiSymbolPrefix = ghostfolioScraperApiSymbolPrefix; - public hasPermissionForSubscription: boolean; - public info: InfoItem; - public isLoading = true; - public isUUID = isUUID; - public placeholder = ''; - public pageSize = DEFAULT_PAGE_SIZE; - public selection: SelectionModel>; - public totalItems = 0; - public user: User; - - public constructor( - public adminMarketDataService: AdminMarketDataService, - private adminService: AdminService, - private changeDetectorRef: ChangeDetectorRef, - private dataService: DataService, - private destroyRef: DestroyRef, - private deviceDetectorService: DeviceDetectorService, - private dialog: MatDialog, - private route: ActivatedRoute, - private router: Router, - private userService: UserService - ) { + protected dataSource = new MatTableDataSource(); + protected defaultDateFormat: string; + protected readonly displayedColumns: string[] = []; + protected readonly filters$ = new Subject(); + protected isLoading = true; + protected readonly isUUID = isUUID; + protected pageSize = DEFAULT_PAGE_SIZE; + protected placeholder = ''; + protected readonly selection = new SelectionModel(true); + protected totalItems = 0; + protected user: User; + + private activeFilters: Filter[] = []; + private benchmarks: Partial[]; + private readonly deviceType = computed( + () => this.deviceDetectorService.deviceInfo().deviceType + ); + private readonly hasPermissionForSubscription: boolean; + private readonly info: InfoItem; + private readonly paginator = viewChild.required(MatPaginator); + private readonly sort = viewChild.required(MatSort); + + private readonly adminService = inject(AdminService); + private readonly changeDetectorRef = inject(ChangeDetectorRef); + private readonly dataService = inject(DataService); + private readonly destroyRef = inject(DestroyRef); + private readonly deviceDetectorService = inject(DeviceDetectorService); + private readonly dialog = inject(MatDialog); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly userService = inject(UserService); + + public constructor() { this.info = this.dataService.fetchInfo(); this.hasPermissionForSubscription = hasPermission( @@ -255,14 +257,14 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { } public ngAfterViewInit() { - this.sort.sortChange.subscribe( + this.sort().sortChange.subscribe( ({ active: sortColumn, direction }: Sort) => { - this.paginator.pageIndex = 0; + this.paginator().pageIndex = 0; this.loadData({ sortColumn, sortDirection: direction, - pageIndex: this.paginator.pageIndex + pageIndex: this.paginator().pageIndex }); } ); @@ -272,24 +274,24 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { const { benchmarks } = this.dataService.fetchInfo(); this.benchmarks = benchmarks; - this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; - - this.selection = new SelectionModel(true); } - public onChangePage(page: PageEvent) { + protected onChangePage(page: PageEvent) { this.loadData({ pageIndex: page.pageIndex, - sortColumn: this.sort.active, - sortDirection: this.sort.direction + sortColumn: this.sort().active, + sortDirection: this.sort().direction }); } - public onDeleteAssetProfile({ dataSource, symbol }: AssetProfileIdentifier) { + protected onDeleteAssetProfile({ + dataSource, + symbol + }: AssetProfileIdentifier) { this.adminMarketDataService.deleteAssetProfile({ dataSource, symbol }); } - public onDeleteAssetProfiles() { + protected onDeleteAssetProfiles() { this.adminMarketDataService.deleteAssetProfiles( this.selection.selected.map(({ dataSource, symbol }) => { return { dataSource, symbol }; @@ -297,7 +299,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { ); } - public onGather7Days() { + protected onGather7Days() { this.adminService .gather7Days() .pipe(takeUntilDestroyed(this.destroyRef)) @@ -308,7 +310,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { }); } - public onGatherMax() { + protected onGatherMax() { this.adminService .gatherMax() .pipe(takeUntilDestroyed(this.destroyRef)) @@ -319,31 +321,14 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { }); } - public onGatherProfileData() { + protected onGatherProfileData() { this.adminService .gatherProfileData() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(); } - public onGatherProfileDataBySymbol({ - dataSource, - symbol - }: AssetProfileIdentifier) { - this.adminService - .gatherProfileDataBySymbol({ dataSource, symbol }) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(); - } - - public onGatherSymbol({ dataSource, symbol }: AssetProfileIdentifier) { - this.adminService - .gatherSymbol({ dataSource, symbol }) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(); - } - - public onOpenAssetProfileDialog({ + protected onOpenAssetProfileDialog({ dataSource, symbol }: AssetProfileIdentifier) { @@ -375,8 +360,8 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { ? Number.MAX_SAFE_INTEGER : DEFAULT_PAGE_SIZE; - if (pageIndex === 0 && this.paginator) { - this.paginator.pageIndex = 0; + if (pageIndex === 0 && this.paginator()) { + this.paginator().pageIndex = 0; } this.placeholder = @@ -406,7 +391,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { }; }) ); - this.dataSource.sort = this.sort; + this.dataSource.sort = this.sort(); this.isLoading = false; @@ -435,12 +420,13 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { data: { dataSource, symbol, - colorScheme: this.user?.settings.colorScheme, - deviceType: this.deviceType, - locale: this.user?.settings?.locale - }, - height: this.deviceType === 'mobile' ? '98vh' : '80vh', - width: this.deviceType === 'mobile' ? '100vw' : '50rem' + colorScheme: + this.user?.settings.colorScheme ?? DEFAULT_COLOR_SCHEME, + deviceType: this.deviceType(), + locale: this.user?.settings?.locale ?? locale + } satisfies AssetProfileDialogParams, + height: this.deviceType() === 'mobile' ? '98vh' : '80vh', + width: this.deviceType() === 'mobile' ? '100vw' : '50rem' }); dialogRef @@ -471,10 +457,10 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { >(GfCreateAssetProfileDialogComponent, { autoFocus: false, data: { - deviceType: this.deviceType, - locale: this.user?.settings?.locale - }, - width: this.deviceType === 'mobile' ? '100vw' : '50rem' + deviceType: this.deviceType(), + locale: this.user?.settings?.locale ?? locale + } satisfies CreateAssetProfileDialogParams, + width: this.deviceType() === 'mobile' ? '100vw' : '50rem' }); dialogRef diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index 42e1f6b63..9d6122c6c 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -2,6 +2,8 @@ import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client'; import { JobOptions, JobStatus } from 'bull'; import ms from 'ms'; +import { ColorScheme } from './types'; + export const ghostfolioPrefix = 'GF'; export const ghostfolioScraperApiSymbolPrefix = `_${ghostfolioPrefix}_`; export const ghostfolioFearAndGreedIndexDataSourceCryptocurrencies = @@ -77,6 +79,7 @@ export const PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_LOW = export const STATISTICS_GATHERING_QUEUE = 'STATISTICS_GATHERING_QUEUE'; +export const DEFAULT_COLOR_SCHEME: ColorScheme = 'LIGHT'; export const DEFAULT_CURRENCY = 'USD'; export const DEFAULT_DATE_FORMAT_MONTH_YEAR = 'MMM yyyy'; export const DEFAULT_HOST = '0.0.0.0'; diff --git a/libs/ui/src/lib/treemap-chart/treemap-chart.component.stories.ts b/libs/ui/src/lib/treemap-chart/treemap-chart.component.stories.ts index c8951ce6b..e98b85252 100644 --- a/libs/ui/src/lib/treemap-chart/treemap-chart.component.stories.ts +++ b/libs/ui/src/lib/treemap-chart/treemap-chart.component.stories.ts @@ -1,3 +1,5 @@ +import { DEFAULT_COLOR_SCHEME } from '@ghostfolio/common/config'; + import { CommonModule } from '@angular/common'; import '@angular/localize/init'; import { moduleMetadata } from '@storybook/angular'; @@ -37,7 +39,7 @@ export const Default: Story = { args: { holdings, baseCurrency: 'USD', - colorScheme: 'LIGHT', + colorScheme: DEFAULT_COLOR_SCHEME, cursor: undefined, dateRange: 'mtd', locale: 'en-US' From f2385fe831fbec2cd26dc335c4be6a369e16a51d Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 17 May 2026 09:55:20 +0300 Subject: [PATCH 076/132] Task/remove spacing in title of asset profile dialog (#6883) Remove spacing --- .../asset-profile-dialog/asset-profile-dialog.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html index 32ae24eb2..a14546973 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html @@ -1,7 +1,7 @@

    - {{ assetProfile?.name ?? data.symbol }} + {{ assetProfile?.name ?? data.symbol }}

    @@ -18,12 +18,12 @@ Compare with... - @for (symbolProfile of benchmarks; track symbolProfile) { + @for (symbolProfile of benchmarks(); track symbolProfile) { {{ symbolProfile.name }} @@ -41,7 +41,7 @@
    - @if (isLoading) { + @if (isLoading()) {
    diff --git a/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts b/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts index 2ecefc311..d2dc9e1bb 100644 --- a/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts +++ b/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts @@ -22,12 +22,11 @@ import { ChangeDetectionStrategy, Component, type ElementRef, - EventEmitter, - Input, + input, OnChanges, OnDestroy, - Output, - ViewChild + output, + viewChild } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MatSelectModule } from '@angular/material/select'; @@ -68,24 +67,25 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; templateUrl: './benchmark-comparator.component.html' }) export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy { - @Input() benchmark: Partial; - @Input() benchmarkDataItems: LineChartItem[] = []; - @Input() benchmarks: Partial[]; - @Input() colorScheme: ColorScheme; - @Input() isLoading: boolean; - @Input() locale = getLocale(); - @Input() performanceDataItems: LineChartItem[]; - @Input() user: User; + public readonly benchmark = input>(); + public readonly benchmarkDataItems = input([]); + public readonly benchmarks = input[]>(); + public readonly colorScheme = input.required(); + public readonly isLoading = input(); + public readonly locale = input(getLocale()); + public readonly performanceDataItems = input.required(); + public readonly user = input(); - @Output() benchmarkChanged = new EventEmitter(); + public readonly benchmarkChanged = output(); - @ViewChild('chartCanvas') chartCanvas: ElementRef; - - public chart: Chart<'line'>; - public hasPermissionToAccessAdminControl: boolean; - public routerLinkAdminControlMarketData = + protected chart: Chart<'line'>; + protected hasPermissionToAccessAdminControl: boolean; + protected readonly routerLinkAdminControlMarketData = internalRoutes.adminControl.subRoutes.marketData.routerLink; + private readonly chartCanvas = + viewChild.required>('chartCanvas'); + public constructor() { Chart.register( annotationPlugin, @@ -104,27 +104,27 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy { public ngOnChanges() { this.hasPermissionToAccessAdminControl = hasPermission( - this.user?.permissions, + this.user()?.permissions, permissions.accessAdminControl ); - if (this.performanceDataItems) { + if (this.performanceDataItems()) { this.initialize(); } } - public onChangeBenchmark(symbolProfileId: string) { - this.benchmarkChanged.next(symbolProfileId); - } - public ngOnDestroy() { this.chart?.destroy(); } + protected onChangeBenchmark(symbolProfileId: string) { + this.benchmarkChanged.emit(symbolProfileId); + } + private initialize() { const benchmarkDataValues: Record = {}; - for (const { date, value } of this.benchmarkDataItems) { + for (const { date, value } of this.benchmarkDataItems()) { benchmarkDataValues[date] = value; } @@ -134,8 +134,11 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy { backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`, borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`, borderWidth: 2, - data: this.performanceDataItems.map(({ date, value }) => { - return { x: parseDate(date).getTime(), y: value * 100 }; + data: this.performanceDataItems().map(({ date, value }) => { + return { + x: parseDate(date)?.getTime() ?? null, + y: value * 100 + }; }), label: $localize`Portfolio` }, @@ -143,9 +146,9 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy { backgroundColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`, borderColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`, borderWidth: 2, - data: this.performanceDataItems.map(({ date }) => { + data: this.performanceDataItems().map(({ date }) => { return { - x: parseDate(date).getTime(), + x: parseDate(date)?.getTime() ?? null, y: benchmarkDataValues[date] }; }), @@ -163,7 +166,7 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy { this.chart.update(); } else { - this.chart = new Chart(this.chartCanvas.nativeElement, { + this.chart = new Chart<'line'>(this.chartCanvas().nativeElement, { data, options: { animation: false, @@ -172,7 +175,7 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy { tension: 0 }, point: { - hoverBackgroundColor: getBackgroundColor(this.colorScheme), + hoverBackgroundColor: getBackgroundColor(this.colorScheme()), hoverRadius: 2, radius: 0 } @@ -183,7 +186,7 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy { annotation: { annotations: { yAxis: { - borderColor: `rgba(${getTextColor(this.colorScheme)}, 0.1)`, + borderColor: `rgba(${getTextColor(this.colorScheme())}, 0.1)`, borderWidth: 1, scaleID: 'y', type: 'line', @@ -196,14 +199,14 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy { }, tooltip: this.getTooltipPluginConfiguration(), verticalHoverLine: { - color: `rgba(${getTextColor(this.colorScheme)}, 0.1)` + color: `rgba(${getTextColor(this.colorScheme())}, 0.1)` } }, responsive: true, scales: { x: { border: { - color: `rgba(${getTextColor(this.colorScheme)}, 0.1)`, + color: `rgba(${getTextColor(this.colorScheme())}, 0.1)`, width: 1 }, display: true, @@ -212,7 +215,7 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy { }, type: 'time', time: { - tooltipFormat: getDateFormatString(this.locale), + tooltipFormat: getDateFormatString(this.locale()), unit: 'year' } }, @@ -228,7 +231,7 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy { tick.value === scale.max || tick.value === scale.min ) { - return `rgba(${getTextColor(this.colorScheme)}, 0.1)`; + return `rgba(${getTextColor(this.colorScheme())}, 0.1)`; } return 'transparent'; @@ -247,7 +250,7 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy { } }, plugins: [ - getVerticalHoverLinePlugin(this.chartCanvas, this.colorScheme) + getVerticalHoverLinePlugin(this.chartCanvas(), this.colorScheme()) ], type: 'line' }); @@ -258,8 +261,8 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy { private getTooltipPluginConfiguration(): Partial> { return { ...getTooltipOptions({ - colorScheme: this.colorScheme, - locale: this.locale, + colorScheme: this.colorScheme(), + locale: this.locale(), unit: '%' }), mode: 'index', From 5dca0445bad6c028de834c43680f6d44d0eb1e97 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Mon, 18 May 2026 21:52:43 +0300 Subject: [PATCH 085/132] Task/upgrade Nx to version 22.7.2 (#6890) * Upgrade Nx to version 22.7.2 * Update changelog --- CHANGELOG.md | 1 + package-lock.json | 857 ++++++++++++++++++++++------------------------ package.json | 22 +- 3 files changed, 421 insertions(+), 459 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54eedaead..447469ea7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Extracted the page tabs to a reusable component - Improved the language localization for German (`de`) - Upgraded `bull-board` from version `7.0.0` to `7.1.5` +- Upgraded `Nx` from version `22.7.1` to `22.7.2` ### Fixed diff --git a/package-lock.json b/package-lock.json index f45993a67..9641c61b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -113,16 +113,16 @@ "@eslint/js": "9.35.0", "@nestjs/schematics": "11.1.0", "@nestjs/testing": "11.1.19", - "@nx/angular": "22.7.1", - "@nx/eslint-plugin": "22.7.1", - "@nx/jest": "22.7.1", - "@nx/js": "22.7.1", - "@nx/module-federation": "22.7.1", - "@nx/nest": "22.7.1", - "@nx/node": "22.7.1", - "@nx/storybook": "22.7.1", - "@nx/web": "22.7.1", - "@nx/workspace": "22.7.1", + "@nx/angular": "22.7.2", + "@nx/eslint-plugin": "22.7.2", + "@nx/jest": "22.7.2", + "@nx/js": "22.7.2", + "@nx/module-federation": "22.7.2", + "@nx/nest": "22.7.2", + "@nx/node": "22.7.2", + "@nx/storybook": "22.7.2", + "@nx/web": "22.7.2", + "@nx/workspace": "22.7.2", "@schematics/angular": "21.2.6", "@storybook/addon-docs": "10.1.10", "@storybook/addon-themes": "10.1.10", @@ -149,7 +149,7 @@ "jest": "30.2.0", "jest-environment-jsdom": "30.2.0", "jest-preset-angular": "16.0.0", - "nx": "22.7.1", + "nx": "22.7.2", "prettier": "3.8.3", "prettier-plugin-organize-attributes": "1.0.0", "prisma": "7.8.0", @@ -8962,21 +8962,21 @@ } }, "node_modules/@nx/angular": { - "version": "22.7.1", - "resolved": "https://registry.npmjs.org/@nx/angular/-/angular-22.7.1.tgz", - "integrity": "sha512-tgEKO7fVQPchDISJZfI256ggzYoPcADomLDQFqs7oYTBaH7ZMDMzTOMRprrittbd1Me4o568ttdh/1Ya1fFv9g==", + "version": "22.7.2", + "resolved": "https://registry.npmjs.org/@nx/angular/-/angular-22.7.2.tgz", + "integrity": "sha512-+HCggLwJXp55ZdKrn0VkYfw9gGgZpiIHdlY8m3KnwJzdA+Tfl9t10JvidFXprk7gmnRaU8hHidfz6e1juG6D6g==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.7.1", - "@nx/eslint": "22.7.1", - "@nx/js": "22.7.1", - "@nx/module-federation": "22.7.1", - "@nx/rspack": "22.7.1", - "@nx/web": "22.7.1", - "@nx/webpack": "22.7.1", - "@nx/workspace": "22.7.1", - "@phenomnomnominal/tsquery": "~6.1.4", + "@nx/devkit": "22.7.2", + "@nx/eslint": "22.7.2", + "@nx/js": "22.7.2", + "@nx/module-federation": "22.7.2", + "@nx/rspack": "22.7.2", + "@nx/web": "22.7.2", + "@nx/webpack": "22.7.2", + "@nx/workspace": "22.7.2", + "@phenomnomnominal/tsquery": "~6.2.0", "@typescript-eslint/type-utils": "^8.0.0", "enquirer": "~2.3.6", "magic-string": "~0.30.2", @@ -9023,17 +9023,17 @@ } }, "node_modules/@nx/cypress": { - "version": "22.7.1", - "resolved": "https://registry.npmjs.org/@nx/cypress/-/cypress-22.7.1.tgz", - "integrity": "sha512-ieypLZFh4iCjfrcSWeYOPk/2Vg8O/CtmQBqqVhPdBLP9p0zgMBvym0XAtyaywhKphrYfVs1eIwwMMRA6Jn55Ng==", + "version": "22.7.2", + "resolved": "https://registry.npmjs.org/@nx/cypress/-/cypress-22.7.2.tgz", + "integrity": "sha512-ivrwIXNTn0p9nGg2z3mjZJYPuH7X+O3eMtuqyPglkxKlAhuUQXVmKYB9nIqRMGR2nGCi9cDC7J38h+0Py+TunA==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.7.1", - "@nx/eslint": "22.7.1", - "@nx/js": "22.7.1", - "@phenomnomnominal/tsquery": "~6.1.4", - "detect-port": "^1.5.1", + "@nx/devkit": "22.7.2", + "@nx/eslint": "22.7.2", + "@nx/js": "22.7.2", + "@phenomnomnominal/tsquery": "~6.2.0", + "detect-port": "^2.1.0", "semver": "^7.6.3", "tree-kill": "1.2.2", "tslib": "^2.3.0" @@ -9048,16 +9048,16 @@ } }, "node_modules/@nx/devkit": { - "version": "22.7.1", - "resolved": "https://registry.npmjs.org/@nx/devkit/-/devkit-22.7.1.tgz", - "integrity": "sha512-z2ayFHq406MyVpNtksGnsfHOYZVTSInwQgZeg6u+S4sD21Wvb+oldhqkbYX46jiGJSaw5aUjFdzXJu2l4MYP1A==", + "version": "22.7.2", + "resolved": "https://registry.npmjs.org/@nx/devkit/-/devkit-22.7.2.tgz", + "integrity": "sha512-oE2SFUxQeZm/EmFABHpWQ4Pi0fBKbJbXKGPvdFaHoMumRxhqBhuBVf/ap5kYFg8Y9bK/zHJkpsEbGyiyRrhvog==", "dev": true, "license": "MIT", "dependencies": { "@zkochan/js-yaml": "0.0.7", "ejs": "5.0.1", "enquirer": "~2.3.6", - "minimatch": "10.2.4", + "minimatch": "10.2.5", "semver": "^7.6.3", "tslib": "^2.3.0", "yargs-parser": "21.1.1" @@ -9077,9 +9077,9 @@ } }, "node_modules/@nx/devkit/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -9103,13 +9103,13 @@ } }, "node_modules/@nx/devkit/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -9119,32 +9119,32 @@ } }, "node_modules/@nx/docker": { - "version": "22.7.1", - "resolved": "https://registry.npmjs.org/@nx/docker/-/docker-22.7.1.tgz", - "integrity": "sha512-jTB9pczTG7gQsaWdycWw8azm8iZ4AkuFttjmIVK4gGtoRf8rzJadn+pID/DHAwKay/Kxb7w2RyfFDJcyNCtvsQ==", + "version": "22.7.2", + "resolved": "https://registry.npmjs.org/@nx/docker/-/docker-22.7.2.tgz", + "integrity": "sha512-VSORTGE28czjDePM5XvNnbwneowlT/6N0t0Jhh6cJtSGCCWwaT4WQb8uVYOgchDr77HwOGhgezI79mmoKHWnsw==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.7.1", + "@nx/devkit": "22.7.2", "enquirer": "~2.3.6", "tslib": "^2.3.0" } }, "node_modules/@nx/eslint": { - "version": "22.7.1", - "resolved": "https://registry.npmjs.org/@nx/eslint/-/eslint-22.7.1.tgz", - "integrity": "sha512-wBx/U1NTZ4arbjFrI7bI0zd1FMSBpqIszzfJ0pXyqrHA3KxNLFTQI713XoSD2hxTNrbh4owFQD7SYG4WpNN3CA==", + "version": "22.7.2", + "resolved": "https://registry.npmjs.org/@nx/eslint/-/eslint-22.7.2.tgz", + "integrity": "sha512-LDWFg6CNtORnEnwB3XSJBjm8QnheN3F9HxE/kq69Fx+4drkSYEXjRx+27M+9kSP1z2HriSQn5LjEmz1yQD8stA==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.7.1", - "@nx/js": "22.7.1", + "@nx/devkit": "22.7.2", + "@nx/js": "22.7.2", "semver": "^7.6.3", "tslib": "^2.3.0", "typescript": "~5.9.2" }, "peerDependencies": { - "@nx/jest": "22.7.1", + "@nx/jest": "22.7.2", "@zkochan/js-yaml": "0.0.7", "eslint": "^8.0.0 || ^9.0.0 || ^10.0.0" }, @@ -9158,20 +9158,20 @@ } }, "node_modules/@nx/eslint-plugin": { - "version": "22.7.1", - "resolved": "https://registry.npmjs.org/@nx/eslint-plugin/-/eslint-plugin-22.7.1.tgz", - "integrity": "sha512-LkW2QwkiB0MslknVN8If2JQZHFPgD1FkfTkVzgw2j+3B/+4dBN8aJsWgr6b3T4vn/n6XxtwFd7NVuJlsk5nyyQ==", + "version": "22.7.2", + "resolved": "https://registry.npmjs.org/@nx/eslint-plugin/-/eslint-plugin-22.7.2.tgz", + "integrity": "sha512-OgfyUt4dUrlTHcnygVLXcxP0KH7yOAaB9pfdWLe86QRWm2Ei4sRdACltPRXoa9tDeEa4EdBvDTccw0AZ3UnrvQ==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.7.1", - "@nx/js": "22.7.1", - "@phenomnomnominal/tsquery": "~6.1.4", + "@nx/devkit": "22.7.2", + "@nx/js": "22.7.2", + "@phenomnomnominal/tsquery": "~6.2.0", "@typescript-eslint/type-utils": "^8.0.0", "@typescript-eslint/utils": "^8.0.0", "chalk": "^4.1.0", "confusing-browser-globals": "^1.0.9", - "globals": "^15.9.0", + "globals": "^17.0.0", "jsonc-eslint-parser": "^2.1.0", "semver": "^7.6.3", "tslib": "^2.3.0" @@ -9187,9 +9187,9 @@ } }, "node_modules/@nx/eslint-plugin/node_modules/globals": { - "version": "15.15.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", - "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", "dev": true, "license": "MIT", "engines": { @@ -9200,22 +9200,22 @@ } }, "node_modules/@nx/jest": { - "version": "22.7.1", - "resolved": "https://registry.npmjs.org/@nx/jest/-/jest-22.7.1.tgz", - "integrity": "sha512-5e9O19Gv6vF/xgyu6fdW49fSB11VqAuWyI/ls0Fjy+2c71JuuJXftF/HfYf7SH008PqccVyXYMCZZNGRGuqWsg==", + "version": "22.7.2", + "resolved": "https://registry.npmjs.org/@nx/jest/-/jest-22.7.2.tgz", + "integrity": "sha512-t+UYRCUUT7BYoRohjf6lWVzeeITjytclxE1ENEzU0+PCAKYN8yJfAWLSsLfK4YDDBv2lTXyzHo7b5Pxpbmz+Qw==", "dev": true, "license": "MIT", "dependencies": { "@jest/reporters": "^30.0.2", "@jest/test-result": "^30.0.2", - "@nx/devkit": "22.7.1", - "@nx/js": "22.7.1", - "@phenomnomnominal/tsquery": "~6.1.4", + "@nx/devkit": "22.7.2", + "@nx/js": "22.7.2", + "@phenomnomnominal/tsquery": "~6.2.0", "identity-obj-proxy": "3.0.0", "jest-config": "^30.0.2", "jest-resolve": "^30.0.2", "jest-util": "^30.0.2", - "minimatch": "10.2.4", + "minimatch": "10.2.5", "picocolors": "^1.1.0", "resolve.exports": "2.0.3", "semver": "^7.6.3", @@ -9234,9 +9234,9 @@ } }, "node_modules/@nx/jest/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -9247,13 +9247,13 @@ } }, "node_modules/@nx/jest/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -9263,9 +9263,9 @@ } }, "node_modules/@nx/js": { - "version": "22.7.1", - "resolved": "https://registry.npmjs.org/@nx/js/-/js-22.7.1.tgz", - "integrity": "sha512-zvPaamdAFehy4PsA963sJuwVXehsbpSJQJgEW6xcWy58lYLI/NRQHSZn4yJmuaFVnuuciRlmiacCom242byWnw==", + "version": "22.7.2", + "resolved": "https://registry.npmjs.org/@nx/js/-/js-22.7.2.tgz", + "integrity": "sha512-d1Hb/2n3QKE9rs8gRtfa/b1/GCGm1rnBFiqePivWbD/9iqerhgkbs6cg4MliLGRnD8gZgXSENLm4IW8ISOi69w==", "dev": true, "license": "MIT", "dependencies": { @@ -9276,16 +9276,16 @@ "@babel/preset-env": "^7.23.2", "@babel/preset-typescript": "^7.22.5", "@babel/runtime": "^7.22.6", - "@nx/devkit": "22.7.1", - "@nx/workspace": "22.7.1", + "@nx/devkit": "22.7.2", + "@nx/workspace": "22.7.2", "@zkochan/js-yaml": "0.0.7", "babel-plugin-const-enum": "^1.0.1", "babel-plugin-macros": "^3.1.0", "babel-plugin-transform-typescript-metadata": "^0.3.1", "chalk": "^4.1.0", "columnify": "^1.6.0", - "detect-port": "^1.5.1", - "ignore": "^5.0.4", + "detect-port": "^2.1.0", + "ignore": "^7.0.5", "js-tokens": "^4.0.0", "jsonc-parser": "3.2.0", "npm-run-path": "^4.0.1", @@ -9305,6 +9305,16 @@ } } }, + "node_modules/@nx/js/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/@nx/js/node_modules/jsonc-parser": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", @@ -9334,18 +9344,18 @@ } }, "node_modules/@nx/module-federation": { - "version": "22.7.1", - "resolved": "https://registry.npmjs.org/@nx/module-federation/-/module-federation-22.7.1.tgz", - "integrity": "sha512-dNPQU9Gx8PcdUjiSbQoya+/Q0oQvVT1l+tNDBx5M72zertoFdSogNqeudS0256/mHbbgGRYX6vteafJBOLuSjg==", + "version": "22.7.2", + "resolved": "https://registry.npmjs.org/@nx/module-federation/-/module-federation-22.7.2.tgz", + "integrity": "sha512-8KblqEdVw0b6uzhVSxz+RbjodN1BnHtWk1J4ndxG5XxiDLvW8bVEmpQAfn6DebsSRTIr+N/e3pah84j7xhZ9Yw==", "dev": true, "license": "MIT", "dependencies": { "@module-federation/enhanced": "^2.3.3", "@module-federation/node": "^2.7.21", "@module-federation/sdk": "^2.1.0", - "@nx/devkit": "22.7.1", - "@nx/js": "22.7.1", - "@nx/web": "22.7.1", + "@nx/devkit": "22.7.2", + "@nx/js": "22.7.2", + "@nx/web": "22.7.2", "@rspack/core": "1.6.8", "express": "^4.21.2", "http-proxy-middleware": "^3.0.5", @@ -9532,15 +9542,15 @@ } }, "node_modules/@nx/module-federation/node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "dev": true, "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "~1.20.3", + "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", @@ -9559,7 +9569,7 @@ "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "~6.14.0", + "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -9578,22 +9588,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/@nx/module-federation/node_modules/express/node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/@nx/module-federation/node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -9782,41 +9776,41 @@ } }, "node_modules/@nx/nest": { - "version": "22.7.1", - "resolved": "https://registry.npmjs.org/@nx/nest/-/nest-22.7.1.tgz", - "integrity": "sha512-Neo6ejcY/7EbvpyzamXSbvknAUljwn68ZU/zNz3F8zDchqX4hrPVxpEr2tOY5JhEtx0ZgNk5gPUls7O80NNqPg==", + "version": "22.7.2", + "resolved": "https://registry.npmjs.org/@nx/nest/-/nest-22.7.2.tgz", + "integrity": "sha512-Xpja5pry0RWJ8K5t25Eh0HCe5Z6kg81oZh3Qr17iodh4jxAKCMbSpx+1j8PNmC6PFfzbhJyXY6gWOtnaCgbh3g==", "dev": true, "license": "MIT", "dependencies": { "@nestjs/schematics": "^11.0.0", - "@nx/devkit": "22.7.1", - "@nx/eslint": "22.7.1", - "@nx/js": "22.7.1", - "@nx/node": "22.7.1", + "@nx/devkit": "22.7.2", + "@nx/eslint": "22.7.2", + "@nx/js": "22.7.2", + "@nx/node": "22.7.2", "tslib": "^2.3.0" } }, "node_modules/@nx/node": { - "version": "22.7.1", - "resolved": "https://registry.npmjs.org/@nx/node/-/node-22.7.1.tgz", - "integrity": "sha512-RCKCDvI3EYkAE7XlGmvNlgZj5zG3a+AaY1WTi/3GsaiuDCRPjZWg2Jm4H/ay4wGqE4JZeFMISjcA+n6ABrL2yA==", + "version": "22.7.2", + "resolved": "https://registry.npmjs.org/@nx/node/-/node-22.7.2.tgz", + "integrity": "sha512-6nj6siMZy45r4hITYfHcqrOFpadYEkbfqwQP3xgTXZvTt6foX0HHoeOcv1rvaEvgdG6/DuZWQj4z8ursCnMWPw==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.7.1", - "@nx/docker": "22.7.1", - "@nx/eslint": "22.7.1", - "@nx/jest": "22.7.1", - "@nx/js": "22.7.1", + "@nx/devkit": "22.7.2", + "@nx/docker": "22.7.2", + "@nx/eslint": "22.7.2", + "@nx/jest": "22.7.2", + "@nx/js": "22.7.2", "kill-port": "^1.6.1", "tcp-port-used": "^1.0.2", "tslib": "^2.3.0" } }, "node_modules/@nx/nx-darwin-arm64": { - "version": "22.7.1", - "resolved": "https://registry.npmjs.org/@nx/nx-darwin-arm64/-/nx-darwin-arm64-22.7.1.tgz", - "integrity": "sha512-m00ZmBn39VUgb0Ahhu5iY6D56ETdXjDbVnOz0XF3DacJrcLtq9sZ+cg1bj6PshqtvRWVg+zJRrZBU6vL7hGuFQ==", + "version": "22.7.2", + "resolved": "https://registry.npmjs.org/@nx/nx-darwin-arm64/-/nx-darwin-arm64-22.7.2.tgz", + "integrity": "sha512-hu+x/IOzx+18imkFwSdtXnvB6d21qcXvc4bCqcbA9BQcUnvTnw0/11SLoasvDqy/9KLKHDWJAIPttcBkbArWVA==", "cpu": [ "arm64" ], @@ -9828,9 +9822,9 @@ ] }, "node_modules/@nx/nx-darwin-x64": { - "version": "22.7.1", - "resolved": "https://registry.npmjs.org/@nx/nx-darwin-x64/-/nx-darwin-x64-22.7.1.tgz", - "integrity": "sha512-DmD8Qow+Yt7Yrmjlz1AsfiwxW+0kRzg+6MY70+d7qChtD2bTzvA/k0ut8SMy+CxU3kxgUbKhGOtml5JDXoX2ww==", + "version": "22.7.2", + "resolved": "https://registry.npmjs.org/@nx/nx-darwin-x64/-/nx-darwin-x64-22.7.2.tgz", + "integrity": "sha512-M4QPs4rjzZN51V7qiKUjJU7hLYtv/h0I/aGUedCQQZibbbDTl45sQlgBQlV/viw2dOw3K5+RxDxtMNFxAbhxQA==", "cpu": [ "x64" ], @@ -9842,9 +9836,9 @@ ] }, "node_modules/@nx/nx-freebsd-x64": { - "version": "22.7.1", - "resolved": "https://registry.npmjs.org/@nx/nx-freebsd-x64/-/nx-freebsd-x64-22.7.1.tgz", - "integrity": "sha512-HboVrUCHcuYTXtuX3dMyRszP7JO90ZVBLWgnmaM7jUM7jnllZjmezUMtpNHfN1GQbVFafJf/NBShDWsu9LuaUA==", + "version": "22.7.2", + "resolved": "https://registry.npmjs.org/@nx/nx-freebsd-x64/-/nx-freebsd-x64-22.7.2.tgz", + "integrity": "sha512-tdC2mBQ/ON9qvTs72aL3XVN7B5wd7UsiRJ/qwC2bk/PIpD0vo5c3EwxFyYXfTD60jnlV+CTFxhSVmu8S1pVsfw==", "cpu": [ "x64" ], @@ -9856,9 +9850,9 @@ ] }, "node_modules/@nx/nx-linux-arm-gnueabihf": { - "version": "22.7.1", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm-gnueabihf/-/nx-linux-arm-gnueabihf-22.7.1.tgz", - "integrity": "sha512-5Gm8Y7L8WXMLUjHhiy1eqGz5/PiRw1YLanFg5audBNkZvH6Jkwzdpoz0dbeKjwMDHz4NmniUV1s76Th8VLWmiQ==", + "version": "22.7.2", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm-gnueabihf/-/nx-linux-arm-gnueabihf-22.7.2.tgz", + "integrity": "sha512-bBHIC9xZ8L12BWkwMKbRi7+oV4UH1v1Yy8PsIvRfjS7GzYNlOAUMkJxywjF2msnkp8M8Rn29MEvzllZjdyaR7Q==", "cpu": [ "arm" ], @@ -9870,9 +9864,9 @@ ] }, "node_modules/@nx/nx-linux-arm64-gnu": { - "version": "22.7.1", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-gnu/-/nx-linux-arm64-gnu-22.7.1.tgz", - "integrity": "sha512-GdgPYMfbijBRFJs1absL/9QdSNLsTAGdyKykDf9CaVxEMZ92VB+pncpX9Vn/ZBCSeeWTLF+bSK3UM5v+loIObQ==", + "version": "22.7.2", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-gnu/-/nx-linux-arm64-gnu-22.7.2.tgz", + "integrity": "sha512-MBYG58VUTmLW4S2RlYmXJiV6P0P1lkiZXtiaulZOXmP5uCSXiqMgK47k56hq9GTbtW1SpyGgh02lkNdCYTbmLw==", "cpu": [ "arm64" ], @@ -9884,9 +9878,9 @@ ] }, "node_modules/@nx/nx-linux-arm64-musl": { - "version": "22.7.1", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-musl/-/nx-linux-arm64-musl-22.7.1.tgz", - "integrity": "sha512-HyBgPtY1hyNTk8683nt7F29jh3lVdS/zul9vS0NgKeCSoYL3GRM3nLoTPynoHUxyVP/tWYOE3ymvnk92qYwL4Q==", + "version": "22.7.2", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-musl/-/nx-linux-arm64-musl-22.7.2.tgz", + "integrity": "sha512-Wf4VBSJt5gEGdzX6uzZoITEYB/Y3TxjvPNT11NKfRU/m63b8/D8jCeRmr7cBTaMUlNmdH3Lf3G1PuPNGoEZ0Mg==", "cpu": [ "arm64" ], @@ -9898,9 +9892,9 @@ ] }, "node_modules/@nx/nx-linux-x64-gnu": { - "version": "22.7.1", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-gnu/-/nx-linux-x64-gnu-22.7.1.tgz", - "integrity": "sha512-bQBgRiEsanNvKcDOjVAUPjvcp0iDLofYYUL2af2iuCDxreLOej+J6MeA5bWTLNly5ly1d4voKGTqa+OsouVyLg==", + "version": "22.7.2", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-gnu/-/nx-linux-x64-gnu-22.7.2.tgz", + "integrity": "sha512-v3AQyfCkv9k+AWT2hy8hAGaCmFYf+G/bt4KAqnWhmXPWNhxrv9FhvTUcjpY+MY+6v7sKdhJv/3eDvtlLd9FOLg==", "cpu": [ "x64" ], @@ -9912,9 +9906,9 @@ ] }, "node_modules/@nx/nx-linux-x64-musl": { - "version": "22.7.1", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-musl/-/nx-linux-x64-musl-22.7.1.tgz", - "integrity": "sha512-gcco2GjcAztF/fRcAgFxtWxrWDnQdNmPaAN9FTt1+qQ9RUSLvdL8bQxKx4Kd9N9T+gXPlrWhMkBkKbbV09+X1Q==", + "version": "22.7.2", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-musl/-/nx-linux-x64-musl-22.7.2.tgz", + "integrity": "sha512-3SMfMB7ynr8wGGTZP+/ZV7FqkCsOg1Raoka+4EtIPX66bEcBycg8FVg81DbyV+IzuKk3N+8Hl2IeY1W2btPypw==", "cpu": [ "x64" ], @@ -9926,9 +9920,9 @@ ] }, "node_modules/@nx/nx-win32-arm64-msvc": { - "version": "22.7.1", - "resolved": "https://registry.npmjs.org/@nx/nx-win32-arm64-msvc/-/nx-win32-arm64-msvc-22.7.1.tgz", - "integrity": "sha512-IT9oEn0YQ83iPH7666aoPyTRsUzBIBJdBLMXeLX4I60fHPXWhUSGpfiLtIsgU2OfeOVb9hU9idwNh1wc4u9rWQ==", + "version": "22.7.2", + "resolved": "https://registry.npmjs.org/@nx/nx-win32-arm64-msvc/-/nx-win32-arm64-msvc-22.7.2.tgz", + "integrity": "sha512-eTFTTF1JUKXu+PNOGd7KAdqyWyfvFKO/wpqHoq9fjnbjXgCdCg1PaRxHIxA1WT5HFj1iHS6Or+GC1zA1KNt0Sw==", "cpu": [ "arm64" ], @@ -9940,9 +9934,9 @@ ] }, "node_modules/@nx/nx-win32-x64-msvc": { - "version": "22.7.1", - "resolved": "https://registry.npmjs.org/@nx/nx-win32-x64-msvc/-/nx-win32-x64-msvc-22.7.1.tgz", - "integrity": "sha512-P2zeSKXVH2Eiwsb8UfP2rMMS7//cHWpiO4M9zt6q0c4lI/hN1vXBciRKVWruGk9ZrWLHuhaMAhG94+MJtzKuRQ==", + "version": "22.7.2", + "resolved": "https://registry.npmjs.org/@nx/nx-win32-x64-msvc/-/nx-win32-x64-msvc-22.7.2.tgz", + "integrity": "sha512-fbVAiJ7RKSanUXrL67Z6as7BY1akznRqo71ACmrxLvLicG3UsmATbHKGp0zULoe3jBm+rNrIrLk+quZn5q0wUg==", "cpu": [ "x64" ], @@ -9954,17 +9948,17 @@ ] }, "node_modules/@nx/rspack": { - "version": "22.7.1", - "resolved": "https://registry.npmjs.org/@nx/rspack/-/rspack-22.7.1.tgz", - "integrity": "sha512-zizR6BWvH9klo9QTkWNRwTO/Rhk2RxPZOZNnPt/bV9adDWSvY+8CYojxtkdXAfiwj65WenvL9WYJUhKu137W4g==", + "version": "22.7.2", + "resolved": "https://registry.npmjs.org/@nx/rspack/-/rspack-22.7.2.tgz", + "integrity": "sha512-hlBIqhL9otJEQ9x8pf6CYqz0DdTKRW1w3jQ7c1hSafEHrC+OHs3UGReYeDO9Avua5eNA4/FVMLUVFUOPNzk3Wg==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.7.1", - "@nx/js": "22.7.1", - "@nx/module-federation": "22.7.1", - "@nx/web": "22.7.1", - "@phenomnomnominal/tsquery": "~6.1.4", + "@nx/devkit": "22.7.2", + "@nx/js": "22.7.2", + "@nx/module-federation": "22.7.2", + "@nx/web": "22.7.2", + "@phenomnomnominal/tsquery": "~6.2.0", "@rspack/core": "1.6.8", "@rspack/dev-server": "^1.1.4", "@rspack/plugin-react-refresh": "^1.0.0", @@ -10207,15 +10201,15 @@ } }, "node_modules/@nx/rspack/node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "dev": true, "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "~1.20.3", + "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", @@ -10234,7 +10228,7 @@ "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "~6.14.0", + "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -10253,22 +10247,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/@nx/rspack/node_modules/express/node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/@nx/rspack/node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -10479,22 +10457,22 @@ } }, "node_modules/@nx/storybook": { - "version": "22.7.1", - "resolved": "https://registry.npmjs.org/@nx/storybook/-/storybook-22.7.1.tgz", - "integrity": "sha512-bjYGB08/lBNNCvgIZijdACQzcb8asuRNlkU9j/D9mGk2wIWv7Ub+Sj3Q8HGnDvpPdSFgbbo0vGw1xGrX4votBw==", + "version": "22.7.2", + "resolved": "https://registry.npmjs.org/@nx/storybook/-/storybook-22.7.2.tgz", + "integrity": "sha512-kf34LHK0nvyTsXG43FqBGBBPzq9Th90hdWUVktLo636K6/mDvVPTM033BPzQDIljEqWAD+34YPiCtKGLE3H1Ag==", "dev": true, "license": "MIT", "dependencies": { - "@nx/cypress": "22.7.1", - "@nx/devkit": "22.7.1", - "@nx/eslint": "22.7.1", - "@nx/js": "22.7.1", - "@phenomnomnominal/tsquery": "~6.1.4", + "@nx/cypress": "22.7.2", + "@nx/devkit": "22.7.2", + "@nx/eslint": "22.7.2", + "@nx/js": "22.7.2", + "@phenomnomnominal/tsquery": "~6.2.0", "semver": "^7.6.3", "tslib": "^2.3.0" }, "peerDependencies": { - "@nx/web": "22.7.1", + "@nx/web": "22.7.2", "storybook": ">=7.0.0 <11.0.0" }, "peerDependenciesMeta": { @@ -10504,26 +10482,26 @@ } }, "node_modules/@nx/web": { - "version": "22.7.1", - "resolved": "https://registry.npmjs.org/@nx/web/-/web-22.7.1.tgz", - "integrity": "sha512-l3rwX1Oi307YAFmfZG6a8elZa/Faj2LvI6PuX7uaYsiSV7GcnFIId1DRCMfhGC/L63uqfcuy34X23+8jrXX9hw==", + "version": "22.7.2", + "resolved": "https://registry.npmjs.org/@nx/web/-/web-22.7.2.tgz", + "integrity": "sha512-DgjlnOlPOpRFHJuItUbm3+DRZqZQkqVUTRhxS/Ep5QtMx/KeO6jbULHFS4BTDV54/I20ejjsWvADYcYhsZaY1g==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.7.1", - "@nx/js": "22.7.1", - "detect-port": "^1.5.1", + "@nx/devkit": "22.7.2", + "@nx/js": "22.7.2", + "detect-port": "^2.1.0", "http-server": "^14.1.0", "picocolors": "^1.1.0", "tslib": "^2.3.0" }, "peerDependencies": { - "@nx/cypress": "22.7.1", - "@nx/eslint": "22.7.1", - "@nx/jest": "22.7.1", - "@nx/playwright": "22.7.1", - "@nx/vite": "22.7.1", - "@nx/webpack": "22.7.1" + "@nx/cypress": "22.7.2", + "@nx/eslint": "22.7.2", + "@nx/jest": "22.7.2", + "@nx/playwright": "22.7.2", + "@nx/vite": "22.7.2", + "@nx/webpack": "22.7.2" }, "peerDependenciesMeta": { "@nx/cypress": { @@ -10547,16 +10525,16 @@ } }, "node_modules/@nx/webpack": { - "version": "22.7.1", - "resolved": "https://registry.npmjs.org/@nx/webpack/-/webpack-22.7.1.tgz", - "integrity": "sha512-XMZPhLXN9d+Q1Axmc8nFSbUlxNfgM5Tvl1pdb+K4WPpQE9Q+hF+E2vNfQa1hgL0Hu6bmScSJAGmrllZ9dfQAjA==", + "version": "22.7.2", + "resolved": "https://registry.npmjs.org/@nx/webpack/-/webpack-22.7.2.tgz", + "integrity": "sha512-XVJcf2Bn1P7GoxJRESKFF5bXqWGPyE6+Bw1BraUmtM7bBRiF3tSXaYrzdwQlyNxZdK82RH+Y8hkBSXX3VZ8Rkg==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.23.2", - "@nx/devkit": "22.7.1", - "@nx/js": "22.7.1", - "@phenomnomnominal/tsquery": "~6.1.4", + "@nx/devkit": "22.7.2", + "@nx/js": "22.7.2", + "@phenomnomnominal/tsquery": "~6.2.0", "ajv": "^8.12.0", "autoprefixer": "^10.4.9", "babel-loader": "^9.1.2", @@ -10683,9 +10661,9 @@ } }, "node_modules/@nx/webpack/node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "dev": true, "license": "MIT", "bin": { @@ -10767,17 +10745,17 @@ } }, "node_modules/@nx/workspace": { - "version": "22.7.1", - "resolved": "https://registry.npmjs.org/@nx/workspace/-/workspace-22.7.1.tgz", - "integrity": "sha512-wnBMgeogdGaRdxDDzZspSt1U87PMeYJhz1ygr42L9Lot9E5nCf17E85iyHl3YxcMSNslHxnHyTkq7MyQ8hHjdQ==", + "version": "22.7.2", + "resolved": "https://registry.npmjs.org/@nx/workspace/-/workspace-22.7.2.tgz", + "integrity": "sha512-xTEQMkeltIS6V5Qb6QRA7O+HIJQjIZSxLm6SvBNczJqAxckuYwMdbrb2IkDSE0XnQqR3gYg7Isz6UuBUHjz66Q==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.7.1", + "@nx/devkit": "22.7.2", "@zkochan/js-yaml": "0.0.7", "chalk": "^4.1.0", "enquirer": "~2.3.6", - "nx": "22.7.1", + "nx": "22.7.2", "picomatch": "4.0.4", "semver": "^7.6.3", "tslib": "^2.3.0", @@ -11278,17 +11256,17 @@ } }, "node_modules/@phenomnomnominal/tsquery": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/@phenomnomnominal/tsquery/-/tsquery-6.1.4.tgz", - "integrity": "sha512-3tHlGy/fxjJCHqIV8nelAzbRTNkCUY+k7lqBGKNuQz99H2OKGRt6oU+U2SZs6LYrbOe8mxMFl6kq6gzHapFRkw==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/@phenomnomnominal/tsquery/-/tsquery-6.2.0.tgz", + "integrity": "sha512-Vo9nkhfZxDB/sBiqIY3pjDC4mOSyure+AFlEW5hcy/tRE82MqCXjRN4InnVNMldinRt0dLYqg4HAU2XPq5e1LA==", "dev": true, "license": "MIT", "dependencies": { - "@types/esquery": "^1.5.0", - "esquery": "^1.5.0" + "@types/esquery": "^1.5.4", + "esquery": "^1.7.0" }, "peerDependencies": { - "typescript": "^3 || ^4 || ^5" + "typescript": ">3.0.0" } }, "node_modules/@pkgjs/parseargs": { @@ -12826,15 +12804,15 @@ } }, "node_modules/@rspack/dev-server/node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "dev": true, "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "~1.20.3", + "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", @@ -12853,7 +12831,7 @@ "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "~6.14.0", + "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -12872,22 +12850,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/@rspack/dev-server/node_modules/express/node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/@rspack/dev-server/node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -16339,13 +16301,13 @@ } }, "node_modules/address": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/address/-/address-1.2.2.tgz", - "integrity": "sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/address/-/address-2.0.3.tgz", + "integrity": "sha512-XNAb/a6TCqou+TufU8/u11HCu9x1gYvOoxLwtlXgIqmkrYQADVv6ljyW2zwiPhHz9R1gItAWpuDrdJMmrOBFEA==", "dev": true, "license": "MIT", "engines": { - "node": ">= 10.0.0" + "node": ">= 16.0.0" } }, "node_modules/adjust-sourcemap-loader": { @@ -16898,13 +16860,13 @@ } }, "node_modules/axios": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", - "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", + "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", "dev": true, "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } @@ -19025,13 +18987,13 @@ } }, "node_modules/css-minimizer-webpack-plugin/node_modules/cssnano": { - "version": "7.1.7", - "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.1.7.tgz", - "integrity": "sha512-N5LGn/OlhMxDTvKACwUPMzT34SSj1b022pvUAE/Vh6r2WD1aUCbc+QNIP/JjX9VVxebdJWZQ3352Lt4oF7dQ/g==", + "version": "7.1.9", + "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.1.9.tgz", + "integrity": "sha512-uPR75+5Dk/WJ/YSPR1/YDHdwMM9c5FsaARljfKWgeCKLKOtJ0we21xy/RcCjn53fZnD/f6yYEIZ8pu18+GnbNQ==", "dev": true, "license": "MIT", "dependencies": { - "cssnano-preset-default": "^7.0.15", + "cssnano-preset-default": "^7.0.17", "lilconfig": "^3.1.3" }, "engines": { @@ -19042,71 +19004,71 @@ "url": "https://opencollective.com/cssnano" }, "peerDependencies": { - "postcss": "^8.5.10" + "postcss": "^8.5.13" } }, "node_modules/css-minimizer-webpack-plugin/node_modules/cssnano-preset-default": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-7.0.15.tgz", - "integrity": "sha512-60kx7lJ40//HA85cIfQXSOJFby2D2V1pOMNHVCxue3KFWCjRzmiQyL9OvI+NAhwUlaojOfF9eK3nGvrJLCBUfQ==", + "version": "7.0.17", + "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-7.0.17.tgz", + "integrity": "sha512-11qO63A+czwguQFJCaTdICvbaxn0pJzz/XghLlv+OT7WyToDxAMR0Xb3/26/l0y0hQJywwNbj/SLSQlGBHE1OA==", "dev": true, "license": "MIT", "dependencies": { "browserslist": "^4.28.2", "css-declaration-sorter": "^7.2.0", - "cssnano-utils": "^5.0.2", + "cssnano-utils": "^5.0.3", "postcss-calc": "^10.1.1", - "postcss-colormin": "^7.0.9", - "postcss-convert-values": "^7.0.11", - "postcss-discard-comments": "^7.0.7", - "postcss-discard-duplicates": "^7.0.3", - "postcss-discard-empty": "^7.0.2", - "postcss-discard-overridden": "^7.0.2", - "postcss-merge-longhand": "^7.0.6", - "postcss-merge-rules": "^7.0.10", - "postcss-minify-font-values": "^7.0.2", - "postcss-minify-gradients": "^7.0.4", - "postcss-minify-params": "^7.0.8", - "postcss-minify-selectors": "^7.1.0", - "postcss-normalize-charset": "^7.0.2", - "postcss-normalize-display-values": "^7.0.2", - "postcss-normalize-positions": "^7.0.3", - "postcss-normalize-repeat-style": "^7.0.3", - "postcss-normalize-string": "^7.0.2", - "postcss-normalize-timing-functions": "^7.0.2", - "postcss-normalize-unicode": "^7.0.8", - "postcss-normalize-url": "^7.0.2", - "postcss-normalize-whitespace": "^7.0.2", - "postcss-ordered-values": "^7.0.3", - "postcss-reduce-initial": "^7.0.8", - "postcss-reduce-transforms": "^7.0.2", - "postcss-svgo": "^7.1.2", - "postcss-unique-selectors": "^7.0.6" + "postcss-colormin": "^7.0.10", + "postcss-convert-values": "^7.0.12", + "postcss-discard-comments": "^7.0.8", + "postcss-discard-duplicates": "^7.0.4", + "postcss-discard-empty": "^7.0.3", + "postcss-discard-overridden": "^7.0.3", + "postcss-merge-longhand": "^7.0.7", + "postcss-merge-rules": "^7.0.11", + "postcss-minify-font-values": "^7.0.3", + "postcss-minify-gradients": "^7.0.5", + "postcss-minify-params": "^7.0.9", + "postcss-minify-selectors": "^7.1.2", + "postcss-normalize-charset": "^7.0.3", + "postcss-normalize-display-values": "^7.0.3", + "postcss-normalize-positions": "^7.0.4", + "postcss-normalize-repeat-style": "^7.0.4", + "postcss-normalize-string": "^7.0.3", + "postcss-normalize-timing-functions": "^7.0.3", + "postcss-normalize-unicode": "^7.0.9", + "postcss-normalize-url": "^7.0.3", + "postcss-normalize-whitespace": "^7.0.3", + "postcss-ordered-values": "^7.0.4", + "postcss-reduce-initial": "^7.0.9", + "postcss-reduce-transforms": "^7.0.3", + "postcss-svgo": "^7.1.3", + "postcss-unique-selectors": "^7.0.7" }, "engines": { "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.5.10" + "postcss": "^8.5.13" } }, "node_modules/css-minimizer-webpack-plugin/node_modules/cssnano-utils": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-5.0.2.tgz", - "integrity": "sha512-kt41WLK7FLKfePzPi645Y+/NtW/nNM7Su6nlNUfJyRNW3JcuU3JU7+cWJc+JexTeZ8dRBvFufefdG2XpXkIo0A==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-5.0.3.tgz", + "integrity": "sha512-ynIREMICLxkxm7e9bCR9sh75s4Q5drICi0ua1yxo5jH2XPBqSKkl4dOh4EbFqtUmnTMhRffHgYL0EKKkMjtJTg==", "dev": true, "license": "MIT", "engines": { "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.5.10" + "postcss": "^8.5.13" } }, "node_modules/css-minimizer-webpack-plugin/node_modules/postcss": { - "version": "8.5.13", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", - "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "dev": true, "funding": [ { @@ -19133,13 +19095,13 @@ } }, "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-colormin": { - "version": "7.0.9", - "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-7.0.9.tgz", - "integrity": "sha512-EZpoUlmbXQUpe+g4ZaGM2kjGlHrQ7Bjzb3xHcNrC9ysI1tGoib6DAYvxg6aB7MGxsjgLF+Qx/jwZQkJ5cKDvXA==", + "version": "7.0.10", + "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-7.0.10.tgz", + "integrity": "sha512-yFr6JezOolHLta/buLE71VKPh2mXursp4saVe98/ol8ZnEWhL+racShqPKlvd/DKWLre/39B6HhcMXf7RZ3hxg==", "dev": true, "license": "MIT", "dependencies": { - "@colordx/core": "^5.2.0", + "@colordx/core": "^5.4.3", "browserslist": "^4.28.2", "caniuse-api": "^3.0.0", "postcss-value-parser": "^4.2.0" @@ -19148,13 +19110,13 @@ "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.5.10" + "postcss": "^8.5.13" } }, "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-convert-values": { - "version": "7.0.11", - "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-7.0.11.tgz", - "integrity": "sha512-H+s7P0f9jJylSysAHs3/5MhAx7GthDO05uw1h56L2xyEqpiLTFLEqBNw3PUYzD5p/AKwWaigCXf6FGELpOw9lw==", + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-7.0.12.tgz", + "integrity": "sha512-xurKu5qqk4viR3Cp3p4xBR4KfnZm4w4ys6+UBwBmeuBSNkH7+DtLnYOYnOffgtE4yx8sH9S1VZ6RAAvROXzP2Q==", "dev": true, "license": "MIT", "dependencies": { @@ -19165,13 +19127,13 @@ "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.5.10" + "postcss": "^8.5.13" } }, "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-discard-comments": { - "version": "7.0.7", - "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-7.0.7.tgz", - "integrity": "sha512-FJhE3fSte7HaRNL4iwD8LTG9vWqj3puxXIdig6LfrFqc1TJRUhY4kXOkeTXZZfTXYny+k+SO7fd2fymj1wduJg==", + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-7.0.8.tgz", + "integrity": "sha512-CvvS5S9WrXblFXCEJ9nVo+4z+eA7zSC7Z88V1HEJuwlQhlFnYTIjg1xJY+BCUiG2bvICap2tXii4mP22BD108Q==", "dev": true, "license": "MIT", "dependencies": { @@ -19181,88 +19143,88 @@ "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.5.10" + "postcss": "^8.5.13" } }, "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-discard-duplicates": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-7.0.3.tgz", - "integrity": "sha512-9cRxXwhEM/aNZon1qZyToX4NmjbFbxOGbww+0CnbYFDbbPRGZ8jg4IbM8UlA+CzkXxM35itxyaHKNqBBg/RTDg==", + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-7.0.4.tgz", + "integrity": "sha512-VBNn1+EuMZkeGVVtz0gRfbNGtx9IFgAsAV+E2pHtXPrp4qfGBkhTIiAuE/wrb+Y6Pakg9NewAlfTpYIFAWODtw==", "dev": true, "license": "MIT", "engines": { "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.5.10" + "postcss": "^8.5.13" } }, "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-discard-empty": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-7.0.2.tgz", - "integrity": "sha512-NZFouOmOwtngJVgkNeI1LtkzFdYqIurxgy4wq3qNvIiXFURTZ3b/K7q3dP3QitlWQ5imHDQL0qSorItQhoxb1g==", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-7.0.3.tgz", + "integrity": "sha512-M2pyjQCU+/7cMHVtL6bKTHjv0lZnPLMpicgr67Dlth7AbuV9gjVTtUqaRwn6Pp6BwSDspUzhz8SaUrRykJU5Dw==", "dev": true, "license": "MIT", "engines": { "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.5.10" + "postcss": "^8.5.13" } }, "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-discard-overridden": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-7.0.2.tgz", - "integrity": "sha512-Ym01X4v6U3sY8X0P1J9P+RTar+7JyLTOzDrxKSeaArFsLmkVu4KcAKPBWDYRIyZ/q4jwpSPnOnekeSSqXSXKUw==", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-7.0.3.tgz", + "integrity": "sha512-aNovXo9UsZuRNLzHJtp13lHIvinDPfiXBPePpXkSjCbgp++iU2FqE+YxvjIsg6EdyPZsASFbfu+JcBFVsErXIQ==", "dev": true, "license": "MIT", "engines": { "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.5.10" + "postcss": "^8.5.13" } }, "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-merge-longhand": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-7.0.6.tgz", - "integrity": "sha512-lDsWeKRsssX/9vKFpingoRiuvGajtOGCJhs1kyaTJ5fzaVzs0aPPYe38UZ/ukMFEA5iuRIjQJHIkH2niYO3ubQ==", + "version": "7.0.7", + "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-7.0.7.tgz", + "integrity": "sha512-b3mfYUxR388u5Pt0HPcVIUtUDn/k15UfTY9M+ORW+meCR6JLNxoZffiYvXyOYQoRYQNZyX/UFkMCM/mNHxe1qA==", "dev": true, "license": "MIT", "dependencies": { "postcss-value-parser": "^4.2.0", - "stylehacks": "^7.0.10" + "stylehacks": "^7.0.11" }, "engines": { "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.5.10" + "postcss": "^8.5.13" } }, "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-merge-rules": { - "version": "7.0.10", - "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-7.0.10.tgz", - "integrity": "sha512-UXYKxkg8Cy1so/evF7AE/25PNXZb3E0SrvjdbtbGf+MW+doLenKqRLQzz6YZW469ktiXK2MVLFWtel/DftCV0Q==", + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-7.0.11.tgz", + "integrity": "sha512-SJUPM18g2BmPhf8BVlbwqWz4aK3pLu6u6xjfwEzra7xL6IBR10sUaiB++EzqcVfadPHrKBSMlNdP+XieykhI+Q==", "dev": true, "license": "MIT", "dependencies": { "browserslist": "^4.28.2", "caniuse-api": "^3.0.0", - "cssnano-utils": "^5.0.2", + "cssnano-utils": "^5.0.3", "postcss-selector-parser": "^7.1.1" }, "engines": { "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.5.10" + "postcss": "^8.5.13" } }, "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-minify-font-values": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-7.0.2.tgz", - "integrity": "sha512-Z82NUmnvhPrvMUaHfkaAVBmWQq9F8Dox4Dy0LiwbaTxfmDUWLQtS+0WCgKViwdWCPPajiY9YzoQftgqKdXkM5g==", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-7.0.3.tgz", + "integrity": "sha512-yilG/VOaNI74IylQvAQQxm3/wZVBkXyYUqNUAdxqwtbWUXPsbK1q8Ms0mL83v+f8YicgcyfYCRZtWACUdYajpA==", "dev": true, "license": "MIT", "dependencies": { @@ -19272,49 +19234,49 @@ "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.5.10" + "postcss": "^8.5.13" } }, "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-minify-gradients": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-7.0.4.tgz", - "integrity": "sha512-g8MNeNyN+lbwKy8DCtJ6zU6awBL0InBsSOaKmgZ1MdRLVItLQUNFNAzzzBnOp4qowOcyyB6GetTlQ0/0UNXvag==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-7.0.5.tgz", + "integrity": "sha512-YraROyQRg3BI1+Hg8E05B/JPdnTm8EDSVu4P2BxdM+CRiOyfmou809+chGIqo6fQqwjPGQ947nbGncSjmTU1WQ==", "dev": true, "license": "MIT", "dependencies": { - "@colordx/core": "^5.2.0", - "cssnano-utils": "^5.0.2", + "@colordx/core": "^5.4.3", + "cssnano-utils": "^5.0.3", "postcss-value-parser": "^4.2.0" }, "engines": { "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.5.10" + "postcss": "^8.5.13" } }, "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-minify-params": { - "version": "7.0.8", - "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-7.0.8.tgz", - "integrity": "sha512-DIUKM5DZGTmxN7KFKT+rxt0FdPDmRrdK/k3n3+6Po+N/QYn06juwagHcfOVBG0CfCHwcnI612GAUCZc3eT+ZEg==", + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-7.0.9.tgz", + "integrity": "sha512-R8itbB8BhlpoYyBm1ou0dD+vJnQ3F6adQipR4UnkCHUwlo+S9WXJaDRg1RHjC8YVAtIdrQzSWvJl40HnGDTKjA==", "dev": true, "license": "MIT", "dependencies": { "browserslist": "^4.28.2", - "cssnano-utils": "^5.0.2", + "cssnano-utils": "^5.0.3", "postcss-value-parser": "^4.2.0" }, "engines": { "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.5.10" + "postcss": "^8.5.13" } }, "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-minify-selectors": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-7.1.0.tgz", - "integrity": "sha512-HYl/6I0aL+UvpA10t65BSa7h+tVjBgE6oRI5N/3ylX3vtwvlDL67G3FT3vYDPnTksxr0riiyJcT0tBtyRVoloA==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-7.1.2.tgz", + "integrity": "sha512-aQtrEWKwqafNlExcKHQvPGsXR2+vlUqqJtf5XsCQcgsSb5PL4wlujWBYDJuWsP4UnQX1YHDHU8qRlD+1PzTQ+Q==", "dev": true, "license": "MIT", "dependencies": { @@ -19327,26 +19289,26 @@ "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.5.10" + "postcss": "^8.5.13" } }, "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-normalize-charset": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-7.0.2.tgz", - "integrity": "sha512-YoINoiR4YKlzfB95Y93b0DSxWy7FLw+1SADIaznMHb88AKizpzfF80tolmiDEbYr1UM4r4Hw+NZq37SwT5f3uw==", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-7.0.3.tgz", + "integrity": "sha512-NoBfZu8PR4c2NlmjvrqQTzCzLY79hwcSRgNQ3ZiNK0ABzf9kYKloE/jNj+/8GQY1wsm8pRRgANk6ydLH8cwo0Q==", "dev": true, "license": "MIT", "engines": { "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.5.10" + "postcss": "^8.5.13" } }, "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-normalize-display-values": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-7.0.2.tgz", - "integrity": "sha512-wu/NTSjnp8sX5TnEHVPN+eScjAtRs18ELtEduG+Ek3GxjeUDUT+VAA3PJjVIXBcVIk6fiLYFj2iKH0q99S3T2Q==", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-7.0.3.tgz", + "integrity": "sha512-ldsCX0QIt05pKIOobZtVQ48wXJecr+czw4+e1/YjVhLMqslShgpVxgPtI2CefURR8oyVoYaU/l829MMwExDMLw==", "dev": true, "license": "MIT", "dependencies": { @@ -19356,13 +19318,13 @@ "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.5.10" + "postcss": "^8.5.13" } }, "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-normalize-positions": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-7.0.3.tgz", - "integrity": "sha512-1CJI++oA3yK/fQlPUcEngUfcSWS08Pkt9fK+jVgL53mmtHDBHi0YiuB0m3D9BXwZjmfvCc2GQmFqCAF/CVcPzQ==", + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-7.0.4.tgz", + "integrity": "sha512-VEvlpeGd3Ju1Hqa/oN4jaP3+ms4laYwkEL9N9u+B6k54PZjXbW1n6wI+aVprf1BQXlCYpS5+1pl/7/vHiKgARg==", "dev": true, "license": "MIT", "dependencies": { @@ -19372,13 +19334,13 @@ "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.5.10" + "postcss": "^8.5.13" } }, "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-normalize-repeat-style": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-7.0.3.tgz", - "integrity": "sha512-RvImJ2Ml4LZSx31qC2C8LDiz65IgBNATtwEr9r3Aue+D0cCGbj4rjNojb/uGpEm4QxnOTzFqMvaDYuKiT1Cmpg==", + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-7.0.4.tgz", + "integrity": "sha512-6mPKlY/8cSaDHxX502wERADarJsccwlky6yIrOapHH2ZgfoKAV94SbiTKfKEs4EEpdazuc3J72WsqeYk7hp9+Q==", "dev": true, "license": "MIT", "dependencies": { @@ -19388,13 +19350,13 @@ "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.5.10" + "postcss": "^8.5.13" } }, "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-normalize-string": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-7.0.2.tgz", - "integrity": "sha512-FqtrUh2BU2MnVeLeWBbJ2rwOjuDnA91XvoImc1BbgMWIxdxiPTaquflBHsmFBA3xh3pC3wPZO9W5MaIc7wU/Xw==", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-7.0.3.tgz", + "integrity": "sha512-HnEQPUchi1eznmDKEYrKUTqrprEq97SrpUYClgUkv7V2zRODD9DFoUsYU+m9ZOetmD5ku7fEMZB/lwy8IT6xVQ==", "dev": true, "license": "MIT", "dependencies": { @@ -19404,13 +19366,13 @@ "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.5.10" + "postcss": "^8.5.13" } }, "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-normalize-timing-functions": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-7.0.2.tgz", - "integrity": "sha512-5H5fpXBnMACEXzn7k9RP7qWZ1eWg8cuZkUuFygStY7icOj+UucwMWXeMmdkF/iITvTVa7fP85tdRCJeznpdFfQ==", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-7.0.3.tgz", + "integrity": "sha512-zmEzHdvpZBZu0OKlbJSfgASQvaayyAoVuWtvyr34IJ/LyS+DaOKvvR3EvFJ9RWWtNIx+CMvO125OVophaxNYew==", "dev": true, "license": "MIT", "dependencies": { @@ -19420,13 +19382,13 @@ "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.5.10" + "postcss": "^8.5.13" } }, "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-normalize-unicode": { - "version": "7.0.8", - "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-7.0.8.tgz", - "integrity": "sha512-imCM3cwK3hvlAG4z1AzYM24m8BPA3/Jk/S71wfbn2I6+E2b+UwFaGvlNqydihXTSl3OFPeQXztqCzg+NGeSibQ==", + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-7.0.9.tgz", + "integrity": "sha512-DRAdWfeh/TjmhLJsw91vdiWCnUod9iwvM7xyS02/nF/sLsCR3A8l3pztrSUrWG8DSBqfX7yEk9FM0USaVJ2mSg==", "dev": true, "license": "MIT", "dependencies": { @@ -19437,13 +19399,13 @@ "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.5.10" + "postcss": "^8.5.13" } }, "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-normalize-url": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-7.0.2.tgz", - "integrity": "sha512-bLnNY7t76NLRb9QQyCVmCN4qwoHxiq6vABH/CXav9wTuR6dNGHGQ72AyO/+h2quWxZk3l7BqxNL1vtDi9H6y1g==", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-7.0.3.tgz", + "integrity": "sha512-CL93wmloq5qsffmFv+bw24MIRbmhHrp53qoh1LDAb/5TtjWEXI/np4xcP/Gw9oWCb2XyWnqHYLDUwiKRoJBA1Q==", "dev": true, "license": "MIT", "dependencies": { @@ -19453,13 +19415,13 @@ "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.5.10" + "postcss": "^8.5.13" } }, "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-normalize-whitespace": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-7.0.2.tgz", - "integrity": "sha512-TNSVkuhkeOhl36WruQlflxOb7HweoeZowSusNpfsM1+ZvqJ24Mc+xksu05ecMQxlu+0zgI8pyznO2EWqDCQbLA==", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-7.0.3.tgz", + "integrity": "sha512-FdHjjn+Ht5Z2ZRjNOmeCbNq6lq09sUYKpmlF/Aq0XjVNSLTL6fmHlA/3swN2wP2caY9GV/tjSDcIIyS7aN7W0A==", "dev": true, "license": "MIT", "dependencies": { @@ -19469,30 +19431,30 @@ "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.5.10" + "postcss": "^8.5.13" } }, "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-ordered-values": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-7.0.3.tgz", - "integrity": "sha512-FTt6R9RF7NAYfpOHa2XFPm89FVuo5GiIbcfwOXFy1MYF38BeiNW9ke8ybw9Pk62eEsUlRVVbxHWA3B7ERYqOOA==", + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-7.0.4.tgz", + "integrity": "sha512-nubSi49hDHQk4E8KIj+IbLY8Bg+8OcSUEhgyolgM+atnOvXjV7EjaR6bac4YGZoFyPa9mWoAF3EaYbWdFkKqVg==", "dev": true, "license": "MIT", "dependencies": { - "cssnano-utils": "^5.0.2", + "cssnano-utils": "^5.0.3", "postcss-value-parser": "^4.2.0" }, "engines": { "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.5.10" + "postcss": "^8.5.13" } }, "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-reduce-initial": { - "version": "7.0.8", - "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-7.0.8.tgz", - "integrity": "sha512-VeVRmbgpgTZuRcDQdqnsB4iYTeS2dBRV07UdwK6V3x61F1xTQ2pgIzHBIR4rQYRlXRNKBTGYYhEL1eNA7w9vaQ==", + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-7.0.9.tgz", + "integrity": "sha512-ztTNPdIxXTxtBcG03E9u8v44M4ElXbMIRT7pf2onlquGula0Y83nKKxqM22FA/hMgkfCjN7ohevkVlaNwI8iOQ==", "dev": true, "license": "MIT", "dependencies": { @@ -19503,13 +19465,13 @@ "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.5.10" + "postcss": "^8.5.13" } }, "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-reduce-transforms": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-7.0.2.tgz", - "integrity": "sha512-OV5P9hMnf7kEkeXVXyS5ESqxbIls7a3TqFymUAV5JICO/9YCBEU+QQhQjZiDHaLwFdV7/CL481kVeBUk5FdY3w==", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-7.0.3.tgz", + "integrity": "sha512-FXsnN9ZwcZTT8Yf8cAHA8qIGUXcX6WfLd9JoYhrdDfmvsVhhfqkkv7m4AC3rwFOfz+GzkUa87OCKF9dUcicd+g==", "dev": true, "license": "MIT", "dependencies": { @@ -19519,13 +19481,13 @@ "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.5.10" + "postcss": "^8.5.13" } }, "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-svgo": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-7.1.2.tgz", - "integrity": "sha512-ixExc8m+/68yuSYQzV/1DgtTup/7nI2dN9eiDS5GMRUzeCH4q9UcqeZPwcSVhdf8ay9fRwXDUHwcY5/XzQSszQ==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-7.1.3.tgz", + "integrity": "sha512-2QfoFOYMcj8lwcVEf9WeTlkVIAm7u2QvOEhMzkQU3KUhhGX/l8hVV9EtjMv4iq3E9iI3OeeMN0YoMLbGusuigw==", "dev": true, "license": "MIT", "dependencies": { @@ -19536,13 +19498,13 @@ "node": "^18.12.0 || ^20.9.0 || >= 18" }, "peerDependencies": { - "postcss": "^8.5.10" + "postcss": "^8.5.13" } }, "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-unique-selectors": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-7.0.6.tgz", - "integrity": "sha512-cDxnYw1QuBMW5w3svZ0BlYF0IA4Amr+1JoTLXzu6vDFPNwohN2QU+sPZNx15b930LR7ce+/600h28/cYoxO9vw==", + "version": "7.0.7", + "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-7.0.7.tgz", + "integrity": "sha512-d+sCkaRnSefghOUdH8CMJZV9yUQhj2ojpe8Nw/lA+LV1UOfeleGkLTl6XdCFFSai9UJ+DJPb69FFuqthXYsY8w==", "dev": true, "license": "MIT", "dependencies": { @@ -19552,13 +19514,13 @@ "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.5.10" + "postcss": "^8.5.13" } }, "node_modules/css-minimizer-webpack-plugin/node_modules/stylehacks": { - "version": "7.0.10", - "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-7.0.10.tgz", - "integrity": "sha512-sRJ7klmhe/Fl5woJcbJUa2qP1Ueffsl1CQI4ePvqXLkZmcIuAt09aP9uT/FOFPqXh9Rh8M5UkgEnwTdTKn/Aag==", + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-7.0.11.tgz", + "integrity": "sha512-iODNfhXVLqc5LADs+Y6Oh5wJuK5ZcHbVng8aiK3y9pjMQdc5hLrBW0eFU6FtnpNrE6PoEg/MmFTU4waotj5WNg==", "dev": true, "license": "MIT", "dependencies": { @@ -19569,7 +19531,7 @@ "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "peerDependencies": { - "postcss": "^8.5.10" + "postcss": "^8.5.13" } }, "node_modules/css-select": { @@ -20819,21 +20781,20 @@ "license": "MIT" }, "node_modules/detect-port": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/detect-port/-/detect-port-1.6.1.tgz", - "integrity": "sha512-CmnVc+Hek2egPx1PeTFVta2W78xy2K/9Rkf6cC4T59S50tVnzKj+tnx5mmx5lwvCkujZ4uRrpRSuV+IVs3f90Q==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-port/-/detect-port-2.1.0.tgz", + "integrity": "sha512-epZuWb/6Q62L+nDHJc/hQAqf8pylsqgk3BpZXVBx1CDnr3nkrVNn73Uu1rXcFzkNcc+hkP3whuOg7JZYaQB65Q==", "dev": true, "license": "MIT", "dependencies": { - "address": "^1.0.1", - "debug": "4" + "address": "^2.0.1" }, "bin": { - "detect": "bin/detect-port.js", - "detect-port": "bin/detect-port.js" + "detect": "dist/commonjs/bin/detect-port.js", + "detect-port": "dist/commonjs/bin/detect-port.js" }, "engines": { - "node": ">= 4.0.0" + "node": ">= 16.0.0" } }, "node_modules/diff": { @@ -22778,9 +22739,9 @@ "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -30900,9 +30861,9 @@ "license": "MIT" }, "node_modules/nx": { - "version": "22.7.1", - "resolved": "https://registry.npmjs.org/nx/-/nx-22.7.1.tgz", - "integrity": "sha512-SadJUQY57MiwRIetm9rhZhdpFeOe1Csib2Vg9C423Pw/h0fZE14qUo6+OBby9vLh5QCkRfRZ0WaHkeO5q6yNtA==", + "version": "22.7.2", + "resolved": "https://registry.npmjs.org/nx/-/nx-22.7.2.tgz", + "integrity": "sha512-Gh7gGO1t/TvgbKuVJMYWbxUwZC+E+PuRRVUeoOeVe82yEvBNl40EKiVHIbbi6GID0s9Zwzflo07UrKGLoDSVGw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -30920,11 +30881,11 @@ "ansi-styles": "4.3.0", "argparse": "2.0.1", "asynckit": "0.4.0", - "axios": "1.15.0", + "axios": "1.16.0", "balanced-match": "4.0.3", "base64-js": "1.5.1", "bl": "4.1.0", - "brace-expansion": "5.0.2", + "brace-expansion": "5.0.5", "buffer": "5.7.1", "call-bind-apply-helpers": "1.0.2", "chalk": "4.1.2", @@ -30953,7 +30914,7 @@ "escape-string-regexp": "1.0.5", "figures": "3.2.0", "flat": "5.0.2", - "follow-redirects": "1.15.11", + "follow-redirects": "1.16.0", "form-data": "4.0.5", "fs-constants": "1.0.0", "function-bind": "1.1.2", @@ -30981,7 +30942,7 @@ "mime-db": "1.52.0", "mime-types": "2.1.35", "mimic-fn": "2.1.0", - "minimatch": "10.2.4", + "minimatch": "10.2.5", "minimist": "1.2.8", "npm-run-path": "4.0.1", "once": "1.4.0", @@ -31023,16 +30984,16 @@ "nx-cloud": "dist/bin/nx-cloud.js" }, "optionalDependencies": { - "@nx/nx-darwin-arm64": "22.7.1", - "@nx/nx-darwin-x64": "22.7.1", - "@nx/nx-freebsd-x64": "22.7.1", - "@nx/nx-linux-arm-gnueabihf": "22.7.1", - "@nx/nx-linux-arm64-gnu": "22.7.1", - "@nx/nx-linux-arm64-musl": "22.7.1", - "@nx/nx-linux-x64-gnu": "22.7.1", - "@nx/nx-linux-x64-musl": "22.7.1", - "@nx/nx-win32-arm64-msvc": "22.7.1", - "@nx/nx-win32-x64-msvc": "22.7.1" + "@nx/nx-darwin-arm64": "22.7.2", + "@nx/nx-darwin-x64": "22.7.2", + "@nx/nx-freebsd-x64": "22.7.2", + "@nx/nx-linux-arm-gnueabihf": "22.7.2", + "@nx/nx-linux-arm64-gnu": "22.7.2", + "@nx/nx-linux-arm64-musl": "22.7.2", + "@nx/nx-linux-x64-gnu": "22.7.2", + "@nx/nx-linux-x64-musl": "22.7.2", + "@nx/nx-win32-arm64-msvc": "22.7.2", + "@nx/nx-win32-x64-msvc": "22.7.2" }, "peerDependencies": { "@swc-node/register": "^1.11.1", @@ -31109,16 +31070,16 @@ } }, "node_modules/nx/node_modules/brace-expansion": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", - "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" } }, "node_modules/nx/node_modules/cli-cursor": { @@ -31307,13 +31268,13 @@ } }, "node_modules/nx/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" diff --git a/package.json b/package.json index dbbe0c3af..91ceb3894 100644 --- a/package.json +++ b/package.json @@ -157,16 +157,16 @@ "@eslint/js": "9.35.0", "@nestjs/schematics": "11.1.0", "@nestjs/testing": "11.1.19", - "@nx/angular": "22.7.1", - "@nx/eslint-plugin": "22.7.1", - "@nx/jest": "22.7.1", - "@nx/js": "22.7.1", - "@nx/module-federation": "22.7.1", - "@nx/nest": "22.7.1", - "@nx/node": "22.7.1", - "@nx/storybook": "22.7.1", - "@nx/web": "22.7.1", - "@nx/workspace": "22.7.1", + "@nx/angular": "22.7.2", + "@nx/eslint-plugin": "22.7.2", + "@nx/jest": "22.7.2", + "@nx/js": "22.7.2", + "@nx/module-federation": "22.7.2", + "@nx/nest": "22.7.2", + "@nx/node": "22.7.2", + "@nx/storybook": "22.7.2", + "@nx/web": "22.7.2", + "@nx/workspace": "22.7.2", "@schematics/angular": "21.2.6", "@storybook/addon-docs": "10.1.10", "@storybook/addon-themes": "10.1.10", @@ -193,7 +193,7 @@ "jest": "30.2.0", "jest-environment-jsdom": "30.2.0", "jest-preset-angular": "16.0.0", - "nx": "22.7.1", + "nx": "22.7.2", "prettier": "3.8.3", "prettier-plugin-organize-attributes": "1.0.0", "prisma": "7.8.0", From fde23ec9dc0e2ad743462b9f7a18be59116da7de Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Mon, 18 May 2026 21:53:44 +0300 Subject: [PATCH 086/132] Task/improve pagination for activities in account detail dialog (#6887) * Improve pagination in activities table * Update changelog --- CHANGELOG.md | 1 + .../account-detail-dialog.component.ts | 36 +++++++++++++------ .../account-detail-dialog.html | 3 ++ 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 447469ea7..14827175b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Improved the pagination in the activities table of the account detail dialog - Improved the pagination in the activities table of the holding detail dialog - Randomized the placeholder in the assistant - Enabled the _Bull Dashboard_ in the admin control panel without requiring an environment variable (experimental) diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts index 05f13f804..a429d9e64 100644 --- a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts @@ -1,6 +1,9 @@ import { GfInvestmentChartComponent } from '@ghostfolio/client/components/investment-chart/investment-chart.component'; import { UserService } from '@ghostfolio/client/services/user/user.service'; -import { NUMERICAL_PRECISION_THRESHOLD_6_FIGURES } from '@ghostfolio/common/config'; +import { + DEFAULT_PAGE_SIZE, + NUMERICAL_PRECISION_THRESHOLD_6_FIGURES +} from '@ghostfolio/common/config'; import { CreateAccountBalanceDto } from '@ghostfolio/common/dtos'; import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper'; import { @@ -33,6 +36,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatButtonModule } from '@angular/material/button'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog'; +import { PageEvent } from '@angular/material/paginator'; import { Sort, SortDirection } from '@angular/material/sort'; import { MatTableDataSource } from '@angular/material/table'; import { MatTabsModule } from '@angular/material/tabs'; @@ -93,6 +97,8 @@ export class GfAccountDetailDialogComponent implements OnInit { protected isLoadingActivities: boolean; protected isLoadingChart: boolean; protected name: string | null; + protected pageIndex = 0; + protected pageSize = DEFAULT_PAGE_SIZE; protected platformName: string; protected sortColumn = 'date'; protected sortDirection: SortDirection = 'desc'; @@ -133,6 +139,21 @@ export class GfAccountDetailDialogComponent implements OnInit { this.initialize(); } + protected onAddAccountBalance(accountBalance: CreateAccountBalanceDto) { + this.dataService + .postAccountBalance(accountBalance) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.initialize(); + }); + } + + protected onChangePage(page: PageEvent) { + this.pageIndex = page.pageIndex; + + this.fetchActivities(); + } + protected onCloneActivity(aActivity: Activity) { this.router.navigate( internalRoutes.portfolio.subRoutes.activities.routerLink, @@ -148,15 +169,6 @@ export class GfAccountDetailDialogComponent implements OnInit { this.dialogRef.close(); } - protected onAddAccountBalance(accountBalance: CreateAccountBalanceDto) { - this.dataService - .postAccountBalance(accountBalance) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => { - this.initialize(); - }); - } - protected onDeleteAccountBalance(aId: string) { this.dataService .deleteAccountBalance(aId) @@ -287,8 +299,10 @@ export class GfAccountDetailDialogComponent implements OnInit { this.dataService .fetchActivities({ filters: [{ id: this.data.accountId, type: 'ACCOUNT' }], + skip: this.pageIndex * this.pageSize, sortColumn: this.sortColumn, - sortDirection: this.sortDirection + sortDirection: this.sortDirection, + take: this.pageSize }) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ activities, count }) => { diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html index 73412d49d..cd397e35e 100644 --- a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html @@ -120,6 +120,8 @@ [hasPermissionToFilter]="false" [hasPermissionToOpenDetails]="false" [locale]="user?.settings?.locale" + [pageIndex]="pageIndex" + [pageSize]="pageSize" [showAccountColumn]="false" [showActions]=" !data.hasImpersonationId && @@ -133,6 +135,7 @@ (activityToClone)="onCloneActivity($event)" (activityToUpdate)="onUpdateActivity($event)" (export)="onExport()" + (pageChanged)="onChangePage($event)" (sortChanged)="onSortChanged($event)" /> From 4b5eae63156c2e8fde284c98c8560153d03dbcc8 Mon Sep 17 00:00:00 2001 From: Jorge Date: Tue, 19 May 2026 19:14:40 +0200 Subject: [PATCH 087/132] Task/improve language localization for ES (#6905) * Update translations * Update changelog --- CHANGELOG.md | 1 + apps/client/src/locales/messages.es.xlf | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14827175b..45137937d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Enabled the _Bull Dashboard_ in the admin control panel without requiring an environment variable (experimental) - Extracted the page tabs to a reusable component - Improved the language localization for German (`de`) +- Improved the language localization for Spanish (`es`) - Upgraded `bull-board` from version `7.0.0` to `7.1.5` - Upgraded `Nx` from version `22.7.1` to `22.7.2` diff --git a/apps/client/src/locales/messages.es.xlf b/apps/client/src/locales/messages.es.xlf index 4e1cd04a3..c99924de8 100644 --- a/apps/client/src/locales/messages.es.xlf +++ b/apps/client/src/locales/messages.es.xlf @@ -440,7 +440,7 @@ Find an account... - Find an account... + Buscar una cuenta... libs/ui/src/lib/assistant/assistant.component.ts 471 @@ -6094,7 +6094,7 @@ Find a holding... - Find a holding... + Buscar una posición... libs/ui/src/lib/assistant/assistant.component.ts 472 @@ -6275,7 +6275,7 @@ Jump to a page... - Jump to a page... + Saltar a una página... libs/ui/src/lib/assistant/assistant.component.ts 473 From 314dbbffd533a6101115fcc207c315d7e8ae1d70 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Tue, 19 May 2026 21:00:32 +0300 Subject: [PATCH 088/132] Task/extract page tabs to dedicated component (part 3) (#6908) Improve styling --- apps/client/src/styles.scss | 1 + libs/ui/src/lib/page-tabs/page-tabs.component.scss | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/client/src/styles.scss b/apps/client/src/styles.scss index 83fd69c91..a863603b0 100644 --- a/apps/client/src/styles.scss +++ b/apps/client/src/styles.scss @@ -479,6 +479,7 @@ ngx-skeleton-loader { .page { display: flex; + height: calc(100svh - var(--mat-toolbar-standard-height)); overflow-y: auto; padding-bottom: env(safe-area-inset-bottom); padding-bottom: constant(safe-area-inset-bottom); diff --git a/libs/ui/src/lib/page-tabs/page-tabs.component.scss b/libs/ui/src/lib/page-tabs/page-tabs.component.scss index 182d2eca1..920b00ae9 100644 --- a/libs/ui/src/lib/page-tabs/page-tabs.component.scss +++ b/libs/ui/src/lib/page-tabs/page-tabs.component.scss @@ -3,7 +3,7 @@ :host { display: flex; flex-direction: column; - height: calc(100svh - var(--mat-toolbar-standard-height)); + height: 100%; width: 100%; @include mat.tabs-overrides( @@ -23,6 +23,10 @@ .mat-mdc-tab-nav-panel { padding: 2rem 0; + + @media (max-width: 575.98px) { + padding: 1rem 0; + } } } From 787e6f6414f82e29b9fe2d1808fc520ebe4e569d Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Tue, 19 May 2026 21:14:23 +0300 Subject: [PATCH 089/132] Task/improve spacing in various tabs (#6909) * Improve spacing --- .../client/src/app/components/home-summary/home-summary.html | 2 +- .../src/app/pages/about/oss-friends/oss-friends-page.html | 4 ++-- .../src/app/pages/portfolio/x-ray/x-ray-page.component.html | 2 +- .../src/app/pages/portfolio/x-ray/x-ray-page.component.ts | 5 ++++- .../resources/glossary/resources-glossary.component.html | 2 +- .../pages/resources/guides/resources-guides.component.html | 2 +- .../pages/resources/markets/resources-markets.component.html | 4 ++-- .../resources/overview/resources-overview.component.html | 2 +- .../personal-finance-tools/personal-finance-tools-page.html | 4 ++-- 9 files changed, 15 insertions(+), 12 deletions(-) diff --git a/apps/client/src/app/components/home-summary/home-summary.html b/apps/client/src/app/components/home-summary/home-summary.html index 064923a04..2ed988e8a 100644 --- a/apps/client/src/app/components/home-summary/home-summary.html +++ b/apps/client/src/app/components/home-summary/home-summary.html @@ -1,4 +1,4 @@ -
    +

    Summary

    diff --git a/apps/client/src/app/pages/about/oss-friends/oss-friends-page.html b/apps/client/src/app/pages/about/oss-friends/oss-friends-page.html index f1431697f..48985eaa3 100644 --- a/apps/client/src/app/pages/about/oss-friends/oss-friends-page.html +++ b/apps/client/src/app/pages/about/oss-friends/oss-friends-page.html @@ -1,5 +1,5 @@
    -
    +

    @for (ossFriend of ossFriends; track ossFriend) { -
    + @for (category of categories; track category.key) { -
    +

    {{ category.name }} @if (user?.subscription?.type === 'Basic') { diff --git a/apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.ts b/apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.ts index 0d37bab9b..c5c4fc979 100644 --- a/apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.ts +++ b/apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.ts @@ -107,7 +107,10 @@ export class GfXRayPageComponent { .fetchPortfolioReport() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ xRay: { categories, statistics } }) => { - this.categories = categories; + this.categories = categories.filter(({ rules }) => { + return rules?.length > 0; + }); + this.inactiveRules = this.mergeInactiveRules(categories); this.statistics = statistics; diff --git a/apps/client/src/app/pages/resources/glossary/resources-glossary.component.html b/apps/client/src/app/pages/resources/glossary/resources-glossary.component.html index fa74cd084..5f4769721 100644 --- a/apps/client/src/app/pages/resources/glossary/resources-glossary.component.html +++ b/apps/client/src/app/pages/resources/glossary/resources-glossary.component.html @@ -132,7 +132,7 @@

    -
    +

    Stealth Wealth

    diff --git a/apps/client/src/app/pages/resources/guides/resources-guides.component.html b/apps/client/src/app/pages/resources/guides/resources-guides.component.html index 54b3d1f3e..24a7532b9 100644 --- a/apps/client/src/app/pages/resources/guides/resources-guides.component.html +++ b/apps/client/src/app/pages/resources/guides/resources-guides.component.html @@ -19,7 +19,7 @@
    -
    +

    How do I get my finances in order?

    diff --git a/apps/client/src/app/pages/resources/markets/resources-markets.component.html b/apps/client/src/app/pages/resources/markets/resources-markets.component.html index ce780aedf..7437da1b1 100644 --- a/apps/client/src/app/pages/resources/markets/resources-markets.component.html +++ b/apps/client/src/app/pages/resources/markets/resources-markets.component.html @@ -30,8 +30,8 @@
    -
    -
    +
    +

    Inflation Chart

    Inflation Chart helps you find the intrinsic value of stock diff --git a/apps/client/src/app/pages/resources/overview/resources-overview.component.html b/apps/client/src/app/pages/resources/overview/resources-overview.component.html index 86a334c79..8d1665373 100644 --- a/apps/client/src/app/pages/resources/overview/resources-overview.component.html +++ b/apps/client/src/app/pages/resources/overview/resources-overview.component.html @@ -4,7 +4,7 @@

    Resources

    @for (item of overviewItems; track item) { -
    +

    {{ item.title }}

    {{ item.description }}

    -
    +

    Discover Open Source Alternatives for Personal Finance Tools @@ -22,7 +22,7 @@ personalFinanceTool of personalFinanceTools; track personalFinanceTool ) { - +
    From 1b9ae69fee6e3938f1b910db12e3d6c464e6ad24 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 21 May 2026 08:57:46 +0300 Subject: [PATCH 090/132] Task/relax URL validation in asset profile and platform DTOs (#6910) * Relax URL validation * Update changelog --- CHANGELOG.md | 2 ++ libs/common/src/lib/dtos/create-asset-profile.dto.ts | 2 +- libs/common/src/lib/dtos/create-platform.dto.ts | 2 +- libs/common/src/lib/dtos/update-asset-profile.dto.ts | 2 +- libs/common/src/lib/dtos/update-platform.dto.ts | 2 +- 5 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45137937d..843f33f05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improved the pagination in the activities table of the holding detail dialog - Randomized the placeholder in the assistant - Enabled the _Bull Dashboard_ in the admin control panel without requiring an environment variable (experimental) +- Relaxed the URL validation in the asset profile DTOs to accept both `HTTP` and `HTTPS` protocols +- Relaxed the URL validation in the platform DTOs to accept both `HTTP` and `HTTPS` protocols - Extracted the page tabs to a reusable component - Improved the language localization for German (`de`) - Improved the language localization for Spanish (`es`) diff --git a/libs/common/src/lib/dtos/create-asset-profile.dto.ts b/libs/common/src/lib/dtos/create-asset-profile.dto.ts index 85ad73cc0..1bd3ba9a8 100644 --- a/libs/common/src/lib/dtos/create-asset-profile.dto.ts +++ b/libs/common/src/lib/dtos/create-asset-profile.dto.ts @@ -74,7 +74,7 @@ export class CreateAssetProfileDto { @IsOptional() @IsUrl({ - protocols: ['https'], + protocols: ['http', 'https'], require_protocol: true }) url?: string; diff --git a/libs/common/src/lib/dtos/create-platform.dto.ts b/libs/common/src/lib/dtos/create-platform.dto.ts index 941354c11..e482dd241 100644 --- a/libs/common/src/lib/dtos/create-platform.dto.ts +++ b/libs/common/src/lib/dtos/create-platform.dto.ts @@ -5,7 +5,7 @@ export class CreatePlatformDto { name: string; @IsUrl({ - protocols: ['https'], + protocols: ['http', 'https'], require_protocol: true }) url: string; diff --git a/libs/common/src/lib/dtos/update-asset-profile.dto.ts b/libs/common/src/lib/dtos/update-asset-profile.dto.ts index 43f5aa617..a4981493e 100644 --- a/libs/common/src/lib/dtos/update-asset-profile.dto.ts +++ b/libs/common/src/lib/dtos/update-asset-profile.dto.ts @@ -64,7 +64,7 @@ export class UpdateAssetProfileDto { @IsOptional() @IsUrl({ - protocols: ['https'], + protocols: ['http', 'https'], require_protocol: true }) url?: string; diff --git a/libs/common/src/lib/dtos/update-platform.dto.ts b/libs/common/src/lib/dtos/update-platform.dto.ts index 4c4f907af..03ae94b21 100644 --- a/libs/common/src/lib/dtos/update-platform.dto.ts +++ b/libs/common/src/lib/dtos/update-platform.dto.ts @@ -8,7 +8,7 @@ export class UpdatePlatformDto { name: string; @IsUrl({ - protocols: ['https'], + protocols: ['http', 'https'], require_protocol: true }) url: string; From cde519fa7ba8cf548c134c25c2595b9acda37b0a Mon Sep 17 00:00:00 2001 From: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com> Date: Thu, 21 May 2026 23:49:23 +0700 Subject: [PATCH 091/132] Task/improve type safety of header component (#6913) * feat(common): allow null for filters * feat(client): implement login with access token dialog result interface * feat(client): enforce encapsulation * feat(client): enforce immutability * feat(client): replace constructor based DI with inject function * feat(client): implement view child signals * feat(client): implement output signals * feat(client): implement input signals * fix(client): format html file --- .../components/header/header.component.html | 121 +++++----- .../app/components/header/header.component.ts | 211 +++++++++--------- .../interfaces/interfaces.ts | 4 + ...ogin-with-access-token-dialog.component.ts | 10 +- .../src/lib/dtos/update-user-setting.dto.ts | 10 +- 5 files changed, 190 insertions(+), 166 deletions(-) diff --git a/apps/client/src/app/components/header/header.component.html b/apps/client/src/app/components/header/header.component.html index 283e930e0..ae3121861 100644 --- a/apps/client/src/app/components/header/header.component.html +++ b/apps/client/src/app/components/header/header.component.html @@ -1,14 +1,14 @@ - @if (user) { -
    + @if (user()) { + @@ -20,11 +20,11 @@ mat-button [class]="{ 'font-weight-bold': - currentRoute === internalRoutes.home.path || - currentRoute === internalRoutes.zen.path, + currentRoute() === internalRoutes.home.path || + currentRoute() === internalRoutes.zen.path, 'text-decoration-underline': - currentRoute === internalRoutes.home.path || - currentRoute === internalRoutes.zen.path + currentRoute() === internalRoutes.home.path || + currentRoute() === internalRoutes.zen.path }" [routerLink]="['/']" >OverviewPortfolioAccountsAdmin ControlResources @if ( - hasPermissionForSubscription && user?.subscription?.type === 'Basic' + hasPermissionForSubscription && user()?.subscription?.type === 'Basic' ) {
  • Pricing - @if (currentRoute !== routePricing && hasPromotion) { + @if (currentRoute() !== routePricing && hasPromotion()) { % } @@ -116,8 +117,8 @@ i18n mat-button [class]="{ - 'font-weight-bold': currentRoute === routeAbout, - 'text-decoration-underline': currentRoute === routeAbout + 'font-weight-bold': currentRoute() === routeAbout, + 'text-decoration-underline': currentRoute() === routeAbout }" [routerLink]="routerLinkAbout" >About @if ( - hasPermissionForSubscription && user?.subscription?.type === 'Basic' + hasPermissionForSubscription && + user()?.subscription?.type === 'Basic' ) { - @if (user.subscription.offer.isRenewal) { + @if (user().subscription.offer.isRenewal) { Renew Plan } @else { Upgrade Plan @@ -199,7 +203,7 @@ >
    } - @if (user?.access?.length > 0) { + @if (user()?.access?.length > 0) { - @for (accessItem of user?.access; track accessItem) { + @for (accessItem of user()?.access; track accessItem) {
  • } - @if (user === null) { -
    + @if (user() === null) { + @@ -349,8 +354,8 @@ i18n mat-button [class]="{ - 'font-weight-bold': currentRoute === routeFeatures, - 'text-decoration-underline': currentRoute === routeFeatures + 'font-weight-bold': currentRoute() === routeFeatures, + 'text-decoration-underline': currentRoute() === routeFeatures }" [routerLink]="routerLinkFeatures" >FeaturesAbout Pricing - @if (currentRoute !== routePricing && hasPromotion) { + @if (currentRoute() !== routePricing && hasPromotion()) { % } @@ -396,8 +401,8 @@ i18n mat-button [class]="{ - 'font-weight-bold': currentRoute === routeMarkets, - 'text-decoration-underline': currentRoute === routeMarkets + 'font-weight-bold': currentRoute() === routeMarkets, + 'text-decoration-underline': currentRoute() === routeMarkets }" [routerLink]="routerLinkMarkets" >MarketsSign in - @if (currentRoute !== 'register' && hasPermissionToCreateUser) { + @if (currentRoute() !== 'register' && hasPermissionToCreateUser) {
  • (); - - @ViewChild('assistant') assistantElement: GfAssistantComponent; - @ViewChild('assistantTrigger') assistentMenuTriggerElement: MatMenuTrigger; - - public hasFilters: boolean; - public hasImpersonationId: boolean; - public hasPermissionForAuthGoogle: boolean; - public hasPermissionForAuthOidc: boolean; - public hasPermissionForAuthToken: boolean; - public hasPermissionForSubscription: boolean; - public hasPermissionToAccessAdminControl: boolean; - public hasPermissionToAccessAssistant: boolean; - public hasPermissionToAccessFearAndGreedIndex: boolean; - public hasPermissionToCreateUser: boolean; - public impersonationId: string; - public internalRoutes = internalRoutes; - public isMenuOpen: boolean; - public routeAbout = publicRoutes.about.path; - public routeFeatures = publicRoutes.features.path; - public routeMarkets = publicRoutes.markets.path; - public routePricing = publicRoutes.pricing.path; - public routeResources = publicRoutes.resources.path; - public routerLinkAbout = publicRoutes.about.routerLink; - public routerLinkAccount = internalRoutes.account.routerLink; - public routerLinkAccounts = internalRoutes.accounts.routerLink; - public routerLinkAdminControl = internalRoutes.adminControl.routerLink; - public routerLinkFeatures = publicRoutes.features.routerLink; - public routerLinkMarkets = publicRoutes.markets.routerLink; - public routerLinkPortfolio = internalRoutes.portfolio.routerLink; - public routerLinkPricing = publicRoutes.pricing.routerLink; - public routerLinkRegister = publicRoutes.register.routerLink; - public routerLinkResources = publicRoutes.resources.routerLink; - - public constructor( - private dataService: DataService, - private destroyRef: DestroyRef, - private dialog: MatDialog, - private impersonationStorageService: ImpersonationStorageService, - private layoutService: LayoutService, - private notificationService: NotificationService, - private router: Router, - private settingsStorageService: SettingsStorageService, - private tokenStorageService: TokenStorageService, - private userService: UserService - ) { + public readonly currentRoute = input.required(); + public readonly deviceType = input.required(); + public readonly hasPermissionToChangeDateRange = input.required(); + public readonly hasPermissionToChangeFilters = input.required(); + public readonly hasPromotion = input.required(); + public readonly hasTabs = input.required(); + public readonly info = input.required(); + public readonly pageTitle = input.required(); + public readonly user = input.required(); + + public readonly signOut = output(); + + protected readonly assistantElement = + viewChild.required('assistant'); + protected readonly assistentMenuTriggerElement = + viewChild.required('assistantTrigger'); + + protected hasFilters: boolean; + protected hasImpersonationId: boolean; + protected hasPermissionForAuthGoogle: boolean; + protected hasPermissionForAuthOidc: boolean; + protected hasPermissionForAuthToken: boolean; + protected hasPermissionForSubscription: boolean; + protected hasPermissionToAccessAdminControl: boolean; + protected hasPermissionToAccessAssistant: boolean; + protected hasPermissionToAccessFearAndGreedIndex: boolean; + protected hasPermissionToCreateUser: boolean; + protected impersonationId: string; + protected readonly internalRoutes = internalRoutes; + protected isMenuOpen: boolean; + protected readonly routeAbout = publicRoutes.about.path; + protected readonly routeFeatures = publicRoutes.features.path; + protected readonly routeMarkets = publicRoutes.markets.path; + protected readonly routePricing = publicRoutes.pricing.path; + protected readonly routeResources = publicRoutes.resources.path; + protected readonly routerLinkAbout = publicRoutes.about.routerLink; + protected readonly routerLinkAccount = internalRoutes.account.routerLink; + protected readonly routerLinkAccounts = internalRoutes.accounts.routerLink; + protected readonly routerLinkAdminControl = + internalRoutes.adminControl.routerLink; + protected readonly routerLinkFeatures = publicRoutes.features.routerLink; + protected readonly routerLinkMarkets = publicRoutes.markets.routerLink; + protected readonly routerLinkPortfolio = internalRoutes.portfolio.routerLink; + protected readonly routerLinkPricing = publicRoutes.pricing.routerLink; + protected readonly routerLinkRegister = publicRoutes.register.routerLink; + protected readonly routerLinkResources = publicRoutes.resources.routerLink; + + private readonly dataService = inject(DataService); + private readonly destroyRef = inject(DestroyRef); + private readonly dialog = inject(MatDialog); + private readonly impersonationStorageService = inject( + ImpersonationStorageService + ); + private readonly layoutService = inject(LayoutService); + private readonly notificationService = inject(NotificationService); + private readonly router = inject(Router); + private readonly settingsStorageService = inject(SettingsStorageService); + private readonly tokenStorageService = inject(TokenStorageService); + private readonly userService = inject(UserService); + + public constructor() { this.impersonationStorageService .onChangeHasImpersonation() .pipe(takeUntilDestroyed(this.destroyRef)) @@ -162,55 +154,71 @@ export class GfHeaderComponent implements OnChanges { }); } + @HostListener('window:keydown', ['$event']) + protected openAssistantWithHotKey(event: KeyboardEvent) { + if ( + event.key === '/' && + event.target instanceof Element && + event.target?.nodeName?.toLowerCase() !== 'input' && + event.target?.nodeName?.toLowerCase() !== 'textarea' && + this.hasPermissionToAccessAssistant + ) { + this.assistantElement().setIsOpen(true); + this.assistentMenuTriggerElement().openMenu(); + + event.preventDefault(); + } + } + public ngOnChanges() { this.hasFilters = this.userService.hasFilters(); this.hasPermissionForAuthGoogle = hasPermission( - this.info?.globalPermissions, + this.info()?.globalPermissions, permissions.enableAuthGoogle ); this.hasPermissionForAuthOidc = hasPermission( - this.info?.globalPermissions, + this.info()?.globalPermissions, permissions.enableAuthOidc ); this.hasPermissionForAuthToken = hasPermission( - this.info?.globalPermissions, + this.info()?.globalPermissions, permissions.enableAuthToken ); this.hasPermissionForSubscription = hasPermission( - this.info?.globalPermissions, + this.info()?.globalPermissions, permissions.enableSubscription ); this.hasPermissionToAccessAdminControl = hasPermission( - this.user?.permissions, + this.user()?.permissions, permissions.accessAdminControl ); this.hasPermissionToAccessAssistant = hasPermission( - this.user?.permissions, + this.user()?.permissions, permissions.accessAssistant ); this.hasPermissionToAccessFearAndGreedIndex = hasPermission( - this.info?.globalPermissions, + this.info()?.globalPermissions, permissions.enableFearAndGreedIndex ); this.hasPermissionToCreateUser = hasPermission( - this.info?.globalPermissions, + this.info()?.globalPermissions, permissions.createUserAccount ); } - public closeAssistant() { - this.assistentMenuTriggerElement?.closeMenu(); + protected closeAssistant() { + this.assistentMenuTriggerElement().closeMenu(); } - public impersonateAccount(aId: string) { + protected impersonateAccount(aId: string) { if (aId) { this.impersonationStorageService.setId(aId); } else { @@ -220,7 +228,7 @@ export class GfHeaderComponent implements OnChanges { window.location.reload(); } - public onDateRangeChange(dateRange: DateRange) { + protected onDateRangeChange(dateRange: DateRange) { this.dataService .putUserSetting({ dateRange }) .pipe(takeUntilDestroyed(this.destroyRef)) @@ -232,7 +240,7 @@ export class GfHeaderComponent implements OnChanges { }); } - public onFiltersChanged(filters: Filter[]) { + protected onFiltersChanged(filters: Filter[]) { const userSetting: UpdateUserSettingDto = {}; for (const filter of filters) { @@ -260,32 +268,33 @@ export class GfHeaderComponent implements OnChanges { }); } - public onLogoClick() { - if (['home', 'zen'].includes(this.currentRoute)) { + protected onLogoClick() { + if (['home', 'zen'].includes(this.currentRoute())) { this.layoutService.getShouldReloadSubject().next(); } } - public onMenuClosed() { + protected onMenuClosed() { this.isMenuOpen = false; } - public onMenuOpened() { + protected onMenuOpened() { this.isMenuOpen = true; } - public onOpenAssistant() { - this.assistantElement.initialize(); + protected onOpenAssistant() { + this.assistantElement().initialize(); } - public onSignOut() { - this.signOut.next(); + protected onSignOut() { + this.signOut.emit(); } - public openLoginDialog() { + protected openLoginDialog() { const dialogRef = this.dialog.open< GfLoginWithAccessTokenDialogComponent, - LoginWithAccessTokenDialogParams + LoginWithAccessTokenDialogParams, + LoginWithAccessTokenDialogResult >(GfLoginWithAccessTokenDialogComponent, { autoFocus: false, data: { @@ -322,7 +331,7 @@ export class GfHeaderComponent implements OnChanges { }); } - public setToken(aToken: string) { + private setToken(aToken: string) { this.tokenStorageService.saveToken( aToken, this.settingsStorageService.getSetting(KEY_STAY_SIGNED_IN) === 'true' diff --git a/apps/client/src/app/components/login-with-access-token-dialog/interfaces/interfaces.ts b/apps/client/src/app/components/login-with-access-token-dialog/interfaces/interfaces.ts index e9222e142..b903fcfef 100644 --- a/apps/client/src/app/components/login-with-access-token-dialog/interfaces/interfaces.ts +++ b/apps/client/src/app/components/login-with-access-token-dialog/interfaces/interfaces.ts @@ -5,3 +5,7 @@ export interface LoginWithAccessTokenDialogParams { hasPermissionToUseAuthToken: boolean; title: string; } + +export interface LoginWithAccessTokenDialogResult { + accessToken: string | null; +} diff --git a/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.component.ts b/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.component.ts index d79c7a675..d6ce28c96 100644 --- a/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.component.ts +++ b/apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.component.ts @@ -22,7 +22,10 @@ import { IonIcon } from '@ionic/angular/standalone'; import { addIcons } from 'ionicons'; import { eyeOffOutline, eyeOutline } from 'ionicons/icons'; -import { LoginWithAccessTokenDialogParams } from './interfaces/interfaces'; +import { + LoginWithAccessTokenDialogParams, + LoginWithAccessTokenDialogResult +} from './interfaces/interfaces'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -49,7 +52,10 @@ export class GfLoginWithAccessTokenDialogComponent { public constructor( @Inject(MAT_DIALOG_DATA) public data: LoginWithAccessTokenDialogParams, - public dialogRef: MatDialogRef, + public dialogRef: MatDialogRef< + GfLoginWithAccessTokenDialogComponent, + LoginWithAccessTokenDialogResult + >, private settingsStorageService: SettingsStorageService ) { addIcons({ eyeOffOutline, eyeOutline }); diff --git a/libs/common/src/lib/dtos/update-user-setting.dto.ts b/libs/common/src/lib/dtos/update-user-setting.dto.ts index f2781835c..d46982e70 100644 --- a/libs/common/src/lib/dtos/update-user-setting.dto.ts +++ b/libs/common/src/lib/dtos/update-user-setting.dto.ts @@ -58,23 +58,23 @@ export class UpdateUserSettingDto { @IsArray() @IsOptional() - 'filters.accounts'?: string[]; + 'filters.accounts'?: string[] | null; @IsArray() @IsOptional() - 'filters.assetClasses'?: string[]; + 'filters.assetClasses'?: string[] | null; @IsString() @IsOptional() - 'filters.dataSource'?: string; + 'filters.dataSource'?: string | null; @IsString() @IsOptional() - 'filters.symbol'?: string; + 'filters.symbol'?: string | null; @IsArray() @IsOptional() - 'filters.tags'?: string[]; + 'filters.tags'?: string[] | null; @IsIn(['CHART', 'TABLE'] as HoldingsViewMode[]) @IsOptional() From e0a43cb21451a6a5e0408368621882c8540406d0 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 21 May 2026 19:51:19 +0300 Subject: [PATCH 092/132] Feature/add icon column to benchmark component (#6907) * Add icon column * Update changelog --- CHANGELOG.md | 1 + .../home-watchlist/home-watchlist.html | 1 + .../lib/benchmark/benchmark.component.html | 33 ++++++++++++------- .../src/lib/benchmark/benchmark.component.ts | 4 +++ 4 files changed, 28 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 843f33f05..bbbaaf902 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added the icon column to the benchmark component - Added support for the `DIRECT_URL` environment variable to enable direct database connections ### Changed diff --git a/apps/client/src/app/components/home-watchlist/home-watchlist.html b/apps/client/src/app/components/home-watchlist/home-watchlist.html index a17259000..c7c9a9c4b 100644 --- a/apps/client/src/app/components/home-watchlist/home-watchlist.html +++ b/apps/client/src/app/components/home-watchlist/home-watchlist.html @@ -14,6 +14,7 @@ [deviceType]="deviceType()" [hasPermissionToDeleteItem]="hasPermissionToDeleteWatchlistItem" [locale]="user?.settings?.locale || undefined" + [showIcon]="true" [user]="user" (itemDeleted)="onWatchlistItemDeleted($event)" /> diff --git a/libs/ui/src/lib/benchmark/benchmark.component.html b/libs/ui/src/lib/benchmark/benchmark.component.html index ab0d4fb6f..a950f0c44 100644 --- a/libs/ui/src/lib/benchmark/benchmark.component.html +++ b/libs/ui/src/lib/benchmark/benchmark.component.html @@ -7,11 +7,22 @@ matSortDirection="asc" [dataSource]="dataSource" > - - + + + + + + + + + Name - +
    {{ element?.name }}
    @@ -26,14 +37,14 @@ 50-Day Trend
    @@ -55,14 +66,14 @@ 200-Day Trend
    @@ -84,14 +95,14 @@ Last All Time High
    @@ -109,7 +120,7 @@ @@ -118,7 +129,7 @@ > from ATH - + @if (isNumber(element?.performances?.allTimeHigh?.performancePercent)) { (); public readonly hasPermissionToDeleteItem = input(); public readonly locale = input(getLocale()); + public readonly showIcon = input(false); public readonly showSymbol = input(true); public readonly user = input(); @@ -75,6 +78,7 @@ export class GfBenchmarkComponent { protected readonly dataSource = new MatTableDataSource([]); protected readonly displayedColumns = computed(() => { return [ + ...(this.showIcon() ? ['icon'] : []), 'name', ...(this.user()?.settings?.isExperimentalFeatures ? ['trend50d', 'trend200d'] From db1cb4e68171b4b1be5aebdb452e4a393f04e194 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 21 May 2026 19:53:17 +0300 Subject: [PATCH 093/132] Task/filter out sectors with zero weight in Yahoo Finance data enhancer (#6895) * Filter out sectors with zero weight * Update changelog --- CHANGELOG.md | 1 + .../yahoo-finance/yahoo-finance.service.ts | 20 ++++++++++--------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bbbaaf902..1995cebd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improved the pagination in the activities table of the account detail dialog - Improved the pagination in the activities table of the holding detail dialog - Randomized the placeholder in the assistant +- Filtered out sectors with zero weight for ETF and mutual fund assets in the _Yahoo Finance_ data enhancer - Enabled the _Bull Dashboard_ in the admin control panel without requiring an environment variable (experimental) - Relaxed the URL validation in the asset profile DTOs to accept both `HTTP` and `HTTPS` protocols - Relaxed the URL validation in the platform DTOs to accept both `HTTP` and `HTTPS` protocols diff --git a/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts b/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts index 72136dc04..30ad81c09 100644 --- a/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts +++ b/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts @@ -218,16 +218,18 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { }; }) ?? []; - response.sectors = ( - assetProfile.topHoldings?.sectorWeightings ?? [] - ).flatMap((sectorWeighting) => { - return Object.entries(sectorWeighting).map(([sector, weight]) => { - return { - name: this.parseSector(sector), - weight: weight as number - }; + response.sectors = (assetProfile.topHoldings?.sectorWeightings ?? []) + .flatMap((sectorWeighting) => { + return Object.entries(sectorWeighting).map(([sector, weight]) => { + return { + name: this.parseSector(sector), + weight: weight as number + }; + }); + }) + .filter(({ weight }) => { + return weight > 0; }); - }); } else if ( assetSubClass === 'STOCK' && assetProfile.summaryProfile?.country From 8e3778cc66dcee3f806ef9aa6d286cb3a2e47029 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 21 May 2026 20:03:38 +0300 Subject: [PATCH 094/132] Task/improve Stripe checkout session verification (#6872) * Improve verification of stripeCheckoutSessionId * Update changelog --- CHANGELOG.md | 1 + .../subscription/subscription.controller.ts | 10 +++-- .../app/subscription/subscription.service.ts | 42 ++++++++++++++----- .../migration.sql | 5 +++ prisma/schema.prisma | 15 +++---- 5 files changed, 51 insertions(+), 22 deletions(-) create mode 100644 prisma/migrations/20260516074856_added_stripe_checkout_session_to_subscription/migration.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 1995cebd9..a731397a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Randomized the placeholder in the assistant - Filtered out sectors with zero weight for ETF and mutual fund assets in the _Yahoo Finance_ data enhancer - Enabled the _Bull Dashboard_ in the admin control panel without requiring an environment variable (experimental) +- Improved the verification of the _Stripe_ checkout session when creating a subscription - Relaxed the URL validation in the asset profile DTOs to accept both `HTTP` and `HTTPS` protocols - Relaxed the URL validation in the platform DTOs to accept both `HTTP` and `HTTPS` protocols - Extracted the page tabs to a reusable component diff --git a/apps/api/src/app/subscription/subscription.controller.ts b/apps/api/src/app/subscription/subscription.controller.ts index e1c705fdd..3e6316ec6 100644 --- a/apps/api/src/app/subscription/subscription.controller.ts +++ b/apps/api/src/app/subscription/subscription.controller.ts @@ -100,10 +100,12 @@ export class SubscriptionController { request.query.checkoutSessionId as string ); - Logger.log( - `Subscription for user '${userId}' has been created via Stripe`, - 'SubscriptionController' - ); + if (userId) { + Logger.log( + `Subscription for user '${userId}' has been created via Stripe`, + 'SubscriptionController' + ); + } response.redirect( `${this.configurationService.get( diff --git a/apps/api/src/app/subscription/subscription.service.ts b/apps/api/src/app/subscription/subscription.service.ts index 07795d0d1..557d81976 100644 --- a/apps/api/src/app/subscription/subscription.service.ts +++ b/apps/api/src/app/subscription/subscription.service.ts @@ -17,7 +17,7 @@ import { } from '@ghostfolio/common/types'; import { Injectable, Logger } from '@nestjs/common'; -import { Subscription } from '@prisma/client'; +import { Prisma, Subscription } from '@prisma/client'; import { addMilliseconds, isBefore } from 'date-fns'; import ms, { StringValue } from 'ms'; import Stripe from 'stripe'; @@ -108,11 +108,13 @@ export class SubscriptionService { duration = '1 year', durationExtension, price, + stripeCheckoutSessionId, userId }: { duration?: StringValue; durationExtension?: StringValue; price: number; + stripeCheckoutSessionId?: string; userId: string; }) { let expiresAt = addMilliseconds(new Date(), ms(duration)); @@ -125,6 +127,7 @@ export class SubscriptionService { data: { expiresAt, price, + stripeCheckoutSessionId, user: { connect: { id: userId @@ -136,25 +139,42 @@ export class SubscriptionService { public async createSubscriptionViaStripe(aCheckoutSessionId: string) { try { - let durationExtension: StringValue; - const session = await this.stripe.checkout.sessions.retrieve(aCheckoutSessionId); + if (session.payment_status !== 'paid' || session.status !== 'complete') { + throw new Error( + `Stripe Checkout Session '${aCheckoutSessionId}' has not been paid (status=${session.status}, payment_status=${session.payment_status})` + ); + } + const subscriptionOffer: SubscriptionOffer = JSON.parse( session.metadata.subscriptionOffer ?? '{}' ); - if (subscriptionOffer) { - durationExtension = subscriptionOffer.durationExtension; + const durationExtension = subscriptionOffer?.durationExtension; + + try { + await this.createSubscription({ + durationExtension, + price: session.amount_total / 100, + stripeCheckoutSessionId: session.id, + userId: session.client_reference_id + }); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ) { + Logger.log( + `Stripe Checkout Session '${session.id}' has already been redeemed`, + 'SubscriptionService' + ); + } else { + throw error; + } } - await this.createSubscription({ - durationExtension, - price: session.amount_total / 100, - userId: session.client_reference_id - }); - return session.client_reference_id; } catch (error) { Logger.error(error, 'SubscriptionService'); diff --git a/prisma/migrations/20260516074856_added_stripe_checkout_session_to_subscription/migration.sql b/prisma/migrations/20260516074856_added_stripe_checkout_session_to_subscription/migration.sql new file mode 100644 index 000000000..f5c8ae016 --- /dev/null +++ b/prisma/migrations/20260516074856_added_stripe_checkout_session_to_subscription/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Subscription" ADD COLUMN "stripeCheckoutSessionId" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "Subscription_stripeCheckoutSessionId_key" ON "Subscription"("stripeCheckoutSessionId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a739ce8ff..024ab7aa0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -236,13 +236,14 @@ model SymbolProfileOverrides { } model Subscription { - createdAt DateTime @default(now()) - expiresAt DateTime - id String @id @default(uuid()) - price Float? - updatedAt DateTime @updatedAt - user User @relation(fields: [userId], onDelete: Cascade, references: [id]) - userId String + createdAt DateTime @default(now()) + expiresAt DateTime + id String @id @default(uuid()) + price Float? + stripeCheckoutSessionId String? @unique + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], onDelete: Cascade, references: [id]) + userId String @@index([userId]) } From 78134020a2885a15a64018b0240bac009b1a3d50 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 21 May 2026 20:06:25 +0300 Subject: [PATCH 095/132] Release 3.4.0 (#6919) --- CHANGELOG.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a731397a7..233f02bbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## Unreleased +## 3.4.0 - 2026-05-21 ### Added diff --git a/package-lock.json b/package-lock.json index 9641c61b0..34f63f0b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ghostfolio", - "version": "3.3.0", + "version": "3.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ghostfolio", - "version": "3.3.0", + "version": "3.4.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/package.json b/package.json index 91ceb3894..0578cd7de 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostfolio", - "version": "3.3.0", + "version": "3.4.0", "homepage": "https://ghostfol.io", "license": "AGPL-3.0", "repository": "https://github.com/ghostfolio/ghostfolio", From 7e8593e27fa2232764f8426d17857bc7ba117ac9 Mon Sep 17 00:00:00 2001 From: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com> Date: Sat, 23 May 2026 02:44:04 +0700 Subject: [PATCH 096/132] Task/improve type safety for home market component (#6921) * feat(client): resolve error * feat(client): enforce encapsulation * feat(client): enforce immutability * feat(client): replace constructor based DI with inject function * feat(client): replace deprecated getDeviceInfo * feat(client): convert benchmarks to signal * feat(client): convert historicalDataItems to signal * feat(client): convert fearAndGreedIndex to signal --- .../home-market/home-market.component.ts | 60 ++++++++++--------- .../components/home-market/home-market.html | 10 ++-- 2 files changed, 37 insertions(+), 33 deletions(-) diff --git a/apps/client/src/app/components/home-market/home-market.component.ts b/apps/client/src/app/components/home-market/home-market.component.ts index cb645f2ef..dd0c38cea 100644 --- a/apps/client/src/app/components/home-market/home-market.component.ts +++ b/apps/client/src/app/components/home-market/home-market.component.ts @@ -16,9 +16,12 @@ import { DataService } from '@ghostfolio/ui/services'; import { ChangeDetectorRef, Component, + computed, CUSTOM_ELEMENTS_SCHEMA, DestroyRef, - OnInit + inject, + OnInit, + signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { DeviceDetectorService } from 'ngx-device-detector'; @@ -35,25 +38,27 @@ import { DeviceDetectorService } from 'ngx-device-detector'; templateUrl: './home-market.html' }) export class GfHomeMarketComponent implements OnInit { - public benchmarks: Benchmark[]; - public deviceType: string; - public fearAndGreedIndex: number; - public fearLabel = $localize`Fear`; - public greedLabel = $localize`Greed`; - public hasPermissionToAccessFearAndGreedIndex: boolean; - public historicalDataItems: HistoricalDataItem[]; - public info: InfoItem; - public readonly numberOfDays = 365; - public user: User; + protected readonly benchmarks = signal([]); + protected readonly deviceType = computed( + () => this.deviceDetectorService.deviceInfo().deviceType + ); + protected readonly fearAndGreedIndex = signal(undefined); + protected readonly fearLabel = $localize`Fear`; + protected readonly greedLabel = $localize`Greed`; + protected hasPermissionToAccessFearAndGreedIndex: boolean; + protected readonly historicalDataItems = signal([]); + protected readonly numberOfDays = 365; + protected user: User; - public constructor( - private changeDetectorRef: ChangeDetectorRef, - private dataService: DataService, - private destroyRef: DestroyRef, - private deviceDetectorService: DeviceDetectorService, - private userService: UserService - ) { - this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; + private readonly info: InfoItem; + + private readonly changeDetectorRef = inject(ChangeDetectorRef); + private readonly dataService = inject(DataService); + private readonly destroyRef = inject(DestroyRef); + private readonly deviceDetectorService = inject(DeviceDetectorService); + private readonly userService = inject(UserService); + + public constructor() { this.info = this.dataService.fetchInfo(); this.userService.stateChanged @@ -73,7 +78,10 @@ export class GfHomeMarketComponent implements OnInit { permissions.enableFearAndGreedIndex ); - if (this.hasPermissionToAccessFearAndGreedIndex) { + if ( + this.hasPermissionToAccessFearAndGreedIndex && + this.info.fearAndGreedDataSource + ) { this.dataService .fetchSymbolItem({ dataSource: this.info.fearAndGreedDataSource, @@ -82,16 +90,14 @@ export class GfHomeMarketComponent implements OnInit { }) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ historicalData, marketPrice }) => { - this.fearAndGreedIndex = marketPrice; - this.historicalDataItems = [ + this.fearAndGreedIndex.set(marketPrice); + this.historicalDataItems.set([ ...historicalData, { date: resetHours(new Date()).toISOString(), value: marketPrice } - ]; - - this.changeDetectorRef.markForCheck(); + ]); }); } @@ -99,9 +105,7 @@ export class GfHomeMarketComponent implements OnInit { .fetchBenchmarks() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ benchmarks }) => { - this.benchmarks = benchmarks; - - this.changeDetectorRef.markForCheck(); + this.benchmarks.set(benchmarks); }); } } diff --git a/apps/client/src/app/components/home-market/home-market.html b/apps/client/src/app/components/home-market/home-market.html index fc7230d35..a782526ee 100644 --- a/apps/client/src/app/components/home-market/home-market.html +++ b/apps/client/src/app/components/home-market/home-market.html @@ -10,7 +10,7 @@ class="mb-3" label="Fear & Greed Index" [colorScheme]="user?.settings?.colorScheme" - [historicalDataItems]="historicalDataItems" + [historicalDataItems]="historicalDataItems()" [isAnimated]="true" [locale]="user?.settings?.locale || undefined" [showXAxis]="true" @@ -22,7 +22,7 @@ />
    @@ -31,13 +31,13 @@
    - @if (benchmarks?.length > 0) { + @if (benchmarks()?.length > 0) {
    From 229ff5947841eeb797672565fa39de040041ee06 Mon Sep 17 00:00:00 2001 From: Dmytro Shatokhin <111596676+Trillianti@users.noreply.github.com> Date: Fri, 22 May 2026 22:59:46 +0300 Subject: [PATCH 097/132] Task/upgrade @keyv/redis to v5 (#6917) * Upgrade @keyv/redis to v5 * Update changelog --- CHANGELOG.md | 6 ++++++ package-lock.json | 51 ++++++++++++++++++++++++----------------------- package.json | 2 +- 3 files changed, 33 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 233f02bbc..7cee9179d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Changed + +- Upgraded `@keyv/redis` from version `4.4.0` to `5.1.6` + ## 3.4.0 - 2026-05-21 ### Added diff --git a/package-lock.json b/package-lock.json index 34f63f0b0..9034b3eb6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,7 @@ "@date-fns/utc": "2.1.1", "@internationalized/number": "3.6.6", "@ionic/angular": "8.8.5", - "@keyv/redis": "4.4.0", + "@keyv/redis": "5.1.6", "@nestjs/bull": "11.0.4", "@nestjs/cache-manager": "3.1.0", "@nestjs/common": "11.1.19", @@ -7144,19 +7144,20 @@ } }, "node_modules/@keyv/redis": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@keyv/redis/-/redis-4.4.0.tgz", - "integrity": "sha512-n/KEj3S7crVkoykggqsMUtcjNGvjagGPlJYgO/r6m9hhGZfhp1txJElHxcdJ1ANi/LJoBuOSILj15g6HD2ucqQ==", + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/@keyv/redis/-/redis-5.1.6.tgz", + "integrity": "sha512-eKvW6pspvVaU5dxigaIDZr635/Uw6urTXL3gNbY9WTR8d3QigZQT+r8gxYSEOsw4+1cCBsC4s7T2ptR0WC9LfQ==", "license": "MIT", "dependencies": { - "@redis/client": "^1.6.0", - "cluster-key-slot": "^1.1.2" + "@redis/client": "^5.10.0", + "cluster-key-slot": "^1.1.2", + "hookified": "^1.13.0" }, "engines": { "node": ">= 18" }, "peerDependencies": { - "keyv": "^5.3.3" + "keyv": "^5.6.0" } }, "node_modules/@keyv/serialize": { @@ -11664,17 +11665,27 @@ } }, "node_modules/@redis/client": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", - "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.12.1.tgz", + "integrity": "sha512-7aPGWeqA3uFm43o19umzdl16CEjK/JQGtSXVPevplTaOU3VJA/rseBC1QvYUz9lLDIMBimc4SW/zrW4S89BaCA==", "license": "MIT", "dependencies": { - "cluster-key-slot": "1.1.2", - "generic-pool": "3.9.0", - "yallist": "4.0.0" + "cluster-key-slot": "1.1.2" }, "engines": { - "node": ">=14" + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@node-rs/xxhash": "^1.1.0", + "@opentelemetry/api": ">=1 <2" + }, + "peerDependenciesMeta": { + "@node-rs/xxhash": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } } }, "node_modules/@rolldown/binding-android-arm64": { @@ -23191,15 +23202,6 @@ "node": ">= 0.4" } }, - "node_modules/generic-pool": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", - "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -23818,8 +23820,7 @@ "version": "1.15.1", "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/hosted-git-info": { "version": "9.0.2", diff --git a/package.json b/package.json index 0578cd7de..5bc9e7971 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "@date-fns/utc": "2.1.1", "@internationalized/number": "3.6.6", "@ionic/angular": "8.8.5", - "@keyv/redis": "4.4.0", + "@keyv/redis": "5.1.6", "@nestjs/bull": "11.0.4", "@nestjs/cache-manager": "3.1.0", "@nestjs/common": "11.1.19", From 5b9df03a1e3922154d5558d67d87e1a6c711ad9d Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 23 May 2026 08:28:03 +0200 Subject: [PATCH 098/132] Task/remove deprecated attributes from holdings of public portfolio endpoint response (#6923) * Remove deprecated attributes * Update changelog --- CHANGELOG.md | 1 + .../app/endpoints/public/public.controller.ts | 24 ++++++++++++------- .../src/app/portfolio/portfolio.controller.ts | 15 ++++++++++++ .../app/pages/public/public-page.component.ts | 16 ++++++------- .../public-portfolio-response.interface.ts | 22 ----------------- .../holdings-table.component.html | 23 +++++++++++------- 6 files changed, 53 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cee9179d..c6b8e50a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Removed the deprecated attributes (`assetClass`, `countries`, `currency`, `dataSource`, `name`, `sectors`, `symbol` and `url`) from the holdings of the public portfolio endpoint response - Upgraded `@keyv/redis` from version `4.4.0` to `5.1.6` ## 3.4.0 - 2026-05-21 diff --git a/apps/api/src/app/endpoints/public/public.controller.ts b/apps/api/src/app/endpoints/public/public.controller.ts index 1d6eb6b0b..0488da326 100644 --- a/apps/api/src/app/endpoints/public/public.controller.ts +++ b/apps/api/src/app/endpoints/public/public.controller.ts @@ -167,19 +167,25 @@ export class PublicController { publicPortfolioResponse.holdings[symbol] = { allocationInPercentage: portfolioPosition.valueInBaseCurrency / totalValue, - assetClass: hasDetails ? portfolioPosition.assetClass : undefined, - assetProfile: hasDetails ? portfolioPosition.assetProfile : undefined, - countries: hasDetails ? portfolioPosition.countries : [], - currency: hasDetails ? portfolioPosition.currency : undefined, - dataSource: portfolioPosition.dataSource, + assetProfile: { + ...portfolioPosition.assetProfile, + ...(hasDetails + ? {} + : { + assetClass: undefined, + assetClassLabel: undefined, + assetSubClass: undefined, + assetSubClassLabel: undefined, + countries: [], + currency: undefined, + holdings: [], + sectors: [] + }) + }, dateOfFirstActivity: portfolioPosition.dateOfFirstActivity, markets: hasDetails ? portfolioPosition.markets : undefined, - name: portfolioPosition.name, netPerformancePercentWithCurrencyEffect: portfolioPosition.netPerformancePercentWithCurrencyEffect, - sectors: hasDetails ? portfolioPosition.sectors : [], - symbol: portfolioPosition.symbol, - url: portfolioPosition.url, valueInPercentage: portfolioPosition.valueInBaseCurrency / totalValue }; } diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index cb6f21e8a..3b306c87b 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -220,6 +220,21 @@ export class PortfolioController { hasDetails || portfolioPosition.assetClass === AssetClass.LIQUIDITY ? portfolioPosition.assetClass : undefined, + assetProfile: { + ...portfolioPosition.assetProfile, + ...(hasDetails + ? {} + : { + assetClass: undefined, + assetClassLabel: undefined, + assetSubClass: undefined, + assetSubClassLabel: undefined, + countries: [], + currency: undefined, + holdings: [], + sectors: [] + }) + }, assetSubClass: hasDetails || portfolioPosition.assetSubClass === AssetSubClass.CASH ? portfolioPosition.assetSubClass diff --git a/apps/client/src/app/pages/public/public-page.component.ts b/apps/client/src/app/pages/public/public-page.component.ts index c12a2d5de..43eb23eb9 100644 --- a/apps/client/src/app/pages/public/public-page.component.ts +++ b/apps/client/src/app/pages/public/public-page.component.ts @@ -172,16 +172,16 @@ export class GfPublicPageComponent implements OnInit { this.holdings.push(position); this.positions[symbol] = { - currency: position.currency, - name: position.name, + currency: position.assetProfile.currency, + name: position.assetProfile.name, value: position.allocationInPercentage }; - if (position.assetClass !== AssetClass.LIQUIDITY) { + if (position.assetProfile.assetClass !== AssetClass.LIQUIDITY) { // Prepare analysis data by continents, countries, holdings and sectors except for liquidity - if (position.countries.length > 0) { - for (const country of position.countries) { + if (position.assetProfile.countries.length > 0) { + for (const country of position.assetProfile.countries) { const { code, continent, name, weight } = country; if (this.continents[continent]?.value) { @@ -220,8 +220,8 @@ export class GfPublicPageComponent implements OnInit { 0; } - if (position.sectors.length > 0) { - for (const sector of position.sectors) { + if (position.assetProfile.sectors.length > 0) { + for (const sector of position.assetProfile.sectors) { const { name, weight } = sector; if (this.sectors[name]?.value) { @@ -245,7 +245,7 @@ export class GfPublicPageComponent implements OnInit { } this.symbols[prettifySymbol(symbol)] = { - name: position.name, + name: position.assetProfile.name, symbol: prettifySymbol(symbol), value: isNumber(position.valueInBaseCurrency) ? position.valueInBaseCurrency diff --git a/libs/common/src/lib/interfaces/responses/public-portfolio-response.interface.ts b/libs/common/src/lib/interfaces/responses/public-portfolio-response.interface.ts index eae14cec6..18c7dc57a 100644 --- a/libs/common/src/lib/interfaces/responses/public-portfolio-response.interface.ts +++ b/libs/common/src/lib/interfaces/responses/public-portfolio-response.interface.ts @@ -14,32 +14,10 @@ export interface PublicPortfolioResponse extends PublicPortfolioResponseV1 { [symbol: string]: Pick< PortfolioPosition, | 'allocationInPercentage' - - /** @deprecated */ - | 'assetClass' | 'assetProfile' - - /** @deprecated */ - | 'countries' - | 'currency' - - /** @deprecated */ - | 'dataSource' | 'dateOfFirstActivity' | 'markets' - - /** @deprecated */ - | 'name' | 'netPerformancePercentWithCurrencyEffect' - - /** @deprecated */ - | 'sectors' - - /** @deprecated */ - | 'symbol' - - /** @deprecated */ - | 'url' | 'valueInBaseCurrency' | 'valueInPercentage' >; diff --git a/libs/ui/src/lib/holdings-table/holdings-table.component.html b/libs/ui/src/lib/holdings-table/holdings-table.component.html index 46fc0216d..f19646552 100644 --- a/libs/ui/src/lib/holdings-table/holdings-table.component.html +++ b/libs/ui/src/lib/holdings-table/holdings-table.component.html @@ -11,26 +11,31 @@ - + Name
    - {{ element.name }} - @if (element.name === element.symbol) { + {{ element.assetProfile.name }} + @if (element.assetProfile.name === element.assetProfile.symbol) { ({{ element.assetProfile.assetSubClassLabel }}) }
    - {{ element.symbol }} + {{ element.assetProfile.symbol }}
    @@ -185,8 +190,8 @@ (click)=" canShowDetails(row) && onOpenHoldingDialog({ - dataSource: row.dataSource, - symbol: row.symbol + dataSource: row.assetProfile.dataSource, + symbol: row.assetProfile.symbol }) " > From bb76888084f84aac17f516e0d2cfbea2fa8469d0 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 23 May 2026 08:50:42 +0200 Subject: [PATCH 099/132] Task/set up min-release-age in .npmrc (#6915) * Set up min-release-age * Update changelog --- .npmrc | 1 + CHANGELOG.md | 4 ++++ 2 files changed, 5 insertions(+) create mode 100644 .npmrc diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..7253a5cee --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +min-release-age=7 diff --git a/CHANGELOG.md b/CHANGELOG.md index c6b8e50a9..fa9bdc445 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Configured the `min-release-age` in `.npmrc` + ### Changed - Removed the deprecated attributes (`assetClass`, `countries`, `currency`, `dataSource`, `name`, `sectors`, `symbol` and `url`) from the holdings of the public portfolio endpoint response From 9d4fe5c6b9e97262103d099c24b8836edbecfddc Mon Sep 17 00:00:00 2001 From: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com> Date: Sat, 23 May 2026 14:19:08 +0700 Subject: [PATCH 100/132] Bugfix/double scrollbars on feature page (#6925) * Fix double scrollbars on feature page * Remove has-tabs style class * Update changelog --- CHANGELOG.md | 4 ++++ .../src/app/pages/about/about-page.component.ts | 2 +- .../src/app/pages/admin/admin-page.component.ts | 2 +- apps/client/src/app/pages/faq/faq-page.component.ts | 2 +- .../client/src/app/pages/home/home-page.component.ts | 2 +- .../app/pages/portfolio/portfolio-page.component.ts | 2 +- .../app/pages/resources/resources-page.component.ts | 2 +- .../user-account/user-account-page.component.ts | 2 +- apps/client/src/app/pages/zen/zen-page.component.ts | 2 +- apps/client/src/styles.scss | 12 +++++++++--- 10 files changed, 21 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa9bdc445..1f19cdf66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Removed the deprecated attributes (`assetClass`, `countries`, `currency`, `dataSource`, `name`, `sectors`, `symbol` and `url`) from the holdings of the public portfolio endpoint response - Upgraded `@keyv/redis` from version `4.4.0` to `5.1.6` +### Fixed + +- Fixed a layout regression that caused a double scrollbar on pages without tabs + ## 3.4.0 - 2026-05-21 ### Added diff --git a/apps/client/src/app/pages/about/about-page.component.ts b/apps/client/src/app/pages/about/about-page.component.ts index fb632a586..616977d80 100644 --- a/apps/client/src/app/pages/about/about-page.component.ts +++ b/apps/client/src/app/pages/about/about-page.component.ts @@ -21,7 +21,7 @@ import { } from 'ionicons/icons'; @Component({ - host: { class: 'page has-tabs' }, + host: { class: 'page' }, imports: [GfPageTabsComponent], selector: 'gf-about-page', styleUrls: ['./about-page.scss'], diff --git a/apps/client/src/app/pages/admin/admin-page.component.ts b/apps/client/src/app/pages/admin/admin-page.component.ts index 6b653efb0..b933ff058 100644 --- a/apps/client/src/app/pages/admin/admin-page.component.ts +++ b/apps/client/src/app/pages/admin/admin-page.component.ts @@ -15,7 +15,7 @@ import { } from 'ionicons/icons'; @Component({ - host: { class: 'page has-tabs' }, + host: { class: 'page' }, imports: [GfPageTabsComponent], selector: 'gf-admin-page', styleUrls: ['./admin-page.scss'], diff --git a/apps/client/src/app/pages/faq/faq-page.component.ts b/apps/client/src/app/pages/faq/faq-page.component.ts index 6dabdb5e2..60081687a 100644 --- a/apps/client/src/app/pages/faq/faq-page.component.ts +++ b/apps/client/src/app/pages/faq/faq-page.component.ts @@ -11,7 +11,7 @@ import { addIcons } from 'ionicons'; import { cloudyOutline, readerOutline, serverOutline } from 'ionicons/icons'; @Component({ - host: { class: 'page has-tabs' }, + host: { class: 'page' }, imports: [GfPageTabsComponent], selector: 'gf-faq-page', styleUrls: ['./faq-page.scss'], diff --git a/apps/client/src/app/pages/home/home-page.component.ts b/apps/client/src/app/pages/home/home-page.component.ts index 453a79a52..8c5caab22 100644 --- a/apps/client/src/app/pages/home/home-page.component.ts +++ b/apps/client/src/app/pages/home/home-page.component.ts @@ -25,7 +25,7 @@ import { } from 'ionicons/icons'; @Component({ - host: { class: 'page has-tabs' }, + host: { class: 'page' }, imports: [GfPageTabsComponent], selector: 'gf-home-page', styleUrls: ['./home-page.scss'], diff --git a/apps/client/src/app/pages/portfolio/portfolio-page.component.ts b/apps/client/src/app/pages/portfolio/portfolio-page.component.ts index 00fb3242b..7f60edae8 100644 --- a/apps/client/src/app/pages/portfolio/portfolio-page.component.ts +++ b/apps/client/src/app/pages/portfolio/portfolio-page.component.ts @@ -18,7 +18,7 @@ import { } from 'ionicons/icons'; @Component({ - host: { class: 'page has-tabs' }, + host: { class: 'page' }, imports: [GfPageTabsComponent], selector: 'gf-portfolio-page', styleUrls: ['./portfolio-page.scss'], diff --git a/apps/client/src/app/pages/resources/resources-page.component.ts b/apps/client/src/app/pages/resources/resources-page.component.ts index faba06f28..52980be85 100644 --- a/apps/client/src/app/pages/resources/resources-page.component.ts +++ b/apps/client/src/app/pages/resources/resources-page.component.ts @@ -14,7 +14,7 @@ import { } from 'ionicons/icons'; @Component({ - host: { class: 'page has-tabs' }, + host: { class: 'page' }, imports: [GfPageTabsComponent], selector: 'gf-resources-page', styleUrls: ['./resources-page.scss'], diff --git a/apps/client/src/app/pages/user-account/user-account-page.component.ts b/apps/client/src/app/pages/user-account/user-account-page.component.ts index 71b93b2e4..ec7547b3c 100644 --- a/apps/client/src/app/pages/user-account/user-account-page.component.ts +++ b/apps/client/src/app/pages/user-account/user-account-page.component.ts @@ -12,7 +12,7 @@ import { addIcons } from 'ionicons'; import { diamondOutline, keyOutline, settingsOutline } from 'ionicons/icons'; @Component({ - host: { class: 'page has-tabs' }, + host: { class: 'page' }, imports: [GfPageTabsComponent], selector: 'gf-user-account-page', styleUrls: ['./user-account-page.scss'], diff --git a/apps/client/src/app/pages/zen/zen-page.component.ts b/apps/client/src/app/pages/zen/zen-page.component.ts index df3684fd2..4a897093c 100644 --- a/apps/client/src/app/pages/zen/zen-page.component.ts +++ b/apps/client/src/app/pages/zen/zen-page.component.ts @@ -12,7 +12,7 @@ import { addIcons } from 'ionicons'; import { albumsOutline, analyticsOutline } from 'ionicons/icons'; @Component({ - host: { class: 'page has-tabs' }, + host: { class: 'page' }, imports: [GfPageTabsComponent], selector: 'gf-zen-page', styleUrls: ['./zen-page.scss'], diff --git a/apps/client/src/styles.scss b/apps/client/src/styles.scss index a863603b0..1eb5bd2dd 100644 --- a/apps/client/src/styles.scss +++ b/apps/client/src/styles.scss @@ -365,7 +365,8 @@ ngx-skeleton-loader { } .has-info-message { - .page.has-tabs { + // Restrict viewport height of tabbed views when the Live Demo or system announcements banner are displayed + .page:has(gf-page-tabs) { height: calc(100svh - 2 * var(--mat-toolbar-standard-height)); } } @@ -479,7 +480,6 @@ ngx-skeleton-loader { .page { display: flex; - height: calc(100svh - var(--mat-toolbar-standard-height)); overflow-y: auto; padding-bottom: env(safe-area-inset-bottom); padding-bottom: constant(safe-area-inset-bottom); @@ -491,7 +491,13 @@ ngx-skeleton-loader { z-index: 999; } - &:not(.has-tabs) { + // Restrict viewport height and layout boundaries only when the page hosts tab navigation + &:has(gf-page-tabs) { + height: calc(100svh - var(--mat-toolbar-standard-height)); + } + + // Apply vertical padding only to standalone or nested content views (tabs handle their own padding) + &:not(:has(gf-page-tabs)) { @media (min-width: 576px) { padding: 2rem 0; } From 2ba7e465794f9f6d71cd3fc3c16c73d28faab4a5 Mon Sep 17 00:00:00 2001 From: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com> Date: Sat, 23 May 2026 14:19:40 +0700 Subject: [PATCH 101/132] Task/improve type safety in home summary component (#6926) * fix(client): resolve errors * feat(client): replace constructor based DI with inject function * feat(client): enforce encapsulation * fix(client): remove dead code * feat(client): replace deprecated getDeviceInfo * feat(client): convert isLoading to signal * feat(client): convert hasImpersonationId to signal * feat(client): convert summary to signal * feat(client): convert user and hasPermissionToUpdateUserSettings to signals * feat(client): implement OnPush change detection strategy * fix(client): remove nested subscription --- .../home-summary/home-summary.component.ts | 102 ++++++++---------- .../components/home-summary/home-summary.html | 18 ++-- 2 files changed, 54 insertions(+), 66 deletions(-) diff --git a/apps/client/src/app/components/home-summary/home-summary.component.ts b/apps/client/src/app/components/home-summary/home-summary.component.ts index 60960480d..a63876a54 100644 --- a/apps/client/src/app/components/home-summary/home-summary.component.ts +++ b/apps/client/src/app/components/home-summary/home-summary.component.ts @@ -1,27 +1,27 @@ import { GfPortfolioSummaryComponent } from '@ghostfolio/client/components/portfolio-summary/portfolio-summary.component'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; -import { - InfoItem, - PortfolioSummary, - User -} from '@ghostfolio/common/interfaces'; +import { PortfolioSummary, User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { DataService } from '@ghostfolio/ui/services'; import { - ChangeDetectorRef, + ChangeDetectionStrategy, Component, + computed, CUSTOM_ELEMENTS_SCHEMA, DestroyRef, - OnInit + inject, + OnInit, + signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatCardModule } from '@angular/material/card'; -import { MatSnackBarRef, TextOnlySnackBar } from '@angular/material/snack-bar'; import { DeviceDetectorService } from 'ngx-device-detector'; +import { switchMap } from 'rxjs'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, imports: [GfPortfolioSummaryComponent, MatCardModule], schemas: [CUSTOM_ELEMENTS_SCHEMA], selector: 'gf-home-summary', @@ -29,87 +29,75 @@ import { DeviceDetectorService } from 'ngx-device-detector'; templateUrl: './home-summary.html' }) export class GfHomeSummaryComponent implements OnInit { - public deviceType: string; - public hasImpersonationId: boolean; - public hasPermissionForSubscription: boolean; - public hasPermissionToUpdateUserSettings: boolean; - public info: InfoItem; - public isLoading = true; - public snackBarRef: MatSnackBarRef; - public summary: PortfolioSummary; - public user: User; + protected readonly hasImpersonationId = signal(false); + protected readonly isLoading = signal(true); + protected readonly summary = signal(undefined); + protected readonly user = signal(undefined); + + protected readonly deviceType = computed( + () => this.deviceDetectorService.deviceInfo().deviceType + ); + + protected readonly hasPermissionToUpdateUserSettings = computed(() => { + const user = this.user(); - public constructor( - private changeDetectorRef: ChangeDetectorRef, - private dataService: DataService, - private destroyRef: DestroyRef, - private deviceDetectorService: DeviceDetectorService, - private impersonationStorageService: ImpersonationStorageService, - private userService: UserService - ) { - this.info = this.dataService.fetchInfo(); + return user + ? hasPermission(user.permissions, permissions.updateUserSettings) + : false; + }); - this.hasPermissionForSubscription = hasPermission( - this.info?.globalPermissions, - permissions.enableSubscription - ); + private readonly dataService = inject(DataService); + private readonly destroyRef = inject(DestroyRef); + private readonly deviceDetectorService = inject(DeviceDetectorService); + private readonly impersonationStorageService = inject( + ImpersonationStorageService + ); + private readonly userService = inject(UserService); + public constructor() { this.userService.stateChanged .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((state) => { if (state?.user) { - this.user = state.user; - - this.hasPermissionToUpdateUserSettings = hasPermission( - this.user.permissions, - permissions.updateUserSettings - ); - + this.user.set(state.user); this.update(); } }); } public ngOnInit() { - this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; - this.impersonationStorageService .onChangeHasImpersonation() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((impersonationId) => { - this.hasImpersonationId = !!impersonationId; + this.hasImpersonationId.set(!!impersonationId); }); } - public onChangeEmergencyFund(emergencyFund: number) { + protected onChangeEmergencyFund(emergencyFund: number) { this.dataService .putUserSetting({ emergencyFund }) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => { - this.userService - .get(true) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((user) => { - this.user = user; - - this.changeDetectorRef.markForCheck(); - }); + .pipe( + switchMap(() => this.userService.get(true)), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((user) => { + this.user.set(user); }); } private update() { - this.isLoading = true; + this.isLoading.set(true); this.dataService .fetchPortfolioDetails() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ summary }) => { - this.summary = summary; - this.isLoading = false; + if (summary) { + this.summary.set(summary); + } - this.changeDetectorRef.markForCheck(); + this.isLoading.set(false); }); - - this.changeDetectorRef.markForCheck(); } } diff --git a/apps/client/src/app/components/home-summary/home-summary.html b/apps/client/src/app/components/home-summary/home-summary.html index 2ed988e8a..a6da72b03 100644 --- a/apps/client/src/app/components/home-summary/home-summary.html +++ b/apps/client/src/app/components/home-summary/home-summary.html @@ -5,17 +5,17 @@ From 081ce27d78be4f380f15bcf5ca35193a153cdcde Mon Sep 17 00:00:00 2001 From: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com> Date: Sat, 23 May 2026 23:24:28 +0700 Subject: [PATCH 102/132] Task/improve type safety in home overview component (#6927) * feat(client): implement default date range * feat(client): resolve errors * feat(client): replace constructor based DI with inject function * feat(client): enforce encapsulation * fix(client): remove dead code * feat(client): enforce immutability * feat(client): replace deprecated getDeviceInfo * feat(client): convert errors to signal * feat(client): convert hasImpersonationId to signal * feat(client): convert user to signal * feat(client): convert to computed signals * feat(client): convert historicalDataItems and isLoadingPerformance to signals * feat(client): convert performance and precision to signals * feat(client): implement OnPush change detection strategy * fix(common): revert DateRange type change * fix(client): format file --- .../benchmarks/benchmarks.controller.ts | 7 +- .../src/app/portfolio/portfolio.controller.ts | 11 +- .../src/app/portfolio/portfolio.service.ts | 5 +- apps/api/src/app/user/user.service.ts | 4 +- .../account-detail-dialog.component.ts | 3 +- .../home-overview/home-overview.component.ts | 151 +++++++++--------- .../home-overview/home-overview.html | 42 +++-- .../portfolio-performance.component.html | 4 - .../portfolio-performance.component.ts | 2 - .../import-activities-dialog.component.ts | 3 +- libs/common/src/lib/config.ts | 3 +- 11 files changed, 122 insertions(+), 113 deletions(-) diff --git a/apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts b/apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts index 970925777..74bb6b672 100644 --- a/apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts +++ b/apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts @@ -5,7 +5,10 @@ import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interc import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; -import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; +import { + DEFAULT_DATE_RANGE, + HEADER_KEY_IMPERSONATION +} from '@ghostfolio/common/config'; import type { AssetProfileIdentifier, BenchmarkMarketDataDetailsResponse, @@ -118,7 +121,7 @@ export class BenchmarksController { @Param('dataSource') dataSource: DataSource, @Param('startDateString') startDateString: string, @Param('symbol') symbol: string, - @Query('range') dateRange: DateRange = 'max', + @Query('range') dateRange: DateRange = DEFAULT_DATE_RANGE, @Query('accounts') filterByAccounts?: string, @Query('assetClasses') filterByAssetClasses?: string, @Query('dataSource') filterByDataSource?: string, diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 3b306c87b..8aa94ee92 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -14,6 +14,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { + DEFAULT_DATE_RANGE, HEADER_KEY_IMPERSONATION, UNKNOWN_KEY } from '@ghostfolio/common/config'; @@ -82,7 +83,7 @@ export class PortfolioController { @Query('accounts') filterByAccounts?: string, @Query('assetClasses') filterByAssetClasses?: string, @Query('dataSource') filterByDataSource?: string, - @Query('range') dateRange: DateRange = 'max', + @Query('range') dateRange: DateRange = DEFAULT_DATE_RANGE, @Query('symbol') filterBySymbol?: string, @Query('tags') filterByTags?: string, @Query('withMarkets') withMarketsParam = 'false' @@ -321,7 +322,7 @@ export class PortfolioController { @Query('assetClasses') filterByAssetClasses?: string, @Query('dataSource') filterByDataSource?: string, @Query('groupBy') groupBy?: GroupBy, - @Query('range') dateRange: DateRange = 'max', + @Query('range') dateRange: DateRange = DEFAULT_DATE_RANGE, @Query('symbol') filterBySymbol?: string, @Query('tags') filterByTags?: string ): Promise { @@ -422,7 +423,7 @@ export class PortfolioController { @Query('dataSource') filterByDataSource?: string, @Query('holdingType') filterByHoldingType?: string, @Query('query') filterBySearchQuery?: string, - @Query('range') dateRange: DateRange = 'max', + @Query('range') dateRange: DateRange = DEFAULT_DATE_RANGE, @Query('symbol') filterBySymbol?: string, @Query('tags') filterByTags?: string ): Promise { @@ -455,7 +456,7 @@ export class PortfolioController { @Query('assetClasses') filterByAssetClasses?: string, @Query('dataSource') filterByDataSource?: string, @Query('groupBy') groupBy?: GroupBy, - @Query('range') dateRange: DateRange = 'max', + @Query('range') dateRange: DateRange = DEFAULT_DATE_RANGE, @Query('symbol') filterBySymbol?: string, @Query('tags') filterByTags?: string ): Promise { @@ -527,7 +528,7 @@ export class PortfolioController { @Query('accounts') filterByAccounts?: string, @Query('assetClasses') filterByAssetClasses?: string, @Query('dataSource') filterByDataSource?: string, - @Query('range') dateRange: DateRange = 'max', + @Query('range') dateRange: DateRange = DEFAULT_DATE_RANGE, @Query('symbol') filterBySymbol?: string, @Query('tags') filterByTags?: string, @Query('withExcludedAccounts') withExcludedAccountsParam = 'false' diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index ade683d41..2a9639561 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -32,6 +32,7 @@ import { } from '@ghostfolio/common/calculation-helper'; import { DEFAULT_CURRENCY, + DEFAULT_DATE_RANGE, TAG_ID_EMERGENCY_FUND, TAG_ID_EXCLUDE_FROM_ANALYSIS, UNKNOWN_KEY @@ -470,7 +471,7 @@ export class PortfolioService { } public async getDetails({ - dateRange = 'max', + dateRange = DEFAULT_DATE_RANGE, filters, impersonationId, userId, @@ -1013,7 +1014,7 @@ export class PortfolioService { } public async getPerformance({ - dateRange = 'max', + dateRange = DEFAULT_DATE_RANGE, filters, impersonationId, userId diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 4ad22a043..4a0e1598b 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -26,6 +26,7 @@ import { PropertyService } from '@ghostfolio/api/services/property/property.serv import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { DEFAULT_CURRENCY, + DEFAULT_DATE_RANGE, DEFAULT_LANGUAGE_CODE, PROPERTY_IS_READ_ONLY_MODE, PROPERTY_SYSTEM_MESSAGE, @@ -281,7 +282,8 @@ export class UserService { (user.settings.settings as UserSettings).dateRange = (user.settings.settings as UserSettings).viewMode === 'ZEN' ? 'max' - : ((user.settings.settings as UserSettings)?.dateRange ?? 'max'); + : ((user.settings.settings as UserSettings)?.dateRange ?? + DEFAULT_DATE_RANGE); // Set default value for performance calculation type if (!(user.settings.settings as UserSettings)?.performanceCalculationType) { diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts index a429d9e64..d9b279040 100644 --- a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts @@ -1,6 +1,7 @@ import { GfInvestmentChartComponent } from '@ghostfolio/client/components/investment-chart/investment-chart.component'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { + DEFAULT_DATE_RANGE, DEFAULT_PAGE_SIZE, NUMERICAL_PRECISION_THRESHOLD_6_FIGURES } from '@ghostfolio/common/config'; @@ -330,7 +331,7 @@ export class GfAccountDetailDialogComponent implements OnInit { type: 'ACCOUNT' } ], - range: 'max', + range: DEFAULT_DATE_RANGE, withExcludedAccounts: true, withItems: true }) 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 0336cf169..ad3556536 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 @@ -2,7 +2,11 @@ import { GfPortfolioPerformanceComponent } from '@ghostfolio/client/components/p import { LayoutService } from '@ghostfolio/client/core/layout.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; -import { NUMERICAL_PRECISION_THRESHOLD_6_FIGURES } from '@ghostfolio/common/config'; +import { + DEFAULT_CURRENCY, + DEFAULT_DATE_RANGE, + NUMERICAL_PRECISION_THRESHOLD_6_FIGURES +} from '@ghostfolio/common/config'; import { AssetProfileIdentifier, LineChartItem, @@ -15,11 +19,13 @@ import { GfLineChartComponent } from '@ghostfolio/ui/line-chart'; import { DataService } from '@ghostfolio/ui/services'; import { - ChangeDetectorRef, + ChangeDetectionStrategy, Component, - CUSTOM_ELEMENTS_SCHEMA, + computed, DestroyRef, - OnInit + inject, + OnInit, + signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatButtonModule } from '@angular/material/button'; @@ -27,79 +33,80 @@ import { RouterModule } from '@angular/router'; import { DeviceDetectorService } from 'ngx-device-detector'; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ GfLineChartComponent, GfPortfolioPerformanceComponent, MatButtonModule, RouterModule ], - schemas: [CUSTOM_ELEMENTS_SCHEMA], selector: 'gf-home-overview', styleUrls: ['./home-overview.scss'], templateUrl: './home-overview.html' }) export class GfHomeOverviewComponent implements OnInit { - public deviceType: string; - public errors: AssetProfileIdentifier[]; - public hasError: boolean; - public hasImpersonationId: boolean; - public hasPermissionToCreateActivity: boolean; - public historicalDataItems: LineChartItem[]; - public isAllTimeHigh: boolean; - public isAllTimeLow: boolean; - public isLoadingPerformance = true; - public performance: PortfolioPerformance; - public performanceLabel = $localize`Performance`; - public precision = 2; - public routerLinkAccounts = internalRoutes.accounts.routerLink; - public routerLinkPortfolio = internalRoutes.portfolio.routerLink; - public routerLinkPortfolioActivities = + protected readonly errors = signal([]); + protected readonly hasImpersonationId = signal(false); + protected readonly historicalDataItems = signal(null); + protected readonly isLoadingPerformance = signal(true); + protected readonly performance = signal(null); + protected readonly performanceLabel = $localize`Performance`; + protected readonly precision = signal(2); + protected readonly user = signal(null); + + protected readonly routerLinkAccounts = internalRoutes.accounts.routerLink; + protected readonly routerLinkPortfolio = internalRoutes.portfolio.routerLink; + protected readonly routerLinkPortfolioActivities = internalRoutes.portfolio.subRoutes.activities.routerLink; - public showDetails = false; - public unit: string; - public user: User; - - public constructor( - private changeDetectorRef: ChangeDetectorRef, - private dataService: DataService, - private destroyRef: DestroyRef, - private deviceDetectorService: DeviceDetectorService, - private impersonationStorageService: ImpersonationStorageService, - private layoutService: LayoutService, - private userService: UserService - ) { + + protected readonly deviceType = computed( + () => this.deviceDetectorService.deviceInfo().deviceType + ); + + protected readonly hasPermissionToCreateActivity = computed(() => { + return hasPermission(this.user()?.permissions, permissions.createActivity); + }); + + protected readonly showDetails = computed(() => { + const user = this.user(); + + return user + ? !user.settings.isRestrictedView && user.settings.viewMode !== 'ZEN' + : false; + }); + + protected readonly unit = computed(() => { + return this.showDetails() + ? (this.user()?.settings?.baseCurrency ?? DEFAULT_CURRENCY) + : '%'; + }); + + private readonly dataService = inject(DataService); + private readonly destroyRef = inject(DestroyRef); + private readonly deviceDetectorService = inject(DeviceDetectorService); + private readonly impersonationStorageService = inject( + ImpersonationStorageService + ); + private readonly layoutService = inject(LayoutService); + private readonly userService = inject(UserService); + + public constructor() { this.userService.stateChanged .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((state) => { if (state?.user) { - this.user = state.user; - - this.hasPermissionToCreateActivity = hasPermission( - this.user.permissions, - permissions.createActivity - ); - + this.user.set(state.user); this.update(); } }); } public ngOnInit() { - this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; - - this.showDetails = - !this.user.settings.isRestrictedView && - this.user.settings.viewMode !== 'ZEN'; - - this.unit = this.showDetails ? this.user.settings.baseCurrency : '%'; - this.impersonationStorageService .onChangeHasImpersonation() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((impersonationId) => { - this.hasImpersonationId = !!impersonationId; - - this.changeDetectorRef.markForCheck(); + this.hasImpersonationId.set(!!impersonationId); }); this.layoutService.shouldReloadContent$ @@ -110,40 +117,40 @@ export class GfHomeOverviewComponent implements OnInit { } private update() { - this.historicalDataItems = null; - this.isLoadingPerformance = true; + this.historicalDataItems.set(null); + this.isLoadingPerformance.set(true); this.dataService .fetchPortfolioPerformance({ - range: this.user?.settings?.dateRange + range: this.user()?.settings?.dateRange ?? DEFAULT_DATE_RANGE }) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ chart, errors, performance }) => { - this.errors = errors; - this.performance = performance; - - this.historicalDataItems = chart.map( - ({ date, netPerformanceInPercentageWithCurrencyEffect }) => { - return { - date, - value: netPerformanceInPercentageWithCurrencyEffect * 100 - }; - } + this.errors.set(errors ?? []); + this.performance.set(performance); + + this.historicalDataItems.set( + chart?.map( + ({ date, netPerformanceInPercentageWithCurrencyEffect }) => { + return { + date, + value: (netPerformanceInPercentageWithCurrencyEffect ?? 0) * 100 + }; + } + ) ?? null ); + this.precision.set(2); + if ( - this.deviceType === 'mobile' && - this.performance.currentValueInBaseCurrency >= + this.deviceType() === 'mobile' && + performance.currentValueInBaseCurrency >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES ) { - this.precision = 0; + this.precision.set(0); } - this.isLoadingPerformance = false; - - this.changeDetectorRef.markForCheck(); + this.isLoadingPerformance.set(false); }); - - this.changeDetectorRef.markForCheck(); } } 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 b3b957d72..8361c5b88 100644 --- a/apps/client/src/app/components/home-overview/home-overview.html +++ b/apps/client/src/app/components/home-overview/home-overview.html @@ -2,16 +2,16 @@ class="align-items-center container d-flex flex-column h-100 justify-content-center overview p-0 position-relative" > @if ( - !hasImpersonationId && - hasPermissionToCreateActivity && - user?.activitiesCount === 0 + !hasImpersonationId() && + hasPermissionToCreateActivity() && + user()?.activitiesCount === 0 ) {
    diff --git a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html b/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html index 9db996fbf..e8bcfea0e 100644 --- a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html +++ b/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html @@ -24,10 +24,6 @@ }
    diff --git a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts b/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts index dd3ce248e..56e75ec1e 100644 --- a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts +++ b/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts @@ -35,8 +35,6 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; export class GfPortfolioPerformanceComponent implements OnChanges { @Input() deviceType: string; @Input() errors: ResponseError['errors']; - @Input() isAllTimeHigh: boolean; - @Input() isAllTimeLow: boolean; @Input() isLoading: boolean; @Input() locale = getLocale(); @Input() performance: PortfolioPerformance; diff --git a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts index 7796939fd..42260d648 100644 --- a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts @@ -1,5 +1,6 @@ import { GfFileDropDirective } from '@ghostfolio/client/directives/file-drop/file-drop.directive'; import { ImportActivitiesService } from '@ghostfolio/client/services/import-activities.service'; +import { DEFAULT_DATE_RANGE } from '@ghostfolio/common/config'; import { CreateAccountWithBalancesDto, CreateAssetProfileWithMarketDataDto, @@ -145,7 +146,7 @@ export class GfImportActivitiesDialogComponent { type: 'ASSET_CLASS' } ], - range: 'max' + range: DEFAULT_DATE_RANGE }) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ holdings }) => { diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index 9d6122c6c..113dffe4a 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -2,7 +2,7 @@ import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client'; import { JobOptions, JobStatus } from 'bull'; import ms from 'ms'; -import { ColorScheme } from './types'; +import { ColorScheme, DateRange } from './types'; export const ghostfolioPrefix = 'GF'; export const ghostfolioScraperApiSymbolPrefix = `_${ghostfolioPrefix}_`; @@ -82,6 +82,7 @@ export const STATISTICS_GATHERING_QUEUE = 'STATISTICS_GATHERING_QUEUE'; export const DEFAULT_COLOR_SCHEME: ColorScheme = 'LIGHT'; export const DEFAULT_CURRENCY = 'USD'; export const DEFAULT_DATE_FORMAT_MONTH_YEAR = 'MMM yyyy'; +export const DEFAULT_DATE_RANGE: DateRange = 'max'; export const DEFAULT_HOST = '0.0.0.0'; export const DEFAULT_LANGUAGE_CODE = 'en'; export const DEFAULT_PAGE_SIZE = 50; From 7f76bcd2bf9bf14c7e941050888b370c372c7b11 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 23 May 2026 21:06:36 +0200 Subject: [PATCH 103/132] Task/configure queues to keep completed jobs (#6931) * Configure removeOnComplete * Update changelog --- CHANGELOG.md | 1 + libs/common/src/lib/config.ts | 20 ++++++++++++++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f19cdf66..f911a88e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Configured the queues to keep the last `5000` completed jobs with a maximum age of one week - Removed the deprecated attributes (`assetClass`, `countries`, `currency`, `dataSource`, `name`, `sectors`, `symbol` and `url`) from the holdings of the public portfolio endpoint response - Upgraded `@keyv/redis` from version `4.4.0` to `5.1.6` diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index 113dffe4a..33118296f 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -175,7 +175,10 @@ export const GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS: JobOptions = { delay: ms('1 minute'), type: 'exponential' }, - removeOnComplete: true + removeOnComplete: { + age: ms('1 week') / 1000, + count: 5000 + } }; export const GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME = @@ -186,7 +189,10 @@ export const GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_OPTIONS: JobOptions = { delay: ms('1 minute'), type: 'exponential' }, - removeOnComplete: true + removeOnComplete: { + age: ms('1 week') / 1000, + count: 5000 + } }; export const GATHER_STATISTICS_PROCESS_JOB_OPTIONS: JobOptions = { @@ -195,7 +201,10 @@ export const GATHER_STATISTICS_PROCESS_JOB_OPTIONS: JobOptions = { delay: ms('1 minute'), type: 'exponential' }, - removeOnComplete: true + removeOnComplete: { + age: ms('1 week') / 1000, + count: 5000 + } }; export const GATHER_STATISTICS_DOCKER_HUB_PULLS_PROCESS_JOB_NAME = @@ -218,7 +227,10 @@ export const INVESTMENT_ACTIVITY_TYPES = [ export const PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME = 'PORTFOLIO'; export const PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS: JobOptions = { - removeOnComplete: true + removeOnComplete: { + age: ms('1 week') / 1000, + count: 5000 + } }; export const HEADER_KEY_IMPERSONATION = 'Impersonation-Id'; From 1a1735f69bb8dcfc59c257a8fe9dc09593e43e97 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 24 May 2026 08:38:41 +0200 Subject: [PATCH 104/132] Task/remove deprecated attributes from holdings of public portfolio endpoint response (part 2) (#6932) Remove deprecated attributes --- .../app/endpoints/public/public.controller.ts | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/apps/api/src/app/endpoints/public/public.controller.ts b/apps/api/src/app/endpoints/public/public.controller.ts index 0488da326..08e49704b 100644 --- a/apps/api/src/app/endpoints/public/public.controller.ts +++ b/apps/api/src/app/endpoints/public/public.controller.ts @@ -21,7 +21,11 @@ import { UseInterceptors } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; -import { Type as ActivityType } from '@prisma/client'; +import { + AssetClass, + AssetSubClass, + Type as ActivityType +} from '@prisma/client'; import { Big } from 'big.js'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; @@ -169,13 +173,29 @@ export class PublicController { portfolioPosition.valueInBaseCurrency / totalValue, assetProfile: { ...portfolioPosition.assetProfile, + assetClass: + hasDetails || + portfolioPosition.assetProfile.assetClass === AssetClass.LIQUIDITY + ? portfolioPosition.assetProfile.assetClass + : undefined, + assetClassLabel: + hasDetails || + portfolioPosition.assetProfile.assetClass === AssetClass.LIQUIDITY + ? portfolioPosition.assetProfile.assetClassLabel + : undefined, + assetSubClass: + hasDetails || + portfolioPosition.assetProfile.assetSubClass === AssetSubClass.CASH + ? portfolioPosition.assetProfile.assetSubClass + : undefined, + assetSubClassLabel: + hasDetails || + portfolioPosition.assetProfile.assetSubClass === AssetSubClass.CASH + ? portfolioPosition.assetProfile.assetSubClassLabel + : undefined, ...(hasDetails ? {} : { - assetClass: undefined, - assetClassLabel: undefined, - assetSubClass: undefined, - assetSubClassLabel: undefined, countries: [], currency: undefined, holdings: [], From 9d7e3dfe18faf4a8157484769ef2b15090c1b9ee Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 24 May 2026 08:39:12 +0200 Subject: [PATCH 105/132] Bugfix/missing cash positions caused by incorrect data source (#6933) * Change incorrect data source * Update changelog --- CHANGELOG.md | 1 + apps/api/src/app/portfolio/portfolio.service.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f911a88e9..196e232aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fixed a layout regression that caused a double scrollbar on pages without tabs +- Resolved an issue with missing cash positions caused by an incorrect data source ## 3.4.0 - 2026-05-21 diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 2a9639561..cee36ec27 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -1612,7 +1612,7 @@ export class PortfolioService { assetSubClass: AssetSubClass.CASH, countries: [], createdAt: account.createdAt, - dataSource: DataSource.MANUAL, + dataSource: this.dataProviderService.getDataSourceForExchangeRates(), holdings: [], id: currency, isActive: true, From 1cc91aedfa8417f5326c41a4354843c160eb7693 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 24 May 2026 08:47:45 +0200 Subject: [PATCH 106/132] Revert "Task/configure queues to keep completed jobs (#6931)" (#6936) This reverts commit 7f76bcd2bf9bf14c7e941050888b370c372c7b11. --- CHANGELOG.md | 1 - libs/common/src/lib/config.ts | 20 ++++---------------- 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 196e232aa..6a8418717 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Configured the queues to keep the last `5000` completed jobs with a maximum age of one week - Removed the deprecated attributes (`assetClass`, `countries`, `currency`, `dataSource`, `name`, `sectors`, `symbol` and `url`) from the holdings of the public portfolio endpoint response - Upgraded `@keyv/redis` from version `4.4.0` to `5.1.6` diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index 33118296f..113dffe4a 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -175,10 +175,7 @@ export const GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS: JobOptions = { delay: ms('1 minute'), type: 'exponential' }, - removeOnComplete: { - age: ms('1 week') / 1000, - count: 5000 - } + removeOnComplete: true }; export const GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME = @@ -189,10 +186,7 @@ export const GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_OPTIONS: JobOptions = { delay: ms('1 minute'), type: 'exponential' }, - removeOnComplete: { - age: ms('1 week') / 1000, - count: 5000 - } + removeOnComplete: true }; export const GATHER_STATISTICS_PROCESS_JOB_OPTIONS: JobOptions = { @@ -201,10 +195,7 @@ export const GATHER_STATISTICS_PROCESS_JOB_OPTIONS: JobOptions = { delay: ms('1 minute'), type: 'exponential' }, - removeOnComplete: { - age: ms('1 week') / 1000, - count: 5000 - } + removeOnComplete: true }; export const GATHER_STATISTICS_DOCKER_HUB_PULLS_PROCESS_JOB_NAME = @@ -227,10 +218,7 @@ export const INVESTMENT_ACTIVITY_TYPES = [ export const PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME = 'PORTFOLIO'; export const PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS: JobOptions = { - removeOnComplete: { - age: ms('1 week') / 1000, - count: 5000 - } + removeOnComplete: true }; export const HEADER_KEY_IMPERSONATION = 'Impersonation-Id'; From f4af9edeb533e4a6837d92b89d6142650c755360 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 24 May 2026 08:48:25 +0200 Subject: [PATCH 107/132] Task/remove deprecated order endpoints (#6928) * Remove deprecated order endpoints * Update changelog --- CHANGELOG.md | 1 + apps/api/src/app/activities/activities.controller.ts | 6 +----- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a8418717..e82a2cf76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Removed the deprecated attributes (`assetClass`, `countries`, `currency`, `dataSource`, `name`, `sectors`, `symbol` and `url`) from the holdings of the public portfolio endpoint response +- Removed the deprecated `api/v1/order` endpoints - Upgraded `@keyv/redis` from version `4.4.0` to `5.1.6` ### Fixed diff --git a/apps/api/src/app/activities/activities.controller.ts b/apps/api/src/app/activities/activities.controller.ts index 6b0440dc4..e847f600d 100644 --- a/apps/api/src/app/activities/activities.controller.ts +++ b/apps/api/src/app/activities/activities.controller.ts @@ -43,11 +43,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { ActivitiesService } from './activities.service'; -@Controller([ - 'activities', - /** @deprecated */ - 'order' -]) +@Controller('activities') export class ActivitiesController { public constructor( private readonly activitiesService: ActivitiesService, From 7cd3999b43f19f352e5f4d824c44890afed6a33b Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 24 May 2026 08:52:29 +0200 Subject: [PATCH 108/132] Release 3.5.0 (#6937) --- CHANGELOG.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e82a2cf76..3a327edf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## Unreleased +## 3.5.0 - 2026-05-24 ### Added diff --git a/package-lock.json b/package-lock.json index 9034b3eb6..02044ceb5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ghostfolio", - "version": "3.4.0", + "version": "3.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ghostfolio", - "version": "3.4.0", + "version": "3.5.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/package.json b/package.json index 5bc9e7971..88750699e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostfolio", - "version": "3.4.0", + "version": "3.5.0", "homepage": "https://ghostfol.io", "license": "AGPL-3.0", "repository": "https://github.com/ghostfolio/ghostfolio", From 0e15d148613702ca3303d973a274d905fa1bc2fd Mon Sep 17 00:00:00 2001 From: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com> Date: Sun, 24 May 2026 13:58:49 +0700 Subject: [PATCH 109/132] Task/improve type safety in user account access component (#6934) * feat(client): resolve errors * feat(client): replace constructor based DI with inject function * feat(client): enforce encapsulation * feat(client): enforce immutability * feat(client): replace deprecated getDeviceInfo * fix(client): use AccessPermission enum to replace hard coded string --- ...reate-or-update-access-dialog.component.ts | 35 ++++--- .../interfaces/interfaces.ts | 2 +- .../user-account-access.component.ts | 93 ++++++++++--------- 3 files changed, 73 insertions(+), 57 deletions(-) diff --git a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts index 97bd272d4..51bc76502 100644 --- a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts +++ b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts @@ -29,6 +29,7 @@ import { import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; +import { AccessPermission } from '@prisma/client'; import { StatusCodes } from 'http-status-codes'; import { EMPTY, catchError } from 'rxjs'; @@ -52,7 +53,7 @@ import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces'; }) export class GfCreateOrUpdateAccessDialogComponent implements OnInit { protected accessForm: FormGroup; - protected mode: 'create' | 'update'; + protected readonly mode: 'create' | 'update'; private readonly changeDetectorRef = inject(ChangeDetectorRef); @@ -69,21 +70,25 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { private readonly notificationService = inject(NotificationService); public constructor() { - this.mode = this.data.access?.id ? 'update' : 'create'; + this.mode = this.data.access ? 'update' : 'create'; } public ngOnInit() { - const isPublic = this.data.access.type === 'PUBLIC'; + const access = this.data?.access; + const isPublic = access?.type === 'PUBLIC'; this.accessForm = this.formBuilder.group({ - alias: [this.data.access.alias], + alias: [access?.alias ?? ''], granteeUserId: [ - this.data.access.grantee, + access?.grantee ?? null, isPublic ? null : Validators.required ], - permissions: [this.data.access.permissions[0], Validators.required], + permissions: [ + access?.permissions[0] ?? AccessPermission.READ_RESTRICTED, + Validators.required + ], type: [ - { disabled: this.mode === 'update', value: this.data.access.type }, + { disabled: this.mode === 'update', value: access?.type ?? 'PRIVATE' }, Validators.required ] }); @@ -100,7 +105,9 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { } else { granteeUserIdControl?.clearValidators(); granteeUserIdControl?.setValue(null); - permissionsControl?.setValue(this.data.access.permissions[0]); + permissionsControl?.setValue( + access?.permissions[0] ?? AccessPermission.READ_RESTRICTED + ); } granteeUserIdControl?.updateValueAndValidity(); @@ -109,11 +116,11 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { }); } - public onCancel() { + protected onCancel() { this.dialogRef.close(); } - public async onSubmit() { + protected async onSubmit() { if (this.mode === 'create') { await this.createAccess(); } else { @@ -158,10 +165,16 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { } private async updateAccess() { + const accessId = this.data.access?.id; + + if (!accessId) { + return; + } + const access: UpdateAccessDto = { alias: this.accessForm.get('alias')?.value, granteeUserId: this.accessForm.get('granteeUserId')?.value, - id: this.data.access.id, + id: accessId, permissions: [this.accessForm.get('permissions')?.value] }; diff --git a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/interfaces/interfaces.ts b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/interfaces/interfaces.ts index b7850fb38..8d1ac0ba9 100644 --- a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/interfaces/interfaces.ts +++ b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/interfaces/interfaces.ts @@ -1,5 +1,5 @@ import { Access } from '@ghostfolio/common/interfaces'; export interface CreateOrUpdateAccessDialogParams { - access: Access; + access?: Access; } diff --git a/apps/client/src/app/components/user-account-access/user-account-access.component.ts b/apps/client/src/app/components/user-account-access/user-account-access.component.ts index e6686e80f..985dba2cb 100644 --- a/apps/client/src/app/components/user-account-access/user-account-access.component.ts +++ b/apps/client/src/app/components/user-account-access/user-account-access.component.ts @@ -12,12 +12,19 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, + computed, CUSTOM_ELEMENTS_SCHEMA, DestroyRef, + inject, OnInit } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { + FormControl, + FormGroup, + ReactiveFormsModule, + Validators +} from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatDialog, MatDialogModule } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; @@ -53,30 +60,35 @@ import { CreateOrUpdateAccessDialogParams } from './create-or-update-access-dial templateUrl: './user-account-access.html' }) export class GfUserAccountAccessComponent implements OnInit { - public accessesGet: Access[]; - public accessesGive: Access[]; - public deviceType: string; - public hasPermissionToCreateAccess: boolean; - public hasPermissionToDeleteAccess: boolean; - public hasPermissionToUpdateOwnAccessToken: boolean; - public isAccessTokenHidden = true; - public updateOwnAccessTokenForm = this.formBuilder.group({ - accessToken: ['', Validators.required] + protected accessesGet: Access[]; + protected accessesGive: Access[]; + protected hasPermissionToCreateAccess: boolean; + protected hasPermissionToDeleteAccess: boolean; + protected hasPermissionToUpdateOwnAccessToken: boolean; + protected isAccessTokenHidden = true; + protected readonly updateOwnAccessTokenForm = new FormGroup({ + accessToken: new FormControl('', { + nonNullable: true, + validators: [Validators.required] + }) }); - public user: User; + protected user: User; + + private readonly deviceType = computed( + () => this.deviceDetectorService.deviceInfo().deviceType + ); + + private readonly changeDetectorRef = inject(ChangeDetectorRef); + private readonly dataService = inject(DataService); + private readonly destroyRef = inject(DestroyRef); + private readonly deviceDetectorService = inject(DeviceDetectorService); + private readonly dialog = inject(MatDialog); + private readonly notificationService = inject(NotificationService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly userService = inject(UserService); - public constructor( - private changeDetectorRef: ChangeDetectorRef, - private dataService: DataService, - private destroyRef: DestroyRef, - private deviceDetectorService: DeviceDetectorService, - private dialog: MatDialog, - private formBuilder: FormBuilder, - private notificationService: NotificationService, - private route: ActivatedRoute, - private router: Router, - private userService: UserService - ) { + public constructor() { const { globalPermissions } = this.dataService.fetchInfo(); this.hasPermissionToDeleteAccess = hasPermission( @@ -123,12 +135,10 @@ export class GfUserAccountAccessComponent implements OnInit { } public ngOnInit() { - this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; - this.update(); } - public onDeleteAccess(aId: string) { + protected onDeleteAccess(aId: string) { this.dataService .deleteAccess(aId) .pipe(takeUntilDestroyed(this.destroyRef)) @@ -139,12 +149,13 @@ export class GfUserAccountAccessComponent implements OnInit { }); } - public onGenerateAccessToken() { + protected onGenerateAccessToken() { this.notificationService.confirm({ confirmFn: () => { this.dataService .updateOwnAccessToken({ - accessToken: this.updateOwnAccessTokenForm.get('accessToken').value + accessToken: + this.updateOwnAccessTokenForm.controls.accessToken.value }) .pipe( catchError(() => { @@ -173,7 +184,7 @@ export class GfUserAccountAccessComponent implements OnInit { }); } - public onUpdateAccess(aId: string) { + protected onUpdateAccess(aId: string) { this.router.navigate([], { queryParams: { accessId: aId, editDialog: true } }); @@ -184,17 +195,9 @@ export class GfUserAccountAccessComponent implements OnInit { GfCreateOrUpdateAccessDialogComponent, CreateOrUpdateAccessDialogParams >(GfCreateOrUpdateAccessDialogComponent, { - data: { - access: { - alias: '', - grantee: null, - id: null, - permissions: ['READ_RESTRICTED'], - type: 'PRIVATE' - } - }, - height: this.deviceType === 'mobile' ? '98vh' : undefined, - width: this.deviceType === 'mobile' ? '100vw' : '50rem' + data: {} satisfies CreateOrUpdateAccessDialogParams, + height: this.deviceType() === 'mobile' ? '98vh' : undefined, + width: this.deviceType() === 'mobile' ? '100vw' : '50rem' }); dialogRef.afterClosed().subscribe((access: CreateAccessDto | null) => { @@ -222,14 +225,14 @@ export class GfUserAccountAccessComponent implements OnInit { data: { access: { alias: access.alias, - grantee: access.grantee === 'Public' ? null : access.grantee, + grantee: access.grantee === 'Public' ? undefined : access.grantee, id: access.id, permissions: access.permissions, type: access.type } - }, - height: this.deviceType === 'mobile' ? '98vh' : undefined, - width: this.deviceType === 'mobile' ? '100vw' : '50rem' + } satisfies CreateOrUpdateAccessDialogParams, + height: this.deviceType() === 'mobile' ? '98vh' : undefined, + width: this.deviceType() === 'mobile' ? '100vw' : '50rem' }); dialogRef.afterClosed().subscribe((result) => { @@ -244,9 +247,9 @@ export class GfUserAccountAccessComponent implements OnInit { private update() { this.accessesGet = this.user.access.map(({ alias, id, permissions }) => { return { - alias, id, permissions, + alias: alias ?? '', grantee: $localize`Me`, type: 'PRIVATE' }; From dcbd9225daf151bfa886075ea99e84a7bad0c8d3 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 24 May 2026 09:58:26 +0200 Subject: [PATCH 110/132] Task/improve promises in patchAssetProfileData() of admin service (#6938) Improve promises in patchAssetProfileData() --- apps/api/src/app/admin/admin.service.ts | 36 ++++++++++++++----------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/apps/api/src/app/admin/admin.service.ts b/apps/api/src/app/admin/admin.service.ts index ba338b5b9..0bf5c3925 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -576,8 +576,8 @@ export class AdminService { } try { - Promise.all([ - await this.symbolProfileService.updateAssetProfileIdentifier( + await Promise.all([ + this.symbolProfileService.updateAssetProfileIdentifier( { dataSource, symbol @@ -587,7 +587,7 @@ export class AdminService { symbol: newSymbol as string } ), - await this.marketDataService.updateAssetProfileIdentifier( + this.marketDataService.updateAssetProfileIdentifier( { dataSource, symbol @@ -599,12 +599,15 @@ export class AdminService { ) ]); - return this.symbolProfileService.getSymbolProfiles([ - { - dataSource: DataSource[newDataSource.toString()], - symbol: newSymbol as string - } - ])?.[0]; + const [updatedAssetProfile] = + await this.symbolProfileService.getSymbolProfiles([ + { + dataSource: DataSource[newDataSource.toString()], + symbol: newSymbol as string + } + ]); + + return updatedAssetProfile; } catch { throw new HttpException( getReasonPhrase(StatusCodes.BAD_REQUEST), @@ -650,12 +653,15 @@ export class AdminService { updatedSymbolProfile ); - return this.symbolProfileService.getSymbolProfiles([ - { - dataSource: dataSource as DataSource, - symbol: symbol as string - } - ])?.[0]; + const [updatedAssetProfile] = + await this.symbolProfileService.getSymbolProfiles([ + { + dataSource: dataSource as DataSource, + symbol: symbol as string + } + ]); + + return updatedAssetProfile; } } From 5689326b12962c0247fdf48bc8717379f6e69805 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 24 May 2026 17:47:07 +0200 Subject: [PATCH 111/132] Task/refactor asset profile deletion permissions (#6940) Refactor permissions --- .../admin-market-data.component.ts | 7 ++++- .../admin-market-data/admin-market-data.html | 4 +-- .../admin-market-data.service.ts | 30 +------------------ .../asset-profile-dialog.component.ts | 2 ++ .../asset-profile-dialog.html | 2 +- libs/common/src/lib/helper.ts | 30 ++++++++++++++++++- 6 files changed, 41 insertions(+), 34 deletions(-) diff --git a/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts b/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts index 72a3c337a..28b7297d2 100644 --- a/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts +++ b/apps/client/src/app/components/admin-market-data/admin-market-data.component.ts @@ -4,7 +4,10 @@ import { DEFAULT_PAGE_SIZE, locale } from '@ghostfolio/common/config'; -import { getDateFormatString } from '@ghostfolio/common/helper'; +import { + canDeleteAssetProfile, + getDateFormatString +} from '@ghostfolio/common/helper'; import { AssetProfileIdentifier, Filter, @@ -101,6 +104,7 @@ import { CreateAssetProfileDialogParams } from './create-asset-profile-dialog/in }) export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { protected readonly adminMarketDataService = inject(AdminMarketDataService); + protected readonly allFilters: Filter[] = [ ...Object.keys(AssetSubClass) .filter((assetSubClass) => { @@ -146,6 +150,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit { type: 'PRESET_ID' as Filter['type'] } ]; + protected readonly canDeleteAssetProfile = canDeleteAssetProfile; protected dataSource = new MatTableDataSource(); protected defaultDateFormat: string; protected readonly displayedColumns: string[] = []; diff --git a/apps/client/src/app/components/admin-market-data/admin-market-data.html b/apps/client/src/app/components/admin-market-data/admin-market-data.html index e2c6d1a87..14d12627d 100644 --- a/apps/client/src/app/components/admin-market-data/admin-market-data.html +++ b/apps/client/src/app/components/admin-market-data/admin-market-data.html @@ -52,7 +52,7 @@ @if ( - adminMarketDataService.hasPermissionToDeleteAssetProfile({ + canDeleteAssetProfile({ activitiesCount: element.activitiesCount, isBenchmark: element.isBenchmark, symbol: element.symbol, @@ -271,7 +271,7 @@
    - +
    diff --git a/apps/client/src/app/components/home-watchlist/home-watchlist.component.ts b/apps/client/src/app/components/home-watchlist/home-watchlist.component.ts index 7deace7de..22d829daa 100644 --- a/apps/client/src/app/components/home-watchlist/home-watchlist.component.ts +++ b/apps/client/src/app/components/home-watchlist/home-watchlist.component.ts @@ -8,6 +8,7 @@ import { } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark'; +import { GfFabComponent } from '@ghostfolio/ui/fab'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { DataService } from '@ghostfolio/ui/services'; @@ -22,12 +23,8 @@ import { OnInit } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { MatButtonModule } from '@angular/material/button'; import { MatDialog } from '@angular/material/dialog'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; -import { IonIcon } from '@ionic/angular/standalone'; -import { addIcons } from 'ionicons'; -import { addOutline } from 'ionicons/icons'; import { DeviceDetectorService } from 'ngx-device-detector'; import { GfCreateWatchlistItemDialogComponent } from './create-watchlist-item-dialog/create-watchlist-item-dialog.component'; @@ -37,9 +34,8 @@ import { CreateWatchlistItemDialogParams } from './create-watchlist-item-dialog/ changeDetection: ChangeDetectionStrategy.OnPush, imports: [ GfBenchmarkComponent, + GfFabComponent, GfPremiumIndicatorComponent, - IonIcon, - MatButtonModule, RouterModule ], schemas: [CUSTOM_ELEMENTS_SCHEMA], @@ -108,8 +104,6 @@ export class GfHomeWatchlistComponent implements OnInit { this.changeDetectorRef.markForCheck(); } }); - - addIcons({ addOutline }); } public ngOnInit() { diff --git a/apps/client/src/app/components/home-watchlist/home-watchlist.html b/apps/client/src/app/components/home-watchlist/home-watchlist.html index c7c9a9c4b..e2865b9cf 100644 --- a/apps/client/src/app/components/home-watchlist/home-watchlist.html +++ b/apps/client/src/app/components/home-watchlist/home-watchlist.html @@ -22,15 +22,5 @@
    @if (!hasImpersonationId && hasPermissionToCreateWatchlistItem) { -
    - - - -
    + } diff --git a/apps/client/src/app/components/user-account-access/user-account-access.component.ts b/apps/client/src/app/components/user-account-access/user-account-access.component.ts index 985dba2cb..eef50cee3 100644 --- a/apps/client/src/app/components/user-account-access/user-account-access.component.ts +++ b/apps/client/src/app/components/user-account-access/user-account-access.component.ts @@ -4,6 +4,7 @@ import { CreateAccessDto } from '@ghostfolio/common/dtos'; import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { Access, User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import { GfFabComponent } from '@ghostfolio/ui/fab'; import { NotificationService } from '@ghostfolio/ui/notifications'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { DataService } from '@ghostfolio/ui/services'; @@ -42,9 +43,9 @@ import { CreateOrUpdateAccessDialogParams } from './create-or-update-access-dial @Component({ changeDetection: ChangeDetectionStrategy.OnPush, - host: { class: 'has-fab' }, imports: [ GfAccessTableComponent, + GfFabComponent, GfPremiumIndicatorComponent, IonIcon, MatButtonModule, diff --git a/apps/client/src/app/components/user-account-access/user-account-access.html b/apps/client/src/app/components/user-account-access/user-account-access.html index 412a2f8d2..62b1648bb 100644 --- a/apps/client/src/app/components/user-account-access/user-account-access.html +++ b/apps/client/src/app/components/user-account-access/user-account-access.html @@ -69,16 +69,6 @@ (accessToUpdate)="onUpdateAccess($event)" /> @if (hasPermissionToCreateAccess) { -
    - - - -
    + }
    diff --git a/apps/client/src/app/pages/accounts/accounts-page.component.ts b/apps/client/src/app/pages/accounts/accounts-page.component.ts index 08513ef3e..cca1eda03 100644 --- a/apps/client/src/app/pages/accounts/accounts-page.component.ts +++ b/apps/client/src/app/pages/accounts/accounts-page.component.ts @@ -10,6 +10,7 @@ import { import { User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { GfAccountsTableComponent } from '@ghostfolio/ui/accounts-table'; +import { GfFabComponent } from '@ghostfolio/ui/fab'; import { NotificationService } from '@ghostfolio/ui/notifications'; import { DataService } from '@ghostfolio/ui/services'; @@ -20,12 +21,9 @@ import { OnInit } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { MatButtonModule } from '@angular/material/button'; import { MatDialog } from '@angular/material/dialog'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { Account as AccountModel } from '@prisma/client'; -import { addIcons } from 'ionicons'; -import { addOutline } from 'ionicons/icons'; import { DeviceDetectorService } from 'ngx-device-detector'; import { EMPTY, Subscription } from 'rxjs'; import { catchError } from 'rxjs/operators'; @@ -36,8 +34,8 @@ import { TransferBalanceDialogParams } from './transfer-balance/interfaces/inter import { GfTransferBalanceDialogComponent } from './transfer-balance/transfer-balance-dialog.component'; @Component({ - host: { class: 'has-fab page' }, - imports: [GfAccountsTableComponent, MatButtonModule, RouterModule], + host: { class: 'page' }, + imports: [GfAccountsTableComponent, GfFabComponent, RouterModule], selector: 'gf-accounts-page', styleUrls: ['./accounts-page.scss'], templateUrl: './accounts-page.html' @@ -90,8 +88,6 @@ export class GfAccountsPageComponent implements OnInit { this.openTransferBalanceDialog(); } }); - - addIcons({ addOutline }); } public ngOnInit() { diff --git a/apps/client/src/app/pages/accounts/accounts-page.html b/apps/client/src/app/pages/accounts/accounts-page.html index 3d9d7ee5c..1bdedbbb9 100644 --- a/apps/client/src/app/pages/accounts/accounts-page.html +++ b/apps/client/src/app/pages/accounts/accounts-page.html @@ -26,16 +26,6 @@ hasPermissionToCreateAccount && !user.settings.isRestrictedView ) { -
    - - - -
    + }
  • diff --git a/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts b/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts index e43af52c9..41ff570c2 100644 --- a/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts @@ -12,6 +12,7 @@ import { import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { DateRange } from '@ghostfolio/common/types'; import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; +import { GfFabComponent } from '@ghostfolio/ui/fab'; import { DataService } from '@ghostfolio/ui/services'; import { @@ -21,17 +22,13 @@ import { OnInit } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { MatButtonModule } from '@angular/material/button'; import { MatDialog } from '@angular/material/dialog'; import { PageEvent } from '@angular/material/paginator'; import { MatSnackBarModule } from '@angular/material/snack-bar'; import { Sort, SortDirection } from '@angular/material/sort'; import { MatTableDataSource } from '@angular/material/table'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; -import { IonIcon } from '@ionic/angular/standalone'; import { format, parseISO } from 'date-fns'; -import { addIcons } from 'ionicons'; -import { addOutline } from 'ionicons/icons'; import { DeviceDetectorService } from 'ngx-device-detector'; import { Subscription } from 'rxjs'; @@ -41,11 +38,9 @@ import { GfImportActivitiesDialogComponent } from './import-activities-dialog/im import { ImportActivitiesDialogParams } from './import-activities-dialog/interfaces/interfaces'; @Component({ - host: { class: 'has-fab' }, imports: [ GfActivitiesTableComponent, - IonIcon, - MatButtonModule, + GfFabComponent, MatSnackBarModule, RouterModule ], @@ -107,8 +102,6 @@ export class GfActivitiesPageComponent implements OnInit { } } }); - - addIcons({ addOutline }); } public ngOnInit() { diff --git a/apps/client/src/app/pages/portfolio/activities/activities-page.html b/apps/client/src/app/pages/portfolio/activities/activities-page.html index 2a72dcfd2..f06947988 100644 --- a/apps/client/src/app/pages/portfolio/activities/activities-page.html +++ b/apps/client/src/app/pages/portfolio/activities/activities-page.html @@ -43,16 +43,6 @@ hasPermissionToCreateActivity && !user.settings.isRestrictedView ) { -
    - - - -
    + }
    diff --git a/apps/client/src/styles.scss b/apps/client/src/styles.scss index 1eb5bd2dd..4321622a1 100644 --- a/apps/client/src/styles.scss +++ b/apps/client/src/styles.scss @@ -360,10 +360,6 @@ ngx-skeleton-loader { text-wrap: balance; } -.has-fab { - padding-bottom: 3rem !important; -} - .has-info-message { // Restrict viewport height of tabbed views when the Live Demo or system announcements banner are displayed .page:has(gf-page-tabs) { @@ -484,13 +480,6 @@ ngx-skeleton-loader { padding-bottom: env(safe-area-inset-bottom); padding-bottom: constant(safe-area-inset-bottom); - .fab-container { - bottom: 2rem; - position: fixed; - right: 2rem; - z-index: 999; - } - // Restrict viewport height and layout boundaries only when the page hosts tab navigation &:has(gf-page-tabs) { height: calc(100svh - var(--mat-toolbar-standard-height)); diff --git a/libs/ui/src/lib/fab/fab.component.html b/libs/ui/src/lib/fab/fab.component.html new file mode 100644 index 000000000..021bc5f79 --- /dev/null +++ b/libs/ui/src/lib/fab/fab.component.html @@ -0,0 +1,9 @@ + + + diff --git a/libs/ui/src/lib/fab/fab.component.scss b/libs/ui/src/lib/fab/fab.component.scss new file mode 100644 index 000000000..ab6353981 --- /dev/null +++ b/libs/ui/src/lib/fab/fab.component.scss @@ -0,0 +1,14 @@ +:host { + bottom: calc(constant(safe-area-inset-bottom) + 2rem); + bottom: calc(env(safe-area-inset-bottom) + 2rem); + position: fixed; + right: 2rem; + z-index: 999; +} + +:host-context(gf-page-tabs) { + @media (max-width: 575.98px) { + bottom: calc(constant(safe-area-inset-bottom) + 5rem); + bottom: calc(env(safe-area-inset-bottom) + 5rem); + } +} diff --git a/libs/ui/src/lib/fab/fab.component.ts b/libs/ui/src/lib/fab/fab.component.ts new file mode 100644 index 000000000..20972d5a6 --- /dev/null +++ b/libs/ui/src/lib/fab/fab.component.ts @@ -0,0 +1,21 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { Params, RouterModule } from '@angular/router'; +import { IonIcon } from '@ionic/angular/standalone'; +import { addIcons } from 'ionicons'; +import { addOutline } from 'ionicons/icons'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [IonIcon, MatButtonModule, RouterModule], + selector: 'gf-fab', + styleUrls: ['./fab.component.scss'], + templateUrl: './fab.component.html' +}) +export class GfFabComponent { + public readonly queryParams = input.required(); + + public constructor() { + addIcons({ addOutline }); + } +} diff --git a/libs/ui/src/lib/fab/index.ts b/libs/ui/src/lib/fab/index.ts new file mode 100644 index 000000000..d03295245 --- /dev/null +++ b/libs/ui/src/lib/fab/index.ts @@ -0,0 +1 @@ +export * from './fab.component'; diff --git a/libs/ui/src/lib/page-tabs/page-tabs.component.scss b/libs/ui/src/lib/page-tabs/page-tabs.component.scss index 920b00ae9..0b377e57a 100644 --- a/libs/ui/src/lib/page-tabs/page-tabs.component.scss +++ b/libs/ui/src/lib/page-tabs/page-tabs.component.scss @@ -15,12 +15,6 @@ ); ::ng-deep { - .fab-container { - @media (max-width: 575.98px) { - bottom: 5rem; - } - } - .mat-mdc-tab-nav-panel { padding: 2rem 0; From 5bf04c48f2dc8e6c6dda66f9fb5d2f2376ef1169 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 28 May 2026 17:38:57 +0200 Subject: [PATCH 117/132] Task/upgrade yahoo-finance2 to version 3.14.2 (#6955) * Upgrade yahoo-finance2 to version 3.14.2 * Update changelog --- CHANGELOG.md | 1 + package-lock.json | 8 ++++---- package.json | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fafe92dbf..a7817256f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Extracted the floating action buttons (FAB) to a reusable component - Upgraded `nestjs` from version `11.1.19` to `11.1.21` +- Upgraded `yahoo-finance2` from version `3.14.0` to `3.14.2` ## 3.5.0 - 2026-05-24 diff --git a/package-lock.json b/package-lock.json index f8e778549..7a06ea1b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -94,7 +94,7 @@ "svgmap": "2.19.3", "tablemark": "4.1.0", "twitter-api-v2": "1.29.0", - "yahoo-finance2": "3.14.0", + "yahoo-finance2": "3.14.2", "zone.js": "0.16.1" }, "devDependencies": { @@ -39885,9 +39885,9 @@ } }, "node_modules/yahoo-finance2": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/yahoo-finance2/-/yahoo-finance2-3.14.0.tgz", - "integrity": "sha512-gsT/tqgeizKtMxbIIWFiFyuhM/6MZE4yEyNLmPekr88AX14JL2HWw0/QNMOR081jVtzTjihqDW0zV7IayH1Wcw==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/yahoo-finance2/-/yahoo-finance2-3.14.2.tgz", + "integrity": "sha512-s+F7TWQT7zAtjhfC7rFHEX16Xfq36u3wceysINP7V+esF3mAYyk9slxZU+fEdkxaTuCT0+PnikHdekMX4UPMrg==", "license": "MIT", "dependencies": { "@deno/shim-deno": "~0.18.0", diff --git a/package.json b/package.json index 857313cae..dfc870b94 100644 --- a/package.json +++ b/package.json @@ -138,7 +138,7 @@ "svgmap": "2.19.3", "tablemark": "4.1.0", "twitter-api-v2": "1.29.0", - "yahoo-finance2": "3.14.0", + "yahoo-finance2": "3.14.2", "zone.js": "0.16.1" }, "devDependencies": { From 8156e4057bf76281c75dae3aec6a4e86ade4378c Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 28 May 2026 17:54:52 +0200 Subject: [PATCH 118/132] Task/respect HTTP_PROXY environment variables for outbound HTTP requests (#6952) * Respect HTTP proxy environment variables for outbound HTTP requests * Update changelog --- CHANGELOG.md | 1 + apps/api/src/main.ts | 4 ++++ package-lock.json | 7 ++++--- package.json | 1 + 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7817256f..d82e7dd19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` environment variable support to outbound HTTP requests - Added the `FetchService` to centralize outbound HTTP requests ### Changed diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index f08a09a83..94e389f6a 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -18,11 +18,15 @@ import type { NestExpressApplication } from '@nestjs/platform-express'; import cookieParser from 'cookie-parser'; import { NextFunction, Request, Response } from 'express'; import helmet from 'helmet'; +import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici'; import { AppModule } from './app/app.module'; import { environment } from './environments/environment'; async function bootstrap() { + // Respect HTTP_PROXY / HTTPS_PROXY / NO_PROXY for outbound HTTP requests + setGlobalDispatcher(new EnvHttpProxyAgent()); + const configApp = await NestFactory.create(AppModule); const configService = configApp.get(ConfigService); let customLogLevels: LogLevel[]; diff --git a/package-lock.json b/package-lock.json index 7a06ea1b3..6527c6bc4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -94,6 +94,7 @@ "svgmap": "2.19.3", "tablemark": "4.1.0", "twitter-api-v2": "1.29.0", + "undici": "7.24.4", "yahoo-finance2": "3.14.2", "zone.js": "0.16.1" }, @@ -28576,9 +28577,9 @@ } }, "node_modules/jsdom/node_modules/undici": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", - "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.26.0.tgz", + "integrity": "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==", "dev": true, "license": "MIT", "peer": true, diff --git a/package.json b/package.json index dfc870b94..fea49e02e 100644 --- a/package.json +++ b/package.json @@ -138,6 +138,7 @@ "svgmap": "2.19.3", "tablemark": "4.1.0", "twitter-api-v2": "1.29.0", + "undici": "7.24.4", "yahoo-finance2": "3.14.2", "zone.js": "0.16.1" }, From 4ad6537ddff54271424bae67c1b1dc40cb13320e Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 28 May 2026 17:57:10 +0200 Subject: [PATCH 119/132] Release 3.6.0 (#6956) --- CHANGELOG.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d82e7dd19..983e9d6ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## Unreleased +## 3.6.0 - 2026-05-28 ### Added diff --git a/package-lock.json b/package-lock.json index 6527c6bc4..c9abbd50d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ghostfolio", - "version": "3.5.0", + "version": "3.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ghostfolio", - "version": "3.5.0", + "version": "3.6.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/package.json b/package.json index fea49e02e..22e39c437 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostfolio", - "version": "3.5.0", + "version": "3.6.0", "homepage": "https://ghostfol.io", "license": "AGPL-3.0", "repository": "https://github.com/ghostfolio/ghostfolio", From b371318c7851f8c5b33316ba0ad60c1846e86a76 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 30 May 2026 09:36:09 +0200 Subject: [PATCH 120/132] Bugfix/add missing guards to getMarketDataBySymbol() in market data controller (#6958) Add missing guards --- .../src/app/endpoints/market-data/market-data.controller.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/src/app/endpoints/market-data/market-data.controller.ts b/apps/api/src/app/endpoints/market-data/market-data.controller.ts index 0dae82d2c..f6857283b 100644 --- a/apps/api/src/app/endpoints/market-data/market-data.controller.ts +++ b/apps/api/src/app/endpoints/market-data/market-data.controller.ts @@ -120,10 +120,10 @@ export class MarketDataController { if (!canReadAllAssetProfiles && !canReadOwnAssetProfile) { throw new HttpException( - assetProfile.userId + assetProfile?.userId ? getReasonPhrase(StatusCodes.NOT_FOUND) : getReasonPhrase(StatusCodes.FORBIDDEN), - assetProfile.userId ? StatusCodes.NOT_FOUND : StatusCodes.FORBIDDEN + assetProfile?.userId ? StatusCodes.NOT_FOUND : StatusCodes.FORBIDDEN ); } From fea5ee33bb5178d48ca89c90253e6e8ea48c3870 Mon Sep 17 00:00:00 2001 From: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com> Date: Sat, 30 May 2026 14:36:27 +0700 Subject: [PATCH 121/132] Task/improve type safety in HTTP interceptor (#6957) * feat(client): resolve errors * feat(client): replace constructor based DI with inject function * feat(client): enforce encapsulation and immutability * feat(client): remove unused tap operator * feat(client): implement generic type parameter * feat(client): replace constructor based DI with inject functions --- apps/client/src/app/core/auth.guard.ts | 12 +++-- apps/client/src/app/core/auth.interceptor.ts | 16 +++---- .../src/app/core/http-response.interceptor.ts | 45 +++++++++---------- 3 files changed, 34 insertions(+), 39 deletions(-) diff --git a/apps/client/src/app/core/auth.guard.ts b/apps/client/src/app/core/auth.guard.ts index 3292f0ff7..6ac3417db 100644 --- a/apps/client/src/app/core/auth.guard.ts +++ b/apps/client/src/app/core/auth.guard.ts @@ -3,7 +3,7 @@ import { UserService } from '@ghostfolio/client/services/user/user.service'; import { internalRoutes, publicRoutes } from '@ghostfolio/common/routes/routes'; import { DataService } from '@ghostfolio/ui/services'; -import { Injectable } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, Router, @@ -14,12 +14,10 @@ import { catchError } from 'rxjs/operators'; @Injectable({ providedIn: 'root' }) export class AuthGuard { - public constructor( - private dataService: DataService, - private router: Router, - private settingsStorageService: SettingsStorageService, - private userService: UserService - ) {} + private readonly dataService = inject(DataService); + private readonly router = inject(Router); + private readonly settingsStorageService = inject(SettingsStorageService); + private readonly userService = inject(UserService); canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { const utmSource = route.queryParams?.utm_source; diff --git a/apps/client/src/app/core/auth.interceptor.ts b/apps/client/src/app/core/auth.interceptor.ts index 7491cecf1..9c06a11d5 100644 --- a/apps/client/src/app/core/auth.interceptor.ts +++ b/apps/client/src/app/core/auth.interceptor.ts @@ -13,20 +13,20 @@ import { HttpInterceptor, HttpRequest } from '@angular/common/http'; -import { Injectable } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; import { Observable } from 'rxjs'; @Injectable() export class AuthInterceptor implements HttpInterceptor { - public constructor( - private impersonationStorageService: ImpersonationStorageService, - private tokenStorageService: TokenStorageService - ) {} + private readonly impersonationStorageService = inject( + ImpersonationStorageService + ); + private readonly tokenStorageService = inject(TokenStorageService); - public intercept( - req: HttpRequest, + public intercept( + req: HttpRequest, next: HttpHandler - ): Observable> { + ): Observable> { let request = req; if (request.headers.has(HEADER_KEY_SKIP_INTERCEPTOR)) { diff --git a/apps/client/src/app/core/http-response.interceptor.ts b/apps/client/src/app/core/http-response.interceptor.ts index 315e9d64e..17927a924 100644 --- a/apps/client/src/app/core/http-response.interceptor.ts +++ b/apps/client/src/app/core/http-response.interceptor.ts @@ -12,7 +12,7 @@ import { HttpInterceptor, HttpRequest } from '@angular/common/http'; -import { Injectable } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; import { MatSnackBar, MatSnackBarRef, @@ -22,31 +22,28 @@ import { Router } from '@angular/router'; import { StatusCodes } from 'http-status-codes'; import ms from 'ms'; import { Observable, throwError } from 'rxjs'; -import { catchError, tap } from 'rxjs/operators'; +import { catchError } from 'rxjs/operators'; @Injectable() export class HttpResponseInterceptor implements HttpInterceptor { - public info: InfoItem; - public snackBarRef: MatSnackBarRef; + private readonly info: InfoItem; + private snackBarRef: MatSnackBarRef | undefined; - public constructor( - private dataService: DataService, - private router: Router, - private snackBar: MatSnackBar, - private userService: UserService, - private webAuthnService: WebAuthnService - ) { + private readonly dataService = inject(DataService); + private readonly router = inject(Router); + private readonly snackBar = inject(MatSnackBar); + private readonly userService = inject(UserService); + private readonly webAuthnService = inject(WebAuthnService); + + public constructor() { this.info = this.dataService.fetchInfo(); } - public intercept( - request: HttpRequest, + public intercept( + request: HttpRequest, next: HttpHandler - ): Observable> { + ): Observable> { return next.handle(request).pipe( - tap((event: HttpEvent) => { - return event; - }), catchError((error: HttpErrorResponse) => { if (error.status === StatusCodes.FORBIDDEN) { if (!this.snackBarRef) { @@ -61,7 +58,7 @@ export class HttpResponseInterceptor implements HttpInterceptor { } ); } else if ( - !error.url.includes(internalRoutes.auth.routerLink.join('')) + !error.url?.includes(internalRoutes.auth.routerLink.join('')) ) { this.snackBarRef = this.snackBar.open( $localize`This action is not allowed.`, @@ -72,11 +69,11 @@ export class HttpResponseInterceptor implements HttpInterceptor { ); } - this.snackBarRef.afterDismissed().subscribe(() => { + this.snackBarRef?.afterDismissed().subscribe(() => { this.snackBarRef = undefined; }); - this.snackBarRef.onAction().subscribe(() => { + this.snackBarRef?.onAction().subscribe(() => { this.router.navigate(publicRoutes.pricing.routerLink); }); } @@ -92,11 +89,11 @@ export class HttpResponseInterceptor implements HttpInterceptor { } ); - this.snackBarRef.afterDismissed().subscribe(() => { + this.snackBarRef?.afterDismissed().subscribe(() => { this.snackBarRef = undefined; }); - this.snackBarRef.onAction().subscribe(() => { + this.snackBarRef?.onAction().subscribe(() => { window.location.reload(); }); } @@ -106,12 +103,12 @@ export class HttpResponseInterceptor implements HttpInterceptor { $localize`Oops! It looks like you’re making too many requests. Please slow down a bit.` ); - this.snackBarRef.afterDismissed().subscribe(() => { + this.snackBarRef?.afterDismissed().subscribe(() => { this.snackBarRef = undefined; }); } } else if (error.status === StatusCodes.UNAUTHORIZED) { - if (!error.url.includes('/data-providers/ghostfolio/status')) { + if (!error.url?.includes('/data-providers/ghostfolio/status')) { if (this.webAuthnService.isEnabled()) { this.router.navigate(internalRoutes.webauthn.routerLink); } else { From b6b44347b029bad0a896112ee404f0bbc1858bfe Mon Sep 17 00:00:00 2001 From: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com> Date: Sun, 31 May 2026 11:51:22 +0700 Subject: [PATCH 122/132] Task/improve type safety in file drop directive (#6961) * fix(client): resolve errors * feat(client): migrate to output signal * feat(client): migrate to host property --- .../file-drop/file-drop.directive.ts | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/apps/client/src/app/directives/file-drop/file-drop.directive.ts b/apps/client/src/app/directives/file-drop/file-drop.directive.ts index a7e628bc9..b46357005 100644 --- a/apps/client/src/app/directives/file-drop/file-drop.directive.ts +++ b/apps/client/src/app/directives/file-drop/file-drop.directive.ts @@ -1,28 +1,34 @@ -import { Directive, EventEmitter, HostListener, Output } from '@angular/core'; +import { Directive, output } from '@angular/core'; @Directive({ + host: { + '(dragenter)': 'onDragEnter($event)', + '(dragover)': 'onDragOver($event)', + '(drop)': 'onDrop($event)' + }, selector: '[gfFileDrop]' }) export class GfFileDropDirective { - @Output() filesDropped = new EventEmitter(); + public readonly filesDropped = output(); - @HostListener('dragenter', ['$event']) onDragEnter(event: DragEvent) { + public onDragEnter(event: DragEvent) { event.preventDefault(); event.stopPropagation(); } - @HostListener('dragover', ['$event']) onDragOver(event: DragEvent) { + public onDragOver(event: DragEvent) { event.preventDefault(); event.stopPropagation(); } - @HostListener('drop', ['$event']) onDrop(event: DragEvent) { + public onDrop(event: DragEvent) { event.preventDefault(); event.stopPropagation(); - // Prevent the browser's default behavior for handling the file drop - event.dataTransfer.dropEffect = 'copy'; - - this.filesDropped.emit(event.dataTransfer.files); + if (event.dataTransfer) { + // Prevent the browser's default behavior for handling the file drop + event.dataTransfer.dropEffect = 'copy'; + this.filesDropped.emit(event.dataTransfer.files); + } } } From f9ebb9150abfb49ece0f7d51758d51971c2d3d8e Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 31 May 2026 06:54:51 +0200 Subject: [PATCH 123/132] Task/add portfolio service test (#6949) * Add tests to cover cash positions --- .../app/portfolio/portfolio.service.spec.ts | 272 ++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 apps/api/src/app/portfolio/portfolio.service.spec.ts diff --git a/apps/api/src/app/portfolio/portfolio.service.spec.ts b/apps/api/src/app/portfolio/portfolio.service.spec.ts new file mode 100644 index 000000000..da846c45d --- /dev/null +++ b/apps/api/src/app/portfolio/portfolio.service.spec.ts @@ -0,0 +1,272 @@ +import { AccountService } from '@ghostfolio/api/app/account/account.service'; +import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface'; +import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; +import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { UserService } from '@ghostfolio/api/app/user/user.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; +import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { parseDate } from '@ghostfolio/common/helper'; + +import { Account, DataSource } from '@prisma/client'; +import { Big } from 'big.js'; +import { randomUUID } from 'node:crypto'; + +import { PortfolioService } from './portfolio.service'; + +describe('PortfolioService', () => { + let accountService: AccountService; + let activitiesService: ActivitiesService; + let configurationService: ConfigurationService; + let dataProviderService: DataProviderService; + let exchangeRateDataService: ExchangeRateDataService; + let impersonationService: ImpersonationService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioService: PortfolioService; + let symbolProfileService: SymbolProfileService; + let userService: UserService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + dataProviderService = new DataProviderService( + configurationService, + null, + null, + null, + null, + null + ); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + accountService = new AccountService( + null, + null, + exchangeRateDataService, + null + ); + + activitiesService = new ActivitiesService( + null, + accountService, + null, + dataProviderService, + null, + exchangeRateDataService, + null, + null + ); + + impersonationService = new ImpersonationService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + null, + exchangeRateDataService, + null, + null + ); + + symbolProfileService = new SymbolProfileService(null); + + userService = new UserService( + null, + null, + null, + null, + null, + null, + null, + null + ); + + portfolioService = new PortfolioService( + null, + accountService, + activitiesService, + null, + portfolioCalculatorFactory, + dataProviderService, + exchangeRateDataService, + null, + impersonationService, + null, + null, + symbolProfileService, + userService + ); + }); + + describe('getCashSymbolProfiles', () => { + it('should use the exchange-rate data source so the symbol-profile join in getDetails matches the calculator positions', () => { + jest + .spyOn(dataProviderService, 'getDataSourceForExchangeRates') + .mockReturnValue(DataSource.YAHOO); + + const cashDetails: CashDetails = { + accounts: [ + { + balance: 2000, + comment: null, + createdAt: parseDate('2024-01-01'), + currency: 'USD', + id: randomUUID(), + isExcluded: false, + name: 'USD', + platformId: null, + updatedAt: parseDate('2024-01-01'), + userId: userDummyData.id + } + ], + balanceInBaseCurrency: 1820 + }; + + const assetProfiles = ( + portfolioService as unknown as { + getCashSymbolProfiles: ( + aCashDetails: CashDetails + ) => { dataSource: DataSource; symbol: string }[]; + } + ).getCashSymbolProfiles(cashDetails); + + expect(assetProfiles).toHaveLength(1); + expect(assetProfiles[0].dataSource).toBe(DataSource.YAHOO); + expect(assetProfiles[0].symbol).toBe('USD'); + }); + }); + + describe('getDetails', () => { + it('should return cash holdings when the calculator emits cash positions with the exchange-rate data source', async () => { + const accountId = randomUUID(); + + const cashAccount: Account = { + balance: 2000, + comment: null, + createdAt: parseDate('2024-01-01'), + currency: 'USD', + id: accountId, + isExcluded: false, + name: 'USD', + platformId: null, + updatedAt: parseDate('2024-01-01'), + userId: userDummyData.id + }; + + jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({ + accounts: [cashAccount], + balanceInBaseCurrency: 1820 + }); + + jest + .spyOn(activitiesService, 'getActivitiesForPortfolioCalculator') + .mockResolvedValue({ activities: [], count: 0 }); + + jest + .spyOn(dataProviderService, 'getDataSourceForExchangeRates') + .mockReturnValue(DataSource.YAHOO); + + jest + .spyOn(impersonationService, 'validateImpersonationId') + .mockResolvedValue(null); + + jest + .spyOn(symbolProfileService, 'getSymbolProfiles') + .mockResolvedValue([]); + + jest.spyOn(userService, 'user').mockResolvedValue({ + accessesGet: [], + accounts: [], + activityCount: 0, + dataProviderGhostfolioDailyRequests: 0, + id: userDummyData.id, + settings: { + settings: { + baseCurrency: 'CHF' + } + } + } as unknown as Awaited>); + + const usdPosition = { + activitiesCount: 1, + averagePrice: new Big(1), + currency: 'USD', + dataSource: DataSource.YAHOO, + dateOfFirstActivity: '2024-01-01', + dividend: new Big(0), + dividendInBaseCurrency: new Big(0), + fee: new Big(0), + feeInBaseCurrency: new Big(0), + grossPerformance: new Big(0), + grossPerformancePercentage: new Big(0), + grossPerformancePercentageWithCurrencyEffect: new Big(0), + grossPerformanceWithCurrencyEffect: new Big(0), + investment: new Big(1820), + investmentWithCurrencyEffect: new Big(1820), + marketPrice: 1, + marketPriceInBaseCurrency: 0.91, + netPerformance: new Big(0), + netPerformancePercentage: new Big(0), + netPerformancePercentageWithCurrencyEffectMap: {}, + netPerformanceWithCurrencyEffectMap: {}, + quantity: new Big(2000), + symbol: 'USD', + tags: [], + timeWeightedInvestment: new Big(0), + timeWeightedInvestmentWithCurrencyEffect: new Big(0), + valueInBaseCurrency: new Big(1820) + }; + + jest + .spyOn(portfolioCalculatorFactory, 'createCalculator') + .mockReturnValue({ + getSnapshot: jest.fn().mockResolvedValue({ + activitiesCount: 1, + createdAt: parseDate('2024-01-01'), + currentValueInBaseCurrency: new Big(1820), + errors: [], + hasErrors: false, + historicalData: [], + positions: [usdPosition], + totalFeesWithCurrencyEffect: new Big(0), + totalInterestWithCurrencyEffect: new Big(0), + totalInvestment: new Big(1820), + totalInvestmentWithCurrencyEffect: new Big(1820), + totalLiabilitiesWithCurrencyEffect: new Big(0) + }) + } as unknown as ReturnType< + typeof portfolioCalculatorFactory.createCalculator + >); + + jest + .spyOn( + portfolioService as unknown as { + getValueOfAccountsAndPlatforms: () => Promise<{ + accounts: object; + platforms: object; + }>; + }, + 'getValueOfAccountsAndPlatforms' + ) + .mockResolvedValue({ accounts: {}, platforms: {} }); + + const { holdings } = await portfolioService.getDetails({ + filters: [], + impersonationId: userDummyData.id, + userId: userDummyData.id + }); + + expect(holdings['USD']).toBeDefined(); + expect(holdings['USD'].assetProfile.dataSource).toBe(DataSource.YAHOO); + expect(holdings['USD'].assetProfile.symbol).toBe('USD'); + }); + }); +}); From aa0ec2622f3474a62d030ae512e906c8c33c6d33 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 31 May 2026 07:03:47 +0200 Subject: [PATCH 124/132] Task/extend countries mapping in Trackinsight data enhancer service (#6959) * Extend countries mapping with USA * Update changelog --- CHANGELOG.md | 6 ++++++ .../data-enhancer/trackinsight/trackinsight.service.ts | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 983e9d6ea..9842a2b19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Changed + +- Extended the countries mapping in the data enhancer for asset profile data via _Trackinsight_ + ## 3.6.0 - 2026-05-28 ### Added diff --git a/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts b/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts index 54627f312..eeccf725e 100644 --- a/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts +++ b/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts @@ -13,7 +13,8 @@ import { countries } from 'countries-list'; export class TrackinsightDataEnhancerService implements DataEnhancerInterface { private static baseUrl = 'https://www.trackinsight.com/data-api'; private static countriesMapping = { - 'Russian Federation': 'Russia' + 'Russian Federation': 'Russia', + USA: 'United States' }; private static holdingsWeightTreshold = 0.85; private static sectorsMapping = { From ab6fdf6c4fe55aa663c4f4d6893780141e39ccab Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 31 May 2026 07:16:56 +0200 Subject: [PATCH 125/132] Task/remove deprecated attributes from portfolio position interface (#6950) * Remove deprecated attributes * Update changelog --- CHANGELOG.md | 1 + .../src/app/portfolio/portfolio.controller.ts | 44 ++-- .../src/app/portfolio/portfolio.service.ts | 34 +-- ...ate-or-update-activity-dialog.component.ts | 2 +- .../import-activities-dialog.component.ts | 3 +- .../allocations/allocations-page.component.ts | 50 ++--- .../portfolio/analysis/analysis-page.html | 16 +- .../app/pages/public/public-page.component.ts | 5 +- .../portfolio-position.interface.ts | 47 +--- .../src/lib/assistant/assistant.component.ts | 41 ++-- libs/ui/src/lib/mocks/holdings.ts | 200 ++++-------------- .../portfolio-filter-form.component.html | 9 +- .../portfolio-filter-form.component.ts | 3 +- 13 files changed, 136 insertions(+), 319 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9842a2b19..e0c416fb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Extended the countries mapping in the data enhancer for asset profile data via _Trackinsight_ +- Removed the deprecated attributes (`assetClass`, `assetClassLabel`, `assetSubClass`, `assetSubClassLabel`, `countries`, `currency`, `dataSource`, `holdings`, `name`, `sectors`, `symbol` and `url`) from the holdings of the portfolio details endpoint response ## 3.6.0 - 2026-05-28 diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 8aa94ee92..9515c7a21 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -144,10 +144,10 @@ export class PortfolioController { .reduce((a, b) => a + b, 0); const totalValue = Object.values(holdings) - .filter(({ assetClass, assetSubClass }) => { + .filter(({ assetProfile }) => { return ( - assetClass !== AssetClass.LIQUIDITY && - assetSubClass !== AssetSubClass.CASH + assetProfile.assetClass !== AssetClass.LIQUIDITY && + assetProfile.assetSubClass !== AssetSubClass.CASH ); }) .map(({ valueInBaseCurrency }) => { @@ -217,37 +217,41 @@ export class PortfolioController { for (const [symbol, portfolioPosition] of Object.entries(holdings)) { holdings[symbol] = { ...portfolioPosition, - assetClass: - hasDetails || portfolioPosition.assetClass === AssetClass.LIQUIDITY - ? portfolioPosition.assetClass - : undefined, assetProfile: { ...portfolioPosition.assetProfile, + assetClass: + hasDetails || + portfolioPosition.assetProfile.assetClass === AssetClass.LIQUIDITY + ? portfolioPosition.assetProfile.assetClass + : undefined, + assetClassLabel: + hasDetails || + portfolioPosition.assetProfile.assetClass === AssetClass.LIQUIDITY + ? portfolioPosition.assetProfile.assetClassLabel + : undefined, + assetSubClass: + hasDetails || + portfolioPosition.assetProfile.assetSubClass === AssetSubClass.CASH + ? portfolioPosition.assetProfile.assetSubClass + : undefined, + assetSubClassLabel: + hasDetails || + portfolioPosition.assetProfile.assetSubClass === AssetSubClass.CASH + ? portfolioPosition.assetProfile.assetSubClassLabel + : undefined, ...(hasDetails ? {} : { - assetClass: undefined, - assetClassLabel: undefined, - assetSubClass: undefined, - assetSubClassLabel: undefined, countries: [], currency: undefined, holdings: [], sectors: [] }) }, - assetSubClass: - hasDetails || portfolioPosition.assetSubClass === AssetSubClass.CASH - ? portfolioPosition.assetSubClass - : undefined, - countries: hasDetails ? portfolioPosition.countries : [], - currency: hasDetails ? portfolioPosition.currency : undefined, - holdings: hasDetails ? portfolioPosition.holdings : [], markets: hasDetails ? portfolioPosition.markets : undefined, marketsAdvanced: hasDetails ? portfolioPosition.marketsAdvanced - : undefined, - sectors: hasDetails ? portfolioPosition.sectors : [] + : undefined }; } diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index cee36ec27..ab39a00a5 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -584,7 +584,6 @@ export class PortfolioService { for (const { activitiesCount, - currency, dataSource, dateOfFirstActivity, dividend, @@ -638,16 +637,13 @@ export class PortfolioService { holdings[symbol] = { activitiesCount, - currency, markets, marketsAdvanced, marketPrice, - symbol, tags, allocationInPercentage: filteredValueInBaseCurrency.eq(0) ? 0 : valueInBaseCurrency.div(filteredValueInBaseCurrency).toNumber(), - assetClass: assetProfile.assetClass, assetProfile: { assetClass: assetProfile.assetClass, assetSubClass: assetProfile.assetSubClass, @@ -670,9 +666,6 @@ export class PortfolioService { symbol: assetProfile.symbol, url: assetProfile.url }, - assetSubClass: assetProfile.assetSubClass, - countries: assetProfile.countries, - dataSource: assetProfile.dataSource, dateOfFirstActivity: parseDate(dateOfFirstActivity), dividend: dividend?.toNumber() ?? 0, grossPerformance: grossPerformance?.toNumber() ?? 0, @@ -681,19 +674,7 @@ export class PortfolioService { grossPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0, grossPerformanceWithCurrencyEffect: grossPerformanceWithCurrencyEffect?.toNumber() ?? 0, - holdings: assetProfile.holdings.map( - ({ allocationInPercentage, name }) => { - return { - allocationInPercentage, - name, - valueInBaseCurrency: valueInBaseCurrency - .mul(allocationInPercentage) - .toNumber() - }; - } - ), investment: investment.toNumber(), - name: assetProfile.name, netPerformance: netPerformance?.toNumber() ?? 0, netPerformancePercent: netPerformancePercentage?.toNumber() ?? 0, netPerformancePercentWithCurrencyEffect: @@ -703,8 +684,6 @@ export class PortfolioService { netPerformanceWithCurrencyEffect: netPerformanceWithCurrencyEffectMap?.[dateRange]?.toNumber() ?? 0, quantity: quantity.toNumber(), - sectors: assetProfile.sectors, - url: assetProfile.url, valueInBaseCurrency: valueInBaseCurrency.toNumber() }; } @@ -1472,8 +1451,8 @@ export class PortfolioService { for (const [, position] of Object.entries(holdings)) { const value = position.valueInBaseCurrency; - if (position.assetClass !== AssetClass.LIQUIDITY) { - if (position.countries.length > 0) { + if (position.assetProfile.assetClass !== AssetClass.LIQUIDITY) { + if (position.assetProfile.countries.length > 0) { markets.developedMarkets.valueInBaseCurrency += position.markets.developedMarkets * value; markets.emergingMarkets.valueInBaseCurrency += @@ -1719,11 +1698,8 @@ export class PortfolioService { currency: string; }): PortfolioPosition { return { - currency, activitiesCount: 0, allocationInPercentage: 0, - assetClass: AssetClass.LIQUIDITY, - assetSubClass: AssetSubClass.CASH, assetProfile: { currency, assetClass: AssetClass.LIQUIDITY, @@ -1735,25 +1711,19 @@ export class PortfolioService { sectors: [], symbol: currency }, - countries: [], - dataSource: undefined, dateOfFirstActivity: undefined, dividend: 0, grossPerformance: 0, grossPerformancePercent: 0, grossPerformancePercentWithCurrencyEffect: 0, grossPerformanceWithCurrencyEffect: 0, - holdings: [], investment: balance, marketPrice: 0, - name: currency, netPerformance: 0, netPerformancePercent: 0, netPerformancePercentWithCurrencyEffect: 0, netPerformanceWithCurrencyEffect: 0, quantity: 0, - sectors: [], - symbol: currency, tags: [], valueInBaseCurrency: balance }; diff --git a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts index 1e943824c..decb30682 100644 --- a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts @@ -139,7 +139,7 @@ export class GfCreateOrUpdateActivityDialogComponent { return !['CASH'].includes(assetProfile.assetSubClass); }) .sort((a, b) => { - return a.name?.localeCompare(b.name); + return a.assetProfile.name?.localeCompare(b.assetProfile.name); }) .map(({ assetProfile }) => { return { diff --git a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts index 42260d648..c3dbe6cf2 100644 --- a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts @@ -226,7 +226,8 @@ export class GfImportActivitiesDialogComponent { this.assetProfileForm.controls.assetProfileIdentifier.disable(); const { dataSource, symbol } = - this.assetProfileForm.controls.assetProfileIdentifier.value ?? {}; + this.assetProfileForm.controls.assetProfileIdentifier.value + ?.assetProfile ?? {}; if (!dataSource || !symbol) { return; diff --git a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts index a7f8cd2ec..f48b551bb 100644 --- a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts +++ b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts @@ -73,15 +73,14 @@ export class GfAllocationsPageComponent implements OnInit { public hasImpersonationId: boolean; public holdings: { [symbol: string]: Pick< - PortfolioPosition, + PortfolioPosition['assetProfile'], | 'assetClass' | 'assetClassLabel' | 'assetSubClass' | 'assetSubClassLabel' | 'currency' - | 'exchange' | 'name' - > & { etfProvider: string; value: number }; + > & { etfProvider: string; exchange?: string; value: number }; }; public isLoading = false; public markets: { @@ -206,7 +205,7 @@ export class GfAllocationsPageComponent implements OnInit { assetSubClass, name }: { - assetSubClass: PortfolioPosition['assetSubClass']; + assetSubClass: PortfolioPosition['assetProfile']['assetSubClass']; name: string; }) { if (assetSubClass === 'ETF') { @@ -333,24 +332,27 @@ export class GfAllocationsPageComponent implements OnInit { this.holdings[symbol] = { value, - assetClass: position.assetClass || (UNKNOWN_KEY as AssetClass), - assetClassLabel: position.assetClassLabel || UNKNOWN_KEY, - assetSubClass: position.assetSubClass || (UNKNOWN_KEY as AssetSubClass), - assetSubClassLabel: position.assetSubClassLabel || UNKNOWN_KEY, - currency: position.currency, + assetClass: + position.assetProfile.assetClass || (UNKNOWN_KEY as AssetClass), + assetClassLabel: position.assetProfile.assetClassLabel || UNKNOWN_KEY, + assetSubClass: + position.assetProfile.assetSubClass || (UNKNOWN_KEY as AssetSubClass), + assetSubClassLabel: + position.assetProfile.assetSubClassLabel || UNKNOWN_KEY, + currency: position.assetProfile.currency, etfProvider: this.extractEtfProvider({ - assetSubClass: position.assetSubClass, - name: position.name + assetSubClass: position.assetProfile.assetSubClass, + name: position.assetProfile.name }), exchange: position.exchange, - name: position.name + name: position.assetProfile.name }; - if (position.assetClass !== AssetClass.LIQUIDITY) { + if (position.assetProfile.assetClass !== AssetClass.LIQUIDITY) { // Prepare analysis data by continents, countries, holdings and sectors except for liquidity - if (position.countries.length > 0) { - for (const country of position.countries) { + if (position.assetProfile.countries.length > 0) { + for (const country of position.assetProfile.countries) { const { code, continent, name, weight } = country; if (this.continents[continent]?.value) { @@ -401,12 +403,12 @@ export class GfAllocationsPageComponent implements OnInit { : this.portfolioDetails.holdings[symbol].valueInPercentage; } - if (position.holdings.length > 0) { + if (position.assetProfile.holdings.length > 0) { for (const { allocationInPercentage, name, valueInBaseCurrency - } of position.holdings) { + } of position.assetProfile.holdings) { const normalizedAssetName = this.normalizeAssetName(name); if (this.topHoldingsMap[normalizedAssetName]?.value) { @@ -428,8 +430,8 @@ export class GfAllocationsPageComponent implements OnInit { } } - if (position.sectors.length > 0) { - for (const sector of position.sectors) { + if (position.assetProfile.sectors.length > 0) { + for (const sector of position.assetProfile.sectors) { const { name, weight } = sector; if (this.sectors[name]?.value) { @@ -463,8 +465,8 @@ export class GfAllocationsPageComponent implements OnInit { } this.symbols[prettifySymbol(symbol)] = { - dataSource: position.dataSource, - name: position.name, + dataSource: position.assetProfile.dataSource, + name: position.assetProfile.name, symbol: prettifySymbol(symbol), value: isNumber(position.valueInBaseCurrency) ? position.valueInBaseCurrency @@ -517,8 +519,8 @@ export class GfAllocationsPageComponent implements OnInit { this.totalValueInEtf > 0 ? value / this.totalValueInEtf : 0, parents: Object.entries(this.portfolioDetails.holdings) .map(([symbol, holding]) => { - if (holding.holdings.length > 0) { - const currentParentHolding = holding.holdings.find( + if (holding.assetProfile.holdings.length > 0) { + const currentParentHolding = holding.assetProfile.holdings.find( (parentHolding) => { return ( this.normalizeAssetName(parentHolding.name) === @@ -531,7 +533,7 @@ export class GfAllocationsPageComponent implements OnInit { ? { allocationInPercentage: currentParentHolding.valueInBaseCurrency / value, - name: holding.name, + name: holding.assetProfile.name, position: holding, symbol: prettifySymbol(symbol), valueInBaseCurrency: diff --git a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html index 4c5c61bd8..b93be9182 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html @@ -310,13 +310,15 @@ -
    {{ holding.name }}
    +
    + {{ holding.assetProfile.name }} +
    -
    {{ holding.name }}
    +
    + {{ holding.assetProfile.name }} +
    & { + [symbol: string]: Pick< + PortfolioPosition['assetProfile'], + 'currency' | 'name' + > & { value: number; }; }; diff --git a/libs/common/src/lib/interfaces/portfolio-position.interface.ts b/libs/common/src/lib/interfaces/portfolio-position.interface.ts index c4ef2e3dc..c94a1efa5 100644 --- a/libs/common/src/lib/interfaces/portfolio-position.interface.ts +++ b/libs/common/src/lib/interfaces/portfolio-position.interface.ts @@ -1,22 +1,12 @@ import { Market, MarketAdvanced } from '@ghostfolio/common/types'; -import { AssetClass, AssetSubClass, DataSource, Tag } from '@prisma/client'; +import { Tag } from '@prisma/client'; -import { Country } from './country.interface'; import { EnhancedSymbolProfile } from './enhanced-symbol-profile.interface'; -import { Holding } from './holding.interface'; -import { Sector } from './sector.interface'; export interface PortfolioPosition { activitiesCount: number; allocationInPercentage: number; - - /** @deprecated */ - assetClass?: AssetClass; - - /** @deprecated */ - assetClassLabel?: string; - assetProfile: Pick< EnhancedSymbolProfile, | 'assetClass' @@ -33,22 +23,6 @@ export interface PortfolioPosition { assetClassLabel?: string; assetSubClassLabel?: string; }; - - /** @deprecated */ - assetSubClass?: AssetSubClass; - - /** @deprecated */ - assetSubClassLabel?: string; - - /** @deprecated */ - countries: Country[]; - - /** @deprecated */ - currency: string; - - /** @deprecated */ - dataSource: DataSource; - dateOfFirstActivity: Date; dividend: number; exchange?: string; @@ -56,38 +30,19 @@ export interface PortfolioPosition { grossPerformancePercent: number; grossPerformancePercentWithCurrencyEffect: number; grossPerformanceWithCurrencyEffect: number; - - /** @deprecated */ - holdings: Holding[]; - investment: number; marketChange?: number; marketChangePercent?: number; marketPrice: number; markets?: { [key in Market]: number }; marketsAdvanced?: { [key in MarketAdvanced]: number }; - - /** @deprecated */ - name: string; - netPerformance: number; netPerformancePercent: number; netPerformancePercentWithCurrencyEffect: number; netPerformanceWithCurrencyEffect: number; quantity: number; - - /** @deprecated */ - sectors: Sector[]; - - /** @deprecated */ - symbol: string; - tags?: Tag[]; type?: string; - - /** @deprecated */ - url?: string; - valueInBaseCurrency?: number; valueInPercentage?: number; } diff --git a/libs/ui/src/lib/assistant/assistant.component.ts b/libs/ui/src/lib/assistant/assistant.component.ts index a0985a979..3c162a310 100644 --- a/libs/ui/src/lib/assistant/assistant.component.ts +++ b/libs/ui/src/lib/assistant/assistant.component.ts @@ -504,11 +504,16 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ holdings }) => { this.holdings = holdings - .filter(({ assetSubClass }) => { - return assetSubClass && !['CASH'].includes(assetSubClass); + .filter(({ assetProfile }) => { + return ( + assetProfile.assetSubClass && + !['CASH'].includes(assetProfile.assetSubClass) + ); }) .sort((a, b) => { - return a.name?.localeCompare(b.name); + return (a.assetProfile.name ?? '').localeCompare( + b.assetProfile.name ?? '' + ); }); this.setPortfolioFilterFormValues(); @@ -530,11 +535,11 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { type: 'ASSET_CLASS' }, { - id: filterValue?.holding?.dataSource ?? '', + id: filterValue?.holding?.assetProfile?.dataSource ?? '', type: 'DATA_SOURCE' }, { - id: filterValue?.holding?.symbol ?? '', + id: filterValue?.holding?.assetProfile?.symbol ?? '', type: 'SYMBOL' }, { @@ -718,18 +723,16 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { return EMPTY; }), map(({ holdings }) => { - return holdings.map( - ({ assetSubClass, currency, dataSource, name, symbol }) => { - return { - currency, - dataSource, - name, - symbol, - assetSubClassString: translate(assetSubClass ?? ''), - mode: SearchMode.HOLDING as const - }; - } - ); + return holdings.map(({ assetProfile }) => { + return { + assetSubClassString: translate(assetProfile.assetSubClass ?? ''), + currency: assetProfile.currency ?? '', + dataSource: assetProfile.dataSource, + mode: SearchMode.HOLDING as const, + name: assetProfile.name ?? '', + symbol: assetProfile.symbol + }; + }); }), takeUntilDestroyed(this.destroyRef) ); @@ -777,8 +780,8 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { return ( !!(dataSource && symbol) && getAssetProfileIdentifier({ - dataSource: holding.dataSource, - symbol: holding.symbol + dataSource: holding.assetProfile.dataSource, + symbol: holding.assetProfile.symbol }) === getAssetProfileIdentifier({ dataSource, symbol }) ); }); diff --git a/libs/ui/src/lib/mocks/holdings.ts b/libs/ui/src/lib/mocks/holdings.ts index b32eb527a..11f3bec0e 100644 --- a/libs/ui/src/lib/mocks/holdings.ts +++ b/libs/ui/src/lib/mocks/holdings.ts @@ -4,11 +4,11 @@ export const holdings: PortfolioPosition[] = [ { activitiesCount: 1, allocationInPercentage: 0.042990776363386086, - assetClass: 'EQUITY', - assetClassLabel: 'Equity', assetProfile: { assetClass: 'EQUITY', + assetClassLabel: 'Equity', assetSubClass: 'STOCK', + assetSubClassLabel: 'Stock', countries: [ { code: 'US', @@ -20,60 +20,40 @@ export const holdings: PortfolioPosition[] = [ currency: 'USD', dataSource: 'YAHOO', holdings: [], + name: 'Apple Inc', sectors: [ { name: 'Technology', weight: 1 } ], - symbol: 'AAPL' + symbol: 'AAPL', + url: 'https://www.apple.com' }, - assetSubClass: 'STOCK', - assetSubClassLabel: 'Stock', - countries: [ - { - code: 'US', - continent: 'North America', - name: 'United States', - weight: 1 - } - ], - currency: 'USD', - dataSource: 'YAHOO', dateOfFirstActivity: new Date('2021-12-01T00:00:00.000Z'), dividend: 0, grossPerformance: 3856, grossPerformancePercent: 0.46047289228564603, grossPerformancePercentWithCurrencyEffect: 0.46047289228564603, grossPerformanceWithCurrencyEffect: 3856, - holdings: [], investment: 8374, marketPrice: 244.6, - name: 'Apple Inc', netPerformance: 3855, netPerformancePercent: 0.460353475041796, netPerformancePercentWithCurrencyEffect: 0.036440677966101696, netPerformanceWithCurrencyEffect: 430, quantity: 50, - sectors: [ - { - name: 'Technology', - weight: 1 - } - ], - symbol: 'AAPL', tags: [], - url: 'https://www.apple.com', valueInBaseCurrency: 12230 }, { activitiesCount: 2, allocationInPercentage: 0.02377401948293552, - assetClass: 'EQUITY', - assetClassLabel: 'Equity', assetProfile: { assetClass: 'EQUITY', + assetClassLabel: 'Equity', assetSubClass: 'STOCK', + assetSubClassLabel: 'Stock', countries: [ { code: 'DE', @@ -85,60 +65,40 @@ export const holdings: PortfolioPosition[] = [ currency: 'EUR', dataSource: 'YAHOO', holdings: [], + name: 'Allianz SE', sectors: [ { name: 'Financial Services', weight: 1 } ], - symbol: 'ALV.DE' + symbol: 'ALV.DE', + url: 'https://www.allianz.com' }, - assetSubClass: 'STOCK', - assetSubClassLabel: 'Stock', - countries: [ - { - code: 'DE', - continent: 'Europe', - name: 'Germany', - weight: 1 - } - ], - currency: 'EUR', - dataSource: 'YAHOO', dateOfFirstActivity: new Date('2021-04-23T00:00:00.000Z'), dividend: 192, grossPerformance: 2226.700251889169, grossPerformancePercent: 0.49083842309827874, grossPerformancePercentWithCurrencyEffect: 0.29306136948826367, grossPerformanceWithCurrencyEffect: 1532.8272791336772, - holdings: [], investment: 4536.523929471033, marketPrice: 322.2, - name: 'Allianz SE', netPerformance: 2222.2921914357685, netPerformancePercent: 0.48986674069961134, netPerformancePercentWithCurrencyEffect: 0.034489367670592026, netPerformanceWithCurrencyEffect: 225.48257403052068, quantity: 20, - sectors: [ - { - name: 'Financial Services', - weight: 1 - } - ], - symbol: 'ALV.DE', tags: [], - url: 'https://www.allianz.com', valueInBaseCurrency: 6763.224181360202 }, { activitiesCount: 1, allocationInPercentage: 0.08038536990007467, - assetClass: 'EQUITY', - assetClassLabel: 'Equity', assetProfile: { assetClass: 'EQUITY', + assetClassLabel: 'Equity', assetSubClass: 'STOCK', + assetSubClassLabel: 'Stock', countries: [ { code: 'US', @@ -150,101 +110,73 @@ export const holdings: PortfolioPosition[] = [ currency: 'USD', dataSource: 'YAHOO', holdings: [], + name: 'Amazon.com, Inc.', sectors: [ { name: 'Consumer Discretionary', weight: 1 } ], - symbol: 'AMZN' + symbol: 'AMZN', + url: 'https://www.aboutamazon.com' }, - assetSubClass: 'STOCK', - assetSubClassLabel: 'Stock', - countries: [ - { - code: 'US', - continent: 'North America', - name: 'United States', - weight: 1 - } - ], - currency: 'USD', - dataSource: 'YAHOO', dateOfFirstActivity: new Date('2018-10-01T00:00:00.000Z'), dividend: 0, grossPerformance: 12758.05, grossPerformancePercent: 1.2619300787837724, grossPerformancePercentWithCurrencyEffect: 1.2619300787837724, grossPerformanceWithCurrencyEffect: 12758.05, - holdings: [], investment: 10109.95, marketPrice: 228.68, - name: 'Amazon.com, Inc.', netPerformance: 12677.26, netPerformancePercent: 1.253938941339967, netPerformancePercentWithCurrencyEffect: -0.037866008722316276, netPerformanceWithCurrencyEffect: -899.99926757812, quantity: 100, - sectors: [ - { - name: 'Consumer Discretionary', - weight: 1 - } - ], - symbol: 'AMZN', tags: [], - url: 'https://www.aboutamazon.com', valueInBaseCurrency: 22868 }, { activitiesCount: 1, allocationInPercentage: 0.19216416482928922, - assetClass: 'LIQUIDITY', - assetClassLabel: 'Liquidity', assetProfile: { assetClass: 'LIQUIDITY', - assetSubClass: 'CASH', + assetClassLabel: 'Liquidity', + assetSubClass: 'CRYPTOCURRENCY', + assetSubClassLabel: 'Cryptocurrency', countries: [], currency: 'USD', dataSource: 'COINGECKO', holdings: [], + name: 'Bitcoin', sectors: [], - symbol: 'bitcoin' + symbol: 'bitcoin', + url: undefined }, - assetSubClass: 'CRYPTOCURRENCY', - assetSubClassLabel: 'Cryptocurrency', - countries: [], - currency: 'USD', - dataSource: 'COINGECKO', dateOfFirstActivity: new Date('2017-08-16T00:00:00.000Z'), dividend: 0, grossPerformance: 52666.7898248, grossPerformancePercent: 26.333394912400003, grossPerformancePercentWithCurrencyEffect: 26.333394912400003, grossPerformanceWithCurrencyEffect: 52666.7898248, - holdings: [], investment: 1999.9999999999998, marketPrice: 97364, - name: 'Bitcoin', netPerformance: 52636.8898248, netPerformancePercent: 26.3184449124, netPerformancePercentWithCurrencyEffect: -0.04760906442310894, netPerformanceWithCurrencyEffect: -2732.737808972287, quantity: 0.5614682, - sectors: [], - symbol: 'bitcoin', tags: [], - url: undefined, valueInBaseCurrency: 54666.7898248 }, { activitiesCount: 1, allocationInPercentage: 0.04307127421937313, - assetClass: 'EQUITY', - assetClassLabel: 'Equity', assetProfile: { assetClass: 'EQUITY', + assetClassLabel: 'Equity', assetSubClass: 'STOCK', + assetSubClassLabel: 'Stock', countries: [ { code: 'US', @@ -256,60 +188,40 @@ export const holdings: PortfolioPosition[] = [ currency: 'USD', dataSource: 'YAHOO', holdings: [], + name: 'Microsoft Corporation', sectors: [ { name: 'Technology', weight: 1 } ], - symbol: 'MSFT' + symbol: 'MSFT', + url: 'https://www.microsoft.com' }, - assetSubClass: 'STOCK', - assetSubClassLabel: 'Stock', - countries: [ - { - code: 'US', - continent: 'North America', - name: 'United States', - weight: 1 - } - ], - currency: 'USD', - dataSource: 'YAHOO', dateOfFirstActivity: new Date('2023-01-03T00:00:00.000Z'), dividend: 0, grossPerformance: 5065.5, grossPerformancePercent: 0.7047750229568411, grossPerformancePercentWithCurrencyEffect: 0.7047750229568411, grossPerformanceWithCurrencyEffect: 5065.5, - holdings: [], investment: 7187.4, marketPrice: 408.43, - name: 'Microsoft Corporation', netPerformance: 5065.5, netPerformancePercent: 0.7047750229568411, netPerformancePercentWithCurrencyEffect: -0.015973588391056275, netPerformanceWithCurrencyEffect: -198.899926757814, quantity: 30, - sectors: [ - { - name: 'Technology', - weight: 1 - } - ], - symbol: 'MSFT', tags: [], - url: 'https://www.microsoft.com', valueInBaseCurrency: 12252.9 }, { activitiesCount: 1, allocationInPercentage: 0.18762679306394897, - assetClass: 'EQUITY', - assetClassLabel: 'Equity', assetProfile: { assetClass: 'EQUITY', + assetClassLabel: 'Equity', assetSubClass: 'STOCK', + assetSubClassLabel: 'Stock', countries: [ { code: 'US', @@ -321,60 +233,40 @@ export const holdings: PortfolioPosition[] = [ currency: 'USD', dataSource: 'YAHOO', holdings: [], + name: 'Tesla, Inc.', sectors: [ { name: 'Consumer Discretionary', weight: 1 } ], - symbol: 'TSLA' + symbol: 'TSLA', + url: 'https://www.tesla.com' }, - assetSubClass: 'STOCK', - assetSubClassLabel: 'Stock', - countries: [ - { - code: 'US', - continent: 'North America', - name: 'United States', - weight: 1 - } - ], - currency: 'USD', - dataSource: 'YAHOO', dateOfFirstActivity: new Date('2017-01-03T00:00:00.000Z'), dividend: 0, grossPerformance: 51227.500000005, grossPerformancePercent: 23.843379101756675, grossPerformancePercentWithCurrencyEffect: 23.843379101756675, grossPerformanceWithCurrencyEffect: 51227.500000005, - holdings: [], investment: 2148.499999995, marketPrice: 355.84, - name: 'Tesla, Inc.', netPerformance: 51197.500000005, netPerformancePercent: 23.829415871596066, netPerformancePercentWithCurrencyEffect: -0.12051410125545206, netPerformanceWithCurrencyEffect: -7314.00091552734, quantity: 150, - sectors: [ - { - name: 'Consumer Discretionary', - weight: 1 - } - ], - symbol: 'TSLA', tags: [], - url: 'https://www.tesla.com', valueInBaseCurrency: 53376 }, { activitiesCount: 5, allocationInPercentage: 0.053051250766657634, - assetClass: 'EQUITY', - assetClassLabel: 'Equity', assetProfile: { assetClass: 'EQUITY', + assetClassLabel: 'Equity', assetSubClass: 'ETF', + assetSubClassLabel: 'ETF', countries: [ { code: 'US', @@ -386,50 +278,30 @@ export const holdings: PortfolioPosition[] = [ currency: 'USD', dataSource: 'YAHOO', holdings: [], + name: 'Vanguard Total Stock Market Index Fund ETF Shares', sectors: [ { name: 'Equity', weight: 1 } ], - symbol: 'VTI' + symbol: 'VTI', + url: 'https://www.vanguard.com' }, - assetSubClass: 'ETF', - assetSubClassLabel: 'ETF', - countries: [ - { - code: 'US', - weight: 1, - continent: 'North America', - name: 'United States' - } - ], - currency: 'USD', - dataSource: 'YAHOO', dateOfFirstActivity: new Date('2019-03-01T00:00:00.000Z'), dividend: 0, grossPerformance: 6845.8, grossPerformancePercent: 1.0164758094605268, grossPerformancePercentWithCurrencyEffect: 1.0164758094605268, grossPerformanceWithCurrencyEffect: 6845.8, - holdings: [], investment: 8246.2, marketPrice: 301.84, - name: 'Vanguard Total Stock Market Index Fund ETF Shares', netPerformance: 6746.3, netPerformancePercent: 1.0017018833976383, netPerformancePercentWithCurrencyEffect: 0.01085061564051406, netPerformanceWithCurrencyEffect: 161.99969482422, quantity: 50, - sectors: [ - { - name: 'Equity', - weight: 1 - } - ], - symbol: 'VTI', tags: [], - url: 'https://www.vanguard.com', valueInBaseCurrency: 15092 } ]; diff --git a/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.html b/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.html index f5dbac698..33bde3fd6 100644 --- a/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.html +++ b/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.html @@ -29,18 +29,19 @@ [compareWith]="holdingComparisonFunction" > {{ - filterForm.get('holding')?.value?.name + filterForm.get('holding')?.value?.assetProfile?.name }} - @for (holding of holdings(); track holding.name) { + @for (holding of holdings(); track holding.assetProfile.name) {
    {{ holding.name }}{{ holding.assetProfile.name }}
    {{ holding.symbol | gfSymbol }} · {{ holding.currency }}{{ holding.assetProfile.symbol | gfSymbol }} · + {{ holding.assetProfile.currency }}
    diff --git a/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.ts b/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.ts index c1f82315c..20e8b0f0f 100644 --- a/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.ts +++ b/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.ts @@ -109,7 +109,8 @@ export class GfPortfolioFilterFormComponent } return ( - getAssetProfileIdentifier(option) === getAssetProfileIdentifier(value) + getAssetProfileIdentifier(option.assetProfile) === + getAssetProfileIdentifier(value.assetProfile) ); } From 697ef59e3b58bebc5c21a9e482e4f5643390f316 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 31 May 2026 07:17:55 +0200 Subject: [PATCH 126/132] Feature/add support for web fetch in fetch service (#6960) * Add support for web fetch * Update changelog --- CHANGELOG.md | 4 + apps/api/src/services/fetch/fetch.module.ts | 2 + apps/api/src/services/fetch/fetch.service.ts | 154 +++++++++++++++++- .../interfaces/web-fetch-route.interface.ts | 19 +++ libs/common/src/lib/config.ts | 1 + 5 files changed, 174 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/services/fetch/interfaces/web-fetch-route.interface.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e0c416fb0..d094f394b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Added support for routing selected requests through the _OpenRouter_ `web_fetch` tool in the `FetchService` + ### Changed - Extended the countries mapping in the data enhancer for asset profile data via _Trackinsight_ diff --git a/apps/api/src/services/fetch/fetch.module.ts b/apps/api/src/services/fetch/fetch.module.ts index f98f2f45c..16e6f5f5d 100644 --- a/apps/api/src/services/fetch/fetch.module.ts +++ b/apps/api/src/services/fetch/fetch.module.ts @@ -1,9 +1,11 @@ import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service'; +import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { Module } from '@nestjs/common'; @Module({ exports: [FetchService], + imports: [PropertyModule], providers: [FetchService] }) export class FetchModule {} diff --git a/apps/api/src/services/fetch/fetch.service.ts b/apps/api/src/services/fetch/fetch.service.ts index b3bd022d9..f32e56a1c 100644 --- a/apps/api/src/services/fetch/fetch.service.ts +++ b/apps/api/src/services/fetch/fetch.service.ts @@ -1,15 +1,35 @@ import { redactPaths } from '@ghostfolio/api/helper/object.helper'; +import { PropertyService } from '@ghostfolio/api/services/property/property.service'; +import { + PROPERTY_API_KEY_OPENROUTER, + PROPERTY_OPENROUTER_MODEL, + PROPERTY_WEB_FETCH_ROUTES +} from '@ghostfolio/common/config'; -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { createOpenRouter } from '@openrouter/ai-sdk-provider'; +import { generateText, jsonSchema, tool } from 'ai'; +import ms from 'ms'; + +import { WebFetchRoute } from './interfaces/web-fetch-route.interface'; @Injectable() -export class FetchService { +export class FetchService implements OnModuleInit { private static readonly REDACTED_QUERY_PARAM_NAMES = ['apikey', 'api_token']; + private static readonly WEB_FETCH_TIMEOUT = ms('30 seconds'); + + private webFetchRoutes: WebFetchRoute[] = []; + + public constructor(private readonly propertyService: PropertyService) {} - public async fetch( - input: RequestInfo | URL, - init?: RequestInit - ): Promise { + public async onModuleInit() { + this.webFetchRoutes = + (await this.propertyService.getByKey( + PROPERTY_WEB_FETCH_ROUTES + )) ?? []; + } + + public async fetch(input: RequestInfo | URL, init?: RequestInit) { const method = ( init?.method ?? (input instanceof Request ? input.method : undefined) ?? @@ -21,6 +41,21 @@ export class FetchService { Logger.debug(`${method} ${urlRedacted}`, 'FetchService'); + if (method === 'GET') { + const webFetchRoute = this.getMatchingWebFetchRoute(url); + + if (webFetchRoute) { + const response = await this.fetchViaWebFetchTool({ + url, + webFetchRoute + }); + + if (response) { + return response; + } + } + } + try { return await globalThis.fetch(input, init); } catch (error) { @@ -40,6 +75,113 @@ export class FetchService { } } + private async fetchViaWebFetchTool({ + url, + webFetchRoute + }: { + url: string; + webFetchRoute: WebFetchRoute; + }) { + const [openRouterApiKey, openRouterModel] = await Promise.all([ + this.propertyService.getByKey(PROPERTY_API_KEY_OPENROUTER), + this.propertyService.getByKey(PROPERTY_OPENROUTER_MODEL) + ]); + + if (!openRouterApiKey || !openRouterModel) { + return undefined; + } + + try { + const openRouterService = createOpenRouter({ apiKey: openRouterApiKey }); + + const { sources, text } = await generateText({ + model: openRouterService.chat(openRouterModel), + prompt: [ + 'You have access to a web_fetch tool. You MUST call it to retrieve the URL below, do not answer from prior knowledge.', + 'Return the fetched response body exactly as received: raw body only, no commentary, no Markdown, and no code fences.', + `URL: ${url}` + ].join('\n'), + timeout: FetchService.WEB_FETCH_TIMEOUT, + tools: { + // Provider-defined tool: lets OpenRouter perform the actual web + // request server-side via its `web_fetch` engine. `id` and `args` + // are the OpenRouter-specific identifiers; the input schema is left + // open as the arguments are supplied by the model. + web_fetch: tool({ + args: { engine: 'openrouter' }, + id: 'openrouter.web_fetch', + inputSchema: jsonSchema({ + additionalProperties: true, + type: 'object' + }), + type: 'provider' + }) + } + }); + + const candidates = [ + ...(sources ?? []).map((source) => { + return source.providerMetadata?.openrouter?.content; + }), + text + ]; + + for (const candidate of candidates) { + if (typeof candidate !== 'string') { + continue; + } + + const body = candidate.trim(); + + if (!body) { + continue; + } + + if (webFetchRoute.responseContentType?.includes('application/json')) { + try { + JSON.parse(body); + } catch { + continue; + } + } + + Logger.debug( + `Routed ${this.redactUrl(url)} via web fetch tool`, + 'FetchService' + ); + + return new Response(body, { + headers: webFetchRoute.responseContentType + ? { 'content-type': webFetchRoute.responseContentType } + : undefined + }); + } + + return undefined; + } catch (error) { + Logger.error( + `Web fetch tool failed for ${this.redactUrl(url)}: ${ + error instanceof Error ? error.message : String(error) + }`, + 'FetchService' + ); + + return undefined; + } + } + + private getMatchingWebFetchRoute(url: string) { + try { + const { hostname } = new URL(url); + + return this.webFetchRoutes.find(({ domain }) => { + return hostname === domain || hostname.endsWith(`.${domain}`); + }); + } catch { + return undefined; + } + } + private redactUrl(rawUrl: string): string { try { const url = new URL(rawUrl); diff --git a/apps/api/src/services/fetch/interfaces/web-fetch-route.interface.ts b/apps/api/src/services/fetch/interfaces/web-fetch-route.interface.ts new file mode 100644 index 000000000..efff09398 --- /dev/null +++ b/apps/api/src/services/fetch/interfaces/web-fetch-route.interface.ts @@ -0,0 +1,19 @@ +/** + * Routes outgoing GET requests for a given domain through the OpenRouter + * `web_fetch` tool instead of a direct network request. + * + * Configured via the `WEB_FETCH_ROUTES` property as a JSON array, e.g. + * + * [ + * { + * "domain": "example.com", + * "responseContentType": "application/json" + * } + * ] + * + * Matches the domain itself and its subdomains (e.g. `api.example.com`). + */ +export interface WebFetchRoute { + domain: string; + responseContentType?: string; +} diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index 113dffe4a..28d902d71 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -256,6 +256,7 @@ export const PROPERTY_SLACK_COMMUNITY_USERS = 'SLACK_COMMUNITY_USERS'; export const PROPERTY_STRIPE_CONFIG = 'STRIPE_CONFIG'; export const PROPERTY_SYSTEM_MESSAGE = 'SYSTEM_MESSAGE'; export const PROPERTY_UPTIME = 'UPTIME'; +export const PROPERTY_WEB_FETCH_ROUTES = 'WEB_FETCH_ROUTES'; export const QUEUE_JOB_STATUS_LIST = [ 'active', From 09794639b5994dc1948731bbf26ff99c144e801e Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Tue, 2 Jun 2026 07:35:19 +0200 Subject: [PATCH 127/132] Bugfix/environment variable expansion when debugging in Visual Studio Code (#6977) * Fix environment variable expansion * Update changelog --- .vscode/launch.json | 12 ++++++++++-- CHANGELOG.md | 4 ++++ tools/load-env.ts | 4 ++++ 3 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 tools/load-env.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index c1f19e7f0..6d36314d2 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -18,12 +18,20 @@ "autoAttachChildProcesses": true, "console": "integratedTerminal", "cwd": "${workspaceFolder}/apps/api", - "envFile": "${workspaceFolder}/.env", + "env": { + "GHOSTFOLIO_ENV_FILE": "${workspaceFolder}/.env" + }, "name": "Debug API", "outFiles": ["${workspaceFolder}/dist/apps/api/**/*.js"], "program": "${workspaceFolder}/apps/api/src/main.ts", "request": "launch", - "runtimeArgs": ["--nolazy", "-r", "ts-node/register"], + "runtimeArgs": [ + "--nolazy", + "-r", + "ts-node/register", + "-r", + "${workspaceFolder}/tools/load-env.ts" + ], "skipFiles": [ "${workspaceFolder}/node_modules/**/*.js", "/**/*.js" diff --git a/CHANGELOG.md b/CHANGELOG.md index d094f394b..558cd64e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Extended the countries mapping in the data enhancer for asset profile data via _Trackinsight_ - Removed the deprecated attributes (`assetClass`, `assetClassLabel`, `assetSubClass`, `assetSubClassLabel`, `countries`, `currency`, `dataSource`, `holdings`, `name`, `sectors`, `symbol` and `url`) from the holdings of the portfolio details endpoint response +### Fixed + +- Fixed the environment variable expansion in the `.env` file when debugging via _Visual Studio Code_ + ## 3.6.0 - 2026-05-28 ### Added diff --git a/tools/load-env.ts b/tools/load-env.ts new file mode 100644 index 000000000..3dd0d03c7 --- /dev/null +++ b/tools/load-env.ts @@ -0,0 +1,4 @@ +import { config } from 'dotenv'; +import { expand } from 'dotenv-expand'; + +expand(config({ path: process.env.GHOSTFOLIO_ENV_FILE, quiet: true })); From 60be4de9a5c91519a5e19501940783972c7b5dd7 Mon Sep 17 00:00:00 2001 From: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com> Date: Tue, 2 Jun 2026 21:57:09 +0700 Subject: [PATCH 128/132] Task/improve type safety in analysis page component (#6965) * fix(client): resolve errors in analysis page component * feat(client): replace constructor based DI with inject functions * feat(client): convert to view child signal * feat(client): enforce encapsulation * feat(client): enforce readability * feat(client): replace deprecated getDeviceInfo * feat(client): convert mode to signal --- .../analysis/analysis-page.component.ts | 153 ++++++++++-------- .../portfolio/analysis/analysis-page.html | 8 +- 2 files changed, 88 insertions(+), 73 deletions(-) diff --git a/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts b/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts index 03fd0767a..6c49a9030 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts @@ -2,7 +2,10 @@ import { GfBenchmarkComparatorComponent } from '@ghostfolio/client/components/be import { GfInvestmentChartComponent } from '@ghostfolio/client/components/investment-chart/investment-chart.component'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; -import { NUMERICAL_PRECISION_THRESHOLD_6_FIGURES } from '@ghostfolio/common/config'; +import { + DEFAULT_DATE_RANGE, + NUMERICAL_PRECISION_THRESHOLD_6_FIGURES +} from '@ghostfolio/common/config'; import { HistoricalDataItem, InvestmentItem, @@ -24,9 +27,12 @@ import { Clipboard } from '@angular/cdk/clipboard'; import { ChangeDetectorRef, Component, + computed, DestroyRef, + inject, OnInit, - ViewChild + signal, + viewChild } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatButtonModule } from '@angular/material/button'; @@ -64,53 +70,57 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; templateUrl: './analysis-page.html' }) export class GfAnalysisPageComponent implements OnInit { - @ViewChild(MatMenuTrigger) actionsMenuButton!: MatMenuTrigger; - - public benchmark: Partial; - public benchmarkDataItems: HistoricalDataItem[] = []; - public benchmarks: Partial[]; - public bottom3: PortfolioPosition[]; - public deviceType: string; - public dividendsByGroup: InvestmentItem[]; - public dividendTimelineDataLabel = $localize`Dividend`; - public firstOrderDate: Date; - public hasImpersonationId: boolean; - public hasPermissionToReadAiPrompt: boolean; - public investments: InvestmentItem[]; - public investmentTimelineDataLabel = $localize`Investment`; - public investmentsByGroup: InvestmentItem[]; - public isLoadingAnalysisPrompt: boolean; - public isLoadingBenchmarkComparator: boolean; - public isLoadingDividendTimelineChart: boolean; - public isLoadingInvestmentChart: boolean; - public isLoadingInvestmentTimelineChart: boolean; - public isLoadingPortfolioPrompt: boolean; - public mode: GroupBy = 'month'; - public modeOptions: ToggleOption[] = [ + protected benchmark?: Partial; + protected benchmarkDataItems: HistoricalDataItem[] = []; + protected readonly benchmarks: Partial[]; + protected bottom3: PortfolioPosition[]; + protected dividendsByGroup: InvestmentItem[]; + protected readonly dividendTimelineDataLabel = $localize`Dividend`; + protected hasImpersonationId: boolean; + protected hasPermissionToReadAiPrompt: boolean; + protected investments: InvestmentItem[]; + protected readonly investmentTimelineDataLabel = $localize`Investment`; + protected investmentsByGroup: InvestmentItem[]; + protected isLoadingAnalysisPrompt: boolean; + protected isLoadingBenchmarkComparator: boolean; + protected isLoadingDividendTimelineChart: boolean; + protected isLoadingInvestmentChart: boolean; + protected isLoadingInvestmentTimelineChart: boolean; + protected isLoadingPortfolioPrompt: boolean; + protected readonly mode = signal('month'); + protected readonly modeOptions: ToggleOption[] = [ { label: $localize`Monthly`, value: 'month' }, { label: $localize`Yearly`, value: 'year' } ]; - public performance: PortfolioPerformance; - public performanceDataItems: HistoricalDataItem[]; - public performanceDataItemsInPercentage: HistoricalDataItem[]; - public portfolioEvolutionDataLabel = $localize`Investment`; - public precision = 2; - public streaks: PortfolioInvestmentsResponse['streaks']; - public top3: PortfolioPosition[]; - public unitCurrentStreak: string; - public unitLongestStreak: string; - public user: User; - - public constructor( - private changeDetectorRef: ChangeDetectorRef, - private clipboard: Clipboard, - private dataService: DataService, - private destroyRef: DestroyRef, - private deviceDetectorService: DeviceDetectorService, - private impersonationStorageService: ImpersonationStorageService, - private snackBar: MatSnackBar, - private userService: UserService - ) { + protected performance: PortfolioPerformance; + protected performanceDataItems: HistoricalDataItem[]; + protected performanceDataItemsInPercentage: HistoricalDataItem[]; + protected readonly portfolioEvolutionDataLabel = $localize`Investment`; + protected precision = 2; + protected streaks: PortfolioInvestmentsResponse['streaks']; + protected top3: PortfolioPosition[]; + protected unitCurrentStreak: string; + protected unitLongestStreak: string; + protected user: User; + + private readonly actionsMenuButton = viewChild.required(MatMenuTrigger); + private readonly deviceType = computed( + () => this.deviceDetectorService.deviceInfo().deviceType + ); + private firstOrderDate: Date; + + private readonly changeDetectorRef = inject(ChangeDetectorRef); + private readonly clipboard = inject(Clipboard); + private readonly dataService = inject(DataService); + private readonly destroyRef = inject(DestroyRef); + private readonly deviceDetectorService = inject(DeviceDetectorService); + private readonly impersonationStorageService = inject( + ImpersonationStorageService + ); + private readonly snackBar = inject(MatSnackBar); + private readonly userService = inject(UserService); + + public constructor() { const { benchmarks } = this.dataService.fetchInfo(); this.benchmarks = benchmarks; @@ -123,14 +133,16 @@ export class GfAnalysisPageComponent implements OnInit { ? undefined : this.user?.settings?.savingsRate; - return this.mode === 'year' + if (savingsRatePerMonth === undefined) { + return undefined; + } + + return this.mode() === 'year' ? savingsRatePerMonth * 12 : savingsRatePerMonth; } public ngOnInit() { - this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; - this.impersonationStorageService .onChangeHasImpersonation() .pipe(takeUntilDestroyed(this.destroyRef)) @@ -158,7 +170,7 @@ export class GfAnalysisPageComponent implements OnInit { }); } - public onChangeBenchmark(symbolProfileId: string) { + protected onChangeBenchmark(symbolProfileId: string) { this.dataService .putUserSetting({ benchmark: symbolProfileId }) .pipe(takeUntilDestroyed(this.destroyRef)) @@ -174,12 +186,12 @@ export class GfAnalysisPageComponent implements OnInit { }); } - public onChangeGroupBy(aMode: GroupBy) { - this.mode = aMode; + protected onChangeGroupBy(aMode: GroupBy) { + this.mode.set(aMode); this.fetchDividendsAndInvestments(); } - public onCopyPromptToClipboard(mode: AiPromptMode) { + protected onCopyPromptToClipboard(mode: AiPromptMode) { if (mode === 'analysis') { this.isLoadingAnalysisPrompt = true; } else if (mode === 'portfolio') { @@ -210,7 +222,7 @@ export class GfAnalysisPageComponent implements OnInit { window.open('https://duck.ai', '_blank'); }); - this.actionsMenuButton.closeMenu(); + this.actionsMenuButton().closeMenu(); if (mode === 'analysis') { this.isLoadingAnalysisPrompt = false; @@ -227,8 +239,8 @@ export class GfAnalysisPageComponent implements OnInit { this.dataService .fetchDividends({ filters: this.userService.getFilters(), - groupBy: this.mode, - range: this.user?.settings?.dateRange + groupBy: this.mode(), + range: this.user?.settings?.dateRange ?? DEFAULT_DATE_RANGE }) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ dividends }) => { @@ -242,15 +254,15 @@ export class GfAnalysisPageComponent implements OnInit { this.dataService .fetchInvestments({ filters: this.userService.getFilters(), - groupBy: this.mode, - range: this.user?.settings?.dateRange + groupBy: this.mode(), + range: this.user?.settings?.dateRange ?? DEFAULT_DATE_RANGE }) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ investments, streaks }) => { this.investmentsByGroup = investments; this.streaks = streaks; this.unitCurrentStreak = - this.mode === 'year' + this.mode() === 'year' ? this.streaks?.currentStreak === 1 ? translate('YEAR') : translate('YEARS') @@ -258,7 +270,7 @@ export class GfAnalysisPageComponent implements OnInit { ? translate('MONTH') : translate('MONTHS'); this.unitLongestStreak = - this.mode === 'year' + this.mode() === 'year' ? this.streaks?.longestStreak === 1 ? translate('YEAR') : translate('YEARS') @@ -278,7 +290,7 @@ export class GfAnalysisPageComponent implements OnInit { this.dataService .fetchPortfolioPerformance({ filters: this.userService.getFilters(), - range: this.user?.settings?.dateRange + range: this.user?.settings?.dateRange ?? DEFAULT_DATE_RANGE }) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ chart, firstOrderDate, performance }) => { @@ -298,13 +310,16 @@ export class GfAnalysisPageComponent implements OnInit { valueInPercentage, valueWithCurrencyEffect } - ] of chart.entries()) { + ] of (chart ?? []).entries()) { + // Ignore first item where value is 0 if (index > 0 || this.user?.settings?.dateRange === 'max') { - // Ignore first item where value is 0 - this.investments.push({ - date, - investment: totalInvestmentValueWithCurrencyEffect - }); + if (totalInvestmentValueWithCurrencyEffect !== undefined) { + this.investments.push({ + date, + investment: totalInvestmentValueWithCurrencyEffect + }); + } + this.performanceDataItems.push({ date, value: isNumber(valueWithCurrencyEffect) @@ -320,7 +335,7 @@ export class GfAnalysisPageComponent implements OnInit { } if ( - this.deviceType === 'mobile' && + this.deviceType() === 'mobile' && this.performance.currentValueInBaseCurrency >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES ) { @@ -387,7 +402,7 @@ export class GfAnalysisPageComponent implements OnInit { dataSource, symbol, filters: this.userService.getFilters(), - range: this.user?.settings?.dateRange, + range: this.user?.settings?.dateRange ?? DEFAULT_DATE_RANGE, startDate: this.firstOrderDate }) .pipe(takeUntilDestroyed(this.destroyRef)) diff --git a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html index b93be9182..ec90fccec 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html @@ -442,7 +442,7 @@
    Date: Tue, 2 Jun 2026 19:30:01 +0200 Subject: [PATCH 129/132] Task/upgrade Nx to version 22.7.5 (#6962) * Upgrade Nx to version 22.7.5 * Update changelog --- CHANGELOG.md | 1 + package-lock.json | 678 +++++++++++++++++++++++----------------------- package.json | 22 +- 3 files changed, 347 insertions(+), 354 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 558cd64e6..60a33a4b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Extended the countries mapping in the data enhancer for asset profile data via _Trackinsight_ - Removed the deprecated attributes (`assetClass`, `assetClassLabel`, `assetSubClass`, `assetSubClassLabel`, `countries`, `currency`, `dataSource`, `holdings`, `name`, `sectors`, `symbol` and `url`) from the holdings of the portfolio details endpoint response +- Upgraded `Nx` from version `22.7.2` to `22.7.5` ### Fixed diff --git a/package-lock.json b/package-lock.json index c9abbd50d..6330ef21f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -114,16 +114,16 @@ "@eslint/js": "9.35.0", "@nestjs/schematics": "11.1.0", "@nestjs/testing": "11.1.21", - "@nx/angular": "22.7.2", - "@nx/eslint-plugin": "22.7.2", - "@nx/jest": "22.7.2", - "@nx/js": "22.7.2", - "@nx/module-federation": "22.7.2", - "@nx/nest": "22.7.2", - "@nx/node": "22.7.2", - "@nx/storybook": "22.7.2", - "@nx/web": "22.7.2", - "@nx/workspace": "22.7.2", + "@nx/angular": "22.7.5", + "@nx/eslint-plugin": "22.7.5", + "@nx/jest": "22.7.5", + "@nx/js": "22.7.5", + "@nx/module-federation": "22.7.5", + "@nx/nest": "22.7.5", + "@nx/node": "22.7.5", + "@nx/storybook": "22.7.5", + "@nx/web": "22.7.5", + "@nx/workspace": "22.7.5", "@schematics/angular": "21.2.6", "@storybook/addon-docs": "10.1.10", "@storybook/addon-themes": "10.1.10", @@ -150,7 +150,7 @@ "jest": "30.2.0", "jest-environment-jsdom": "30.2.0", "jest-preset-angular": "16.0.0", - "nx": "22.7.2", + "nx": "22.7.5", "prettier": "3.8.3", "prettier-plugin-organize-attributes": "1.0.0", "prisma": "7.8.0", @@ -7382,13 +7382,13 @@ } }, "node_modules/@module-federation/bridge-react-webpack-plugin": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@module-federation/bridge-react-webpack-plugin/-/bridge-react-webpack-plugin-2.4.0.tgz", - "integrity": "sha512-yxDv/FJoLiKo2eqIcEWvSnSpJgyYkCzJvNaFsQ2QE3rNv68IeAarlSzCo+d0QyQoPJnTETyHsOh1SSBazIzecw==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@module-federation/bridge-react-webpack-plugin/-/bridge-react-webpack-plugin-2.5.0.tgz", + "integrity": "sha512-Ux9XVW//K6K+KHKPdc0Jnc7RtTpZaEXgbVhp5yovtFkCJVt8hEClcTeuI18MvvLiV/q2hUpCU5Wsf9zNaIYStQ==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/sdk": "2.4.0", + "@module-federation/sdk": "2.5.0", "@types/semver": "7.5.8", "semver": "7.6.3" } @@ -7407,14 +7407,14 @@ } }, "node_modules/@module-federation/cli": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@module-federation/cli/-/cli-2.4.0.tgz", - "integrity": "sha512-c46g9srroc2hDfrlHyd4Y404SLnw3v9t7Kqij+yK01Hx8C2FyZpyanTGUHVyrmzqp/0y3lPrWURUHkHfk/cJQA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@module-federation/cli/-/cli-2.5.0.tgz", + "integrity": "sha512-+czXA6yoiiF9W6+YEOCpQE6zpGZpA89X0oCEz3EaWPTkL4chEbxurjpME8CMnJk9iuFxl167+cBQiQlVBiHGGg==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/dts-plugin": "2.4.0", - "@module-federation/sdk": "2.4.0", + "@module-federation/dts-plugin": "2.5.0", + "@module-federation/sdk": "2.5.0", "commander": "11.1.0", "jiti": "2.4.2" }, @@ -7426,16 +7426,16 @@ } }, "node_modules/@module-federation/dts-plugin": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@module-federation/dts-plugin/-/dts-plugin-2.4.0.tgz", - "integrity": "sha512-sa6v5ByyqMRHzpwDu0zc7s5mZ39EFIkG0jkRfZU09pzkrJEIy4uZ1Kt9SLysFB8RBMIAvAakAfqDlVWvf1lndg==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@module-federation/dts-plugin/-/dts-plugin-2.5.0.tgz", + "integrity": "sha512-q7KDhJ5tn2HrUV7uMuh/L3TaaztUosE+4LAb90sxx0pPPqWRwlpBpxu1REubv5BWXmU1K/Ozn14u6jRbjLVaGA==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/error-codes": "2.4.0", - "@module-federation/managers": "2.4.0", - "@module-federation/sdk": "2.4.0", - "@module-federation/third-party-dts-extractor": "2.4.0", + "@module-federation/error-codes": "2.5.0", + "@module-federation/managers": "2.5.0", + "@module-federation/sdk": "2.5.0", + "@module-federation/third-party-dts-extractor": "2.5.0", "adm-zip": "0.5.10", "ansi-colors": "4.1.3", "isomorphic-ws": "5.0.0", @@ -7464,23 +7464,23 @@ } }, "node_modules/@module-federation/enhanced": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@module-federation/enhanced/-/enhanced-2.4.0.tgz", - "integrity": "sha512-NiccK03x7V6bK2LvJNuW520kT+Onx+LJe8lyPsENjXctECCIFJdJOmYr8ABif/kLayWKrrYCzCGVNNiQXANEGQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@module-federation/bridge-react-webpack-plugin": "2.4.0", - "@module-federation/cli": "2.4.0", - "@module-federation/dts-plugin": "2.4.0", - "@module-federation/error-codes": "2.4.0", - "@module-federation/inject-external-runtime-core-plugin": "2.4.0", - "@module-federation/managers": "2.4.0", - "@module-federation/manifest": "2.4.0", - "@module-federation/rspack": "2.4.0", - "@module-federation/runtime-tools": "2.4.0", - "@module-federation/sdk": "2.4.0", - "@module-federation/webpack-bundler-runtime": "2.4.0", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@module-federation/enhanced/-/enhanced-2.5.0.tgz", + "integrity": "sha512-P91tzwyKSCQ6AwirqvAvTqWqmTY79ndpH0uenejFw+bbLpWrjuY0q+iZUXCV/7CSNmqwH2bkA/ssuyZljmcMVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@module-federation/bridge-react-webpack-plugin": "2.5.0", + "@module-federation/cli": "2.5.0", + "@module-federation/dts-plugin": "2.5.0", + "@module-federation/error-codes": "2.5.0", + "@module-federation/inject-external-runtime-core-plugin": "2.5.0", + "@module-federation/managers": "2.5.0", + "@module-federation/manifest": "2.5.0", + "@module-federation/rspack": "2.5.0", + "@module-federation/runtime-tools": "2.5.0", + "@module-federation/sdk": "2.5.0", + "@module-federation/webpack-bundler-runtime": "2.5.0", "schema-utils": "4.3.0", "tapable": "2.3.0", "upath": "2.0.1" @@ -7544,56 +7544,56 @@ } }, "node_modules/@module-federation/error-codes": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-2.4.0.tgz", - "integrity": "sha512-ktCZtwOoiKR1URJyBt223OsOFAUvc13rICYif55mt7+DomtELlh5FicnEz6mPLBUwmNM9vyBMvkxOdp+fQ5oUg==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-2.5.0.tgz", + "integrity": "sha512-sq05/8Gp3csy1nr2/f76K3vLy0/xRqVtP71ibGy8BiLg7h1UxWN7G4EwAKSrPZ4FnsERGeFlIszg5Z+MqlwhFg==", "dev": true, "license": "MIT" }, "node_modules/@module-federation/inject-external-runtime-core-plugin": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@module-federation/inject-external-runtime-core-plugin/-/inject-external-runtime-core-plugin-2.4.0.tgz", - "integrity": "sha512-GucUMQmQXcnJC/OnJGvMz3Qy7ap8nAffhQPwDpOSi0Qwm+Iq/ppzG8N3tlLBDmv/O8hiF8HHlg789XK2kcCQtg==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@module-federation/inject-external-runtime-core-plugin/-/inject-external-runtime-core-plugin-2.5.0.tgz", + "integrity": "sha512-e2KyTHpesBrPXGHMh4d4+s2xBiNoxbiFJkPRYHMCl81a/Gu+byrMkriZcV4VM/TFvBIlrgOJisVc1nnBI5UDRQ==", "dev": true, "license": "MIT", "peerDependencies": { - "@module-federation/runtime-tools": "2.4.0" + "@module-federation/runtime-tools": "2.5.0" } }, "node_modules/@module-federation/managers": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@module-federation/managers/-/managers-2.4.0.tgz", - "integrity": "sha512-Z8j6aog44G1gt4yIAaeDowwZ7xg0aAxTA1Hq69euJK9cR9MDEaLbLUk57jDoiRj6xLwlCiw7ozY+U15BQATk6Q==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@module-federation/managers/-/managers-2.5.0.tgz", + "integrity": "sha512-9b5mU/7OYbKrYUJmhZ1kkfeJCZqR7qX6/FWp+oOfZMzUynN7Rb41dwoUs3TdnOKzbZ3CCwtZ2WsR4pF9ZNvuJA==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/sdk": "2.4.0", + "@module-federation/sdk": "2.5.0", "find-pkg": "2.0.0" } }, "node_modules/@module-federation/manifest": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@module-federation/manifest/-/manifest-2.4.0.tgz", - "integrity": "sha512-ZL+W5rbtgRf9TWRP7Dupt/Svia4bJEOS6gWSj9jzemiLPRPkMO5hjWZKVHIc8oG+Vb25yzozFMmQ+luGi695wg==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@module-federation/manifest/-/manifest-2.5.0.tgz", + "integrity": "sha512-pmwQCGWjM2oKY7CkR7nEDOfMK0bNFJUifuDxuOB5iOWhU+Rp92UyyBI9IbJAtiISTSFGtuKRy40peJGvQq2VcQ==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/dts-plugin": "2.4.0", - "@module-federation/managers": "2.4.0", - "@module-federation/sdk": "2.4.0", + "@module-federation/dts-plugin": "2.5.0", + "@module-federation/managers": "2.5.0", + "@module-federation/sdk": "2.5.0", "find-pkg": "2.0.0" } }, "node_modules/@module-federation/node": { - "version": "2.7.42", - "resolved": "https://registry.npmjs.org/@module-federation/node/-/node-2.7.42.tgz", - "integrity": "sha512-aX/T4L9bPbOgNLIW+30k/dA2Iohoy9/jf4yG1ka6Hkuo5h7iEBeZiQkwIqC06cnCbtKL1HnAiYlXHmrDPW5xvg==", + "version": "2.7.43", + "resolved": "https://registry.npmjs.org/@module-federation/node/-/node-2.7.43.tgz", + "integrity": "sha512-oKoLm7dqb5EvkiNIfsEdLmmBX7XLWHtPSx3M9kEYuXAaNAppoRWC9WtgrrZYXWErB2BG9wxMlx/8Xq3awRUCdQ==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/enhanced": "2.4.0", - "@module-federation/runtime": "2.4.0", - "@module-federation/sdk": "2.4.0", + "@module-federation/enhanced": "2.5.0", + "@module-federation/runtime": "2.5.0", + "@module-federation/sdk": "2.5.0", "encoding": "0.1.13", "node-fetch": "2.7.0", "tapable": "2.3.0" @@ -7608,19 +7608,19 @@ } }, "node_modules/@module-federation/rspack": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@module-federation/rspack/-/rspack-2.4.0.tgz", - "integrity": "sha512-NWH5Vaj/fA9R7PfbwTuE1Ty/pfiAt12On0E3FzoeVPCyb5MxO1i0z+xxRHbPhF4ZOrAPGEMaMQ8Z9vH94EiElw==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@module-federation/rspack/-/rspack-2.5.0.tgz", + "integrity": "sha512-OAFMpMXuLEQFmWBuC1I7LNDQ8N3CDANXe0YGPWkIPNxKq5Tj/KNfDidmutoYgvXlZKOM4yKBKBsL6Xt/UvtOIw==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/bridge-react-webpack-plugin": "2.4.0", - "@module-federation/dts-plugin": "2.4.0", - "@module-federation/inject-external-runtime-core-plugin": "2.4.0", - "@module-federation/managers": "2.4.0", - "@module-federation/manifest": "2.4.0", - "@module-federation/runtime-tools": "2.4.0", - "@module-federation/sdk": "2.4.0" + "@module-federation/bridge-react-webpack-plugin": "2.5.0", + "@module-federation/dts-plugin": "2.5.0", + "@module-federation/inject-external-runtime-core-plugin": "2.5.0", + "@module-federation/managers": "2.5.0", + "@module-federation/manifest": "2.5.0", + "@module-federation/runtime-tools": "2.5.0", + "@module-federation/sdk": "2.5.0" }, "peerDependencies": { "@rspack/core": "^0.7.0 || ^1.0.0 || ^2.0.0-0", @@ -7637,43 +7637,43 @@ } }, "node_modules/@module-federation/runtime": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@module-federation/runtime/-/runtime-2.4.0.tgz", - "integrity": "sha512-IrLAMwUuteRgFlEkg9jrn4bk8uC897FnXvfNmkKD8/qIoNtSd+32e5ouQn+PEYbX/RjRUB1TYveY6rYHpTPkyg==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime/-/runtime-2.5.0.tgz", + "integrity": "sha512-dOc7pFEf8aruHBk5hoJLnvwkCa5ELT78q3o9dqcdaa/TT74X5z0FT0BsaGaRBPcse/iP6czK3fWd7RLv5ZKP5g==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/error-codes": "2.4.0", - "@module-federation/runtime-core": "2.4.0", - "@module-federation/sdk": "2.4.0" + "@module-federation/error-codes": "2.5.0", + "@module-federation/runtime-core": "2.5.0", + "@module-federation/sdk": "2.5.0" } }, "node_modules/@module-federation/runtime-core": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@module-federation/runtime-core/-/runtime-core-2.4.0.tgz", - "integrity": "sha512-0S8fDw28DXDW17lTQwq5vfJWe2lG0Lw3+w4vk3DVVImLwXXay+OGxLDxzWUfypWcMznfpnoAnFUMO3PtuXziuA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime-core/-/runtime-core-2.5.0.tgz", + "integrity": "sha512-STmhQ3c6/hunba2FMP6GrHazXU/8GuN7Gk4dOkWNRpnqYIoD8Wx4MNl76j3HdCzBESC7uSMXTniksVaM1+xxyA==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/error-codes": "2.4.0", - "@module-federation/sdk": "2.4.0" + "@module-federation/error-codes": "2.5.0", + "@module-federation/sdk": "2.5.0" } }, "node_modules/@module-federation/runtime-tools": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@module-federation/runtime-tools/-/runtime-tools-2.4.0.tgz", - "integrity": "sha512-BWQsGT4EWscV9bx3bVHEwp6lERBsiYm7rnPiDpwd2fx+hGEpz1IM9Pz35VryHNDXYxw7MzaAuwTMM+L7uN8OYQ==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime-tools/-/runtime-tools-2.5.0.tgz", + "integrity": "sha512-fR3Na6V78ov3/O17Mev+1vydfmqlYWP4ZNxD/bBkmqKhCO7jMdthNTT02yDljlCyhYl6+X90UJlFhwFle6rIsw==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/runtime": "2.4.0", - "@module-federation/webpack-bundler-runtime": "2.4.0" + "@module-federation/runtime": "2.5.0", + "@module-federation/webpack-bundler-runtime": "2.5.0" } }, "node_modules/@module-federation/sdk": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@module-federation/sdk/-/sdk-2.4.0.tgz", - "integrity": "sha512-eZDdF5B69W9npuka0VL24FY7XDM+YAwwfkscSeWOSqv4/8Hm0xmcmSurlP6NIOrwbeogerRCtEcnx/TFXYjoow==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@module-federation/sdk/-/sdk-2.5.0.tgz", + "integrity": "sha512-ScU22XDyV77l50njjzewMpMlNN1CYo0tHS1D6iy+vNKWrHGq8DWVB0vwG8dmvx/WZ4uq+sXgUsQet17MoKsfZw==", "dev": true, "license": "MIT", "peerDependencies": { @@ -7686,9 +7686,9 @@ } }, "node_modules/@module-federation/third-party-dts-extractor": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@module-federation/third-party-dts-extractor/-/third-party-dts-extractor-2.4.0.tgz", - "integrity": "sha512-4v24t6L3dET/6abMOM2fiM3roT0c8mi21/i+uDc6WG7U0i+Xp2SojBppTs6gnT0lkwMTe+u6xIpNQakdUftHsg==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@module-federation/third-party-dts-extractor/-/third-party-dts-extractor-2.5.0.tgz", + "integrity": "sha512-5di43LGk2ies86Cj8QyzYr540Ijc+nyPqYziyFotL6Pparnu+uf3b3ERfEyQfBmEcyGk1MpitQIO2J3bd9BcNw==", "dev": true, "license": "MIT", "dependencies": { @@ -7697,15 +7697,15 @@ } }, "node_modules/@module-federation/webpack-bundler-runtime": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@module-federation/webpack-bundler-runtime/-/webpack-bundler-runtime-2.4.0.tgz", - "integrity": "sha512-Ntx0+QsgcwtXlpGjL/Vf2PMdPjUHl07b3yM4kBc1kbRogW3Ee84QneBRi/X3w4/jlz4JKbHjD+CMXaqi2W6hgw==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@module-federation/webpack-bundler-runtime/-/webpack-bundler-runtime-2.5.0.tgz", + "integrity": "sha512-UxVad+tNZYkBnZzqJQsZa0pB5gO5cJoCjMumOo3bhzXBJVqHsFupfeHa8Nk7WrRVbJE6zRT9ZHK0s0NDWBMyJw==", "dev": true, "license": "MIT", "dependencies": { - "@module-federation/error-codes": "2.4.0", - "@module-federation/runtime": "2.4.0", - "@module-federation/sdk": "2.4.0" + "@module-federation/error-codes": "2.5.0", + "@module-federation/runtime": "2.5.0", + "@module-federation/sdk": "2.5.0" } }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { @@ -8964,20 +8964,20 @@ } }, "node_modules/@nx/angular": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/angular/-/angular-22.7.2.tgz", - "integrity": "sha512-+HCggLwJXp55ZdKrn0VkYfw9gGgZpiIHdlY8m3KnwJzdA+Tfl9t10JvidFXprk7gmnRaU8hHidfz6e1juG6D6g==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/angular/-/angular-22.7.5.tgz", + "integrity": "sha512-M+xTktTN0VBGpvFsK5u+8oMPZhD3Du2nr/b2U/EpqnfWFb2y7r7nIhQT8NYjvVlGCRyKjJi6tXNHxND6KLqr0g==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.7.2", - "@nx/eslint": "22.7.2", - "@nx/js": "22.7.2", - "@nx/module-federation": "22.7.2", - "@nx/rspack": "22.7.2", - "@nx/web": "22.7.2", - "@nx/webpack": "22.7.2", - "@nx/workspace": "22.7.2", + "@nx/devkit": "22.7.5", + "@nx/eslint": "22.7.5", + "@nx/js": "22.7.5", + "@nx/module-federation": "22.7.5", + "@nx/rspack": "22.7.5", + "@nx/web": "22.7.5", + "@nx/webpack": "22.7.5", + "@nx/workspace": "22.7.5", "@phenomnomnominal/tsquery": "~6.2.0", "@typescript-eslint/type-utils": "^8.0.0", "enquirer": "~2.3.6", @@ -9025,15 +9025,15 @@ } }, "node_modules/@nx/cypress": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/cypress/-/cypress-22.7.2.tgz", - "integrity": "sha512-ivrwIXNTn0p9nGg2z3mjZJYPuH7X+O3eMtuqyPglkxKlAhuUQXVmKYB9nIqRMGR2nGCi9cDC7J38h+0Py+TunA==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/cypress/-/cypress-22.7.5.tgz", + "integrity": "sha512-R7vlStn1ukKL9WL/dtfESKeqC38LyDvapPdSbxlBiDISCMgJAzntLcoBM22LfR3z7xy4kDk21fDMwglO3Bj30A==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.7.2", - "@nx/eslint": "22.7.2", - "@nx/js": "22.7.2", + "@nx/devkit": "22.7.5", + "@nx/eslint": "22.7.5", + "@nx/js": "22.7.5", "@phenomnomnominal/tsquery": "~6.2.0", "detect-port": "^2.1.0", "semver": "^7.6.3", @@ -9050,9 +9050,9 @@ } }, "node_modules/@nx/devkit": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/devkit/-/devkit-22.7.2.tgz", - "integrity": "sha512-oE2SFUxQeZm/EmFABHpWQ4Pi0fBKbJbXKGPvdFaHoMumRxhqBhuBVf/ap5kYFg8Y9bK/zHJkpsEbGyiyRrhvog==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/devkit/-/devkit-22.7.5.tgz", + "integrity": "sha512-/63ziS7kdHXYTLLhwWBu9hFwoFFT8xf+PkcQjsNdPqc5JmkYkSew0cE/vp5ORgBpGLWWnFPJgmfqjbJoO2C7jA==", "dev": true, "license": "MIT", "dependencies": { @@ -9121,32 +9121,32 @@ } }, "node_modules/@nx/docker": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/docker/-/docker-22.7.2.tgz", - "integrity": "sha512-VSORTGE28czjDePM5XvNnbwneowlT/6N0t0Jhh6cJtSGCCWwaT4WQb8uVYOgchDr77HwOGhgezI79mmoKHWnsw==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/docker/-/docker-22.7.5.tgz", + "integrity": "sha512-IuizX/ACvAjoTIued7eHFDaknSL6WVfDTMtzxiqaY+iDpdOwCVTC1ZXQZSMba/xEsh4owk4qnSRmJ2eWSWburw==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.7.2", + "@nx/devkit": "22.7.5", "enquirer": "~2.3.6", "tslib": "^2.3.0" } }, "node_modules/@nx/eslint": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/eslint/-/eslint-22.7.2.tgz", - "integrity": "sha512-LDWFg6CNtORnEnwB3XSJBjm8QnheN3F9HxE/kq69Fx+4drkSYEXjRx+27M+9kSP1z2HriSQn5LjEmz1yQD8stA==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/eslint/-/eslint-22.7.5.tgz", + "integrity": "sha512-D/85AvnF07ng/fLcSvAE8bxFQKvejUc/MP4pX6aFZgRGrpduo7mTwFMlM/UtOtTTPRRRQVLmM9u7jn4JKROBRw==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.7.2", - "@nx/js": "22.7.2", + "@nx/devkit": "22.7.5", + "@nx/js": "22.7.5", "semver": "^7.6.3", "tslib": "^2.3.0", "typescript": "~5.9.2" }, "peerDependencies": { - "@nx/jest": "22.7.2", + "@nx/jest": "22.7.5", "@zkochan/js-yaml": "0.0.7", "eslint": "^8.0.0 || ^9.0.0 || ^10.0.0" }, @@ -9160,14 +9160,14 @@ } }, "node_modules/@nx/eslint-plugin": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/eslint-plugin/-/eslint-plugin-22.7.2.tgz", - "integrity": "sha512-OgfyUt4dUrlTHcnygVLXcxP0KH7yOAaB9pfdWLe86QRWm2Ei4sRdACltPRXoa9tDeEa4EdBvDTccw0AZ3UnrvQ==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/eslint-plugin/-/eslint-plugin-22.7.5.tgz", + "integrity": "sha512-C9mLUAZjcAKvkAifLNxNBWzvX9RFc/fg+GbO0d50596Lw3Yoz5tRCm4mgpUbVI3mkMIQumjoe8hu9bFx85bXnw==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.7.2", - "@nx/js": "22.7.2", + "@nx/devkit": "22.7.5", + "@nx/js": "22.7.5", "@phenomnomnominal/tsquery": "~6.2.0", "@typescript-eslint/type-utils": "^8.0.0", "@typescript-eslint/utils": "^8.0.0", @@ -9202,16 +9202,16 @@ } }, "node_modules/@nx/jest": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/jest/-/jest-22.7.2.tgz", - "integrity": "sha512-t+UYRCUUT7BYoRohjf6lWVzeeITjytclxE1ENEzU0+PCAKYN8yJfAWLSsLfK4YDDBv2lTXyzHo7b5Pxpbmz+Qw==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/jest/-/jest-22.7.5.tgz", + "integrity": "sha512-+WlVdtDlVM1dyJKQg/gKiMMs6B4cR8Qh3NT9J2WFPbKdD89DTR771j+WZE572MLijJmqzOE7uNH189kV1qEj0A==", "dev": true, "license": "MIT", "dependencies": { "@jest/reporters": "^30.0.2", "@jest/test-result": "^30.0.2", - "@nx/devkit": "22.7.2", - "@nx/js": "22.7.2", + "@nx/devkit": "22.7.5", + "@nx/js": "22.7.5", "@phenomnomnominal/tsquery": "~6.2.0", "identity-obj-proxy": "3.0.0", "jest-config": "^30.0.2", @@ -9265,9 +9265,9 @@ } }, "node_modules/@nx/js": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/js/-/js-22.7.2.tgz", - "integrity": "sha512-d1Hb/2n3QKE9rs8gRtfa/b1/GCGm1rnBFiqePivWbD/9iqerhgkbs6cg4MliLGRnD8gZgXSENLm4IW8ISOi69w==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/js/-/js-22.7.5.tgz", + "integrity": "sha512-2nJdlNPwYRldsdmUz+p/O8kF7eVjINaycTO4o1FXn8DL09wLvhxb1kFAaJrGA3Ig6znAnmRVGitccFt1QTPCIg==", "dev": true, "license": "MIT", "dependencies": { @@ -9278,8 +9278,8 @@ "@babel/preset-env": "^7.23.2", "@babel/preset-typescript": "^7.22.5", "@babel/runtime": "^7.22.6", - "@nx/devkit": "22.7.2", - "@nx/workspace": "22.7.2", + "@nx/devkit": "22.7.5", + "@nx/workspace": "22.7.5", "@zkochan/js-yaml": "0.0.7", "babel-plugin-const-enum": "^1.0.1", "babel-plugin-macros": "^3.1.0", @@ -9346,18 +9346,18 @@ } }, "node_modules/@nx/module-federation": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/module-federation/-/module-federation-22.7.2.tgz", - "integrity": "sha512-8KblqEdVw0b6uzhVSxz+RbjodN1BnHtWk1J4ndxG5XxiDLvW8bVEmpQAfn6DebsSRTIr+N/e3pah84j7xhZ9Yw==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/module-federation/-/module-federation-22.7.5.tgz", + "integrity": "sha512-tb2j891NYZvl2jMFWbTgF2aLGOGDjhJrMbUKIml2/xIT6DNF89+ly8+wjMS5Nh7JAK3XSQtlERlFVBCcTD+bQA==", "dev": true, "license": "MIT", "dependencies": { "@module-federation/enhanced": "^2.3.3", "@module-federation/node": "^2.7.21", "@module-federation/sdk": "^2.1.0", - "@nx/devkit": "22.7.2", - "@nx/js": "22.7.2", - "@nx/web": "22.7.2", + "@nx/devkit": "22.7.5", + "@nx/js": "22.7.5", + "@nx/web": "22.7.5", "@rspack/core": "1.6.8", "express": "^4.21.2", "http-proxy-middleware": "^3.0.5", @@ -9778,41 +9778,41 @@ } }, "node_modules/@nx/nest": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/nest/-/nest-22.7.2.tgz", - "integrity": "sha512-Xpja5pry0RWJ8K5t25Eh0HCe5Z6kg81oZh3Qr17iodh4jxAKCMbSpx+1j8PNmC6PFfzbhJyXY6gWOtnaCgbh3g==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/nest/-/nest-22.7.5.tgz", + "integrity": "sha512-UhCLlH3UjankgIFcQi6ZYEgAKSfKQlk6g9jJJpN+yyPDvoQYDPOr0iPicO+dUJvX+xDOxI/jS8easNuZ64fVPA==", "dev": true, "license": "MIT", "dependencies": { "@nestjs/schematics": "^11.0.0", - "@nx/devkit": "22.7.2", - "@nx/eslint": "22.7.2", - "@nx/js": "22.7.2", - "@nx/node": "22.7.2", + "@nx/devkit": "22.7.5", + "@nx/eslint": "22.7.5", + "@nx/js": "22.7.5", + "@nx/node": "22.7.5", "tslib": "^2.3.0" } }, "node_modules/@nx/node": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/node/-/node-22.7.2.tgz", - "integrity": "sha512-6nj6siMZy45r4hITYfHcqrOFpadYEkbfqwQP3xgTXZvTt6foX0HHoeOcv1rvaEvgdG6/DuZWQj4z8ursCnMWPw==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/node/-/node-22.7.5.tgz", + "integrity": "sha512-BJckjbyOgClqD6h2mNDhjftSRsvxbuayw0vKpT9sbd1ivAVhUCqNpe/iVP/VEdTsaK3s26hacfifD8EH3fPNWQ==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.7.2", - "@nx/docker": "22.7.2", - "@nx/eslint": "22.7.2", - "@nx/jest": "22.7.2", - "@nx/js": "22.7.2", + "@nx/devkit": "22.7.5", + "@nx/docker": "22.7.5", + "@nx/eslint": "22.7.5", + "@nx/jest": "22.7.5", + "@nx/js": "22.7.5", "kill-port": "^1.6.1", "tcp-port-used": "^1.0.2", "tslib": "^2.3.0" } }, "node_modules/@nx/nx-darwin-arm64": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/nx-darwin-arm64/-/nx-darwin-arm64-22.7.2.tgz", - "integrity": "sha512-hu+x/IOzx+18imkFwSdtXnvB6d21qcXvc4bCqcbA9BQcUnvTnw0/11SLoasvDqy/9KLKHDWJAIPttcBkbArWVA==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/nx-darwin-arm64/-/nx-darwin-arm64-22.7.5.tgz", + "integrity": "sha512-eoPtwx0qZqvRUD+VVOHm150AlSYwYoPxkDHBBGqKCn5nzPspb0lLWw8q83crM/L1M928YgK0WmGf3C++7eqsTA==", "cpu": [ "arm64" ], @@ -9824,9 +9824,9 @@ ] }, "node_modules/@nx/nx-darwin-x64": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/nx-darwin-x64/-/nx-darwin-x64-22.7.2.tgz", - "integrity": "sha512-M4QPs4rjzZN51V7qiKUjJU7hLYtv/h0I/aGUedCQQZibbbDTl45sQlgBQlV/viw2dOw3K5+RxDxtMNFxAbhxQA==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/nx-darwin-x64/-/nx-darwin-x64-22.7.5.tgz", + "integrity": "sha512-VLOn/ZoEn3HfjSj+yIHLCM56/el79r+9I28CkZNHaSXJQWZ3edSkcgcfYjVxCurpN2VEwDQHLBeFCH8M+lQ7wQ==", "cpu": [ "x64" ], @@ -9838,9 +9838,9 @@ ] }, "node_modules/@nx/nx-freebsd-x64": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/nx-freebsd-x64/-/nx-freebsd-x64-22.7.2.tgz", - "integrity": "sha512-tdC2mBQ/ON9qvTs72aL3XVN7B5wd7UsiRJ/qwC2bk/PIpD0vo5c3EwxFyYXfTD60jnlV+CTFxhSVmu8S1pVsfw==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/nx-freebsd-x64/-/nx-freebsd-x64-22.7.5.tgz", + "integrity": "sha512-LEVer/E2xfGvK9Go+imMQoEninOoq/38Z2bhV1SD3AThXrp1xaLFVkW5jQ6juebeVkAeztEoMLFlr576egS0vw==", "cpu": [ "x64" ], @@ -9852,9 +9852,9 @@ ] }, "node_modules/@nx/nx-linux-arm-gnueabihf": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm-gnueabihf/-/nx-linux-arm-gnueabihf-22.7.2.tgz", - "integrity": "sha512-bBHIC9xZ8L12BWkwMKbRi7+oV4UH1v1Yy8PsIvRfjS7GzYNlOAUMkJxywjF2msnkp8M8Rn29MEvzllZjdyaR7Q==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm-gnueabihf/-/nx-linux-arm-gnueabihf-22.7.5.tgz", + "integrity": "sha512-NP27EFGpmFJM6RL1Ey/AFJ7gA2xuqtIHaw6jjSNGvfrnZRUNaway30GrVaGGeODf0DsvAty/unqoBMPy6kDHbw==", "cpu": [ "arm" ], @@ -9866,9 +9866,9 @@ ] }, "node_modules/@nx/nx-linux-arm64-gnu": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-gnu/-/nx-linux-arm64-gnu-22.7.2.tgz", - "integrity": "sha512-MBYG58VUTmLW4S2RlYmXJiV6P0P1lkiZXtiaulZOXmP5uCSXiqMgK47k56hq9GTbtW1SpyGgh02lkNdCYTbmLw==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-gnu/-/nx-linux-arm64-gnu-22.7.5.tgz", + "integrity": "sha512-QLnkJl3HkHsPfpLiNiAiMfpfAeFpic0U1diAxF8RqChOkCpQ7ulvyBVgE1UrQxvhd+gFQ3ed5RNDxtCRw8nTiw==", "cpu": [ "arm64" ], @@ -9880,9 +9880,9 @@ ] }, "node_modules/@nx/nx-linux-arm64-musl": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-musl/-/nx-linux-arm64-musl-22.7.2.tgz", - "integrity": "sha512-Wf4VBSJt5gEGdzX6uzZoITEYB/Y3TxjvPNT11NKfRU/m63b8/D8jCeRmr7cBTaMUlNmdH3Lf3G1PuPNGoEZ0Mg==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-musl/-/nx-linux-arm64-musl-22.7.5.tgz", + "integrity": "sha512-cEP6KmwBgnb38+jTTaibWCjwXcHmigqhTfy0tN1be7WZr6bHxbqNLsXqKRN70PSNA3HouZcxw1cdRL8tqbPBBA==", "cpu": [ "arm64" ], @@ -9894,9 +9894,9 @@ ] }, "node_modules/@nx/nx-linux-x64-gnu": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-gnu/-/nx-linux-x64-gnu-22.7.2.tgz", - "integrity": "sha512-v3AQyfCkv9k+AWT2hy8hAGaCmFYf+G/bt4KAqnWhmXPWNhxrv9FhvTUcjpY+MY+6v7sKdhJv/3eDvtlLd9FOLg==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-gnu/-/nx-linux-x64-gnu-22.7.5.tgz", + "integrity": "sha512-tbaX1tZCSpGifDNBfDdEZAMxVF3Yg4bhFP/bm1needc0diqb+Zflc0u5tM5/6BWDMITQDwenJVsNiQ8ZdtJURA==", "cpu": [ "x64" ], @@ -9908,9 +9908,9 @@ ] }, "node_modules/@nx/nx-linux-x64-musl": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-musl/-/nx-linux-x64-musl-22.7.2.tgz", - "integrity": "sha512-3SMfMB7ynr8wGGTZP+/ZV7FqkCsOg1Raoka+4EtIPX66bEcBycg8FVg81DbyV+IzuKk3N+8Hl2IeY1W2btPypw==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-musl/-/nx-linux-x64-musl-22.7.5.tgz", + "integrity": "sha512-H0M7csOZIgPT822LqjxSXzf4MXRND15vIkAQe3F3Jlr3Si8LC3tzbL52aVcRfgb8MF/xOB5U47mSwxWt1M2bPQ==", "cpu": [ "x64" ], @@ -9922,9 +9922,9 @@ ] }, "node_modules/@nx/nx-win32-arm64-msvc": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/nx-win32-arm64-msvc/-/nx-win32-arm64-msvc-22.7.2.tgz", - "integrity": "sha512-eTFTTF1JUKXu+PNOGd7KAdqyWyfvFKO/wpqHoq9fjnbjXgCdCg1PaRxHIxA1WT5HFj1iHS6Or+GC1zA1KNt0Sw==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/nx-win32-arm64-msvc/-/nx-win32-arm64-msvc-22.7.5.tgz", + "integrity": "sha512-JTcZch9YAnDL1gbhqePz3DZ4x7iYemLn1yJzrjbbXAmXju2eiiJiZvJJHbV06+SP9HKXDT8RjTKuAWTdVxnHug==", "cpu": [ "arm64" ], @@ -9936,9 +9936,9 @@ ] }, "node_modules/@nx/nx-win32-x64-msvc": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/nx-win32-x64-msvc/-/nx-win32-x64-msvc-22.7.2.tgz", - "integrity": "sha512-fbVAiJ7RKSanUXrL67Z6as7BY1akznRqo71ACmrxLvLicG3UsmATbHKGp0zULoe3jBm+rNrIrLk+quZn5q0wUg==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/nx-win32-x64-msvc/-/nx-win32-x64-msvc-22.7.5.tgz", + "integrity": "sha512-ngcMyHdBJ9FSz2nHdbZ7gtJlFq0O2b05sPAsVMkZ18CKzdaA1qrBDJfsMO49hPCny505eiT766+CkKdaCDl5kA==", "cpu": [ "x64" ], @@ -9950,16 +9950,16 @@ ] }, "node_modules/@nx/rspack": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/rspack/-/rspack-22.7.2.tgz", - "integrity": "sha512-hlBIqhL9otJEQ9x8pf6CYqz0DdTKRW1w3jQ7c1hSafEHrC+OHs3UGReYeDO9Avua5eNA4/FVMLUVFUOPNzk3Wg==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/rspack/-/rspack-22.7.5.tgz", + "integrity": "sha512-E0esSN1S3e0eiHPlNbMsy6guu7APga9C1J5eO9b9IL4dB5NWXUXPsRhfPSv/8hp1gIho/rmqobItaDFgyt6KBQ==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.7.2", - "@nx/js": "22.7.2", - "@nx/module-federation": "22.7.2", - "@nx/web": "22.7.2", + "@nx/devkit": "22.7.5", + "@nx/js": "22.7.5", + "@nx/module-federation": "22.7.5", + "@nx/web": "22.7.5", "@phenomnomnominal/tsquery": "~6.2.0", "@rspack/core": "1.6.8", "@rspack/dev-server": "^1.1.4", @@ -10459,22 +10459,22 @@ } }, "node_modules/@nx/storybook": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/storybook/-/storybook-22.7.2.tgz", - "integrity": "sha512-kf34LHK0nvyTsXG43FqBGBBPzq9Th90hdWUVktLo636K6/mDvVPTM033BPzQDIljEqWAD+34YPiCtKGLE3H1Ag==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/storybook/-/storybook-22.7.5.tgz", + "integrity": "sha512-ZA86Gdhbq93oQjbav+RyrzUzA0nEydQKLu1oY+L6LYXE+IK3Rrjr584VJW5KySAHmu4dgCNcvnS+lyZKXK5SXQ==", "dev": true, "license": "MIT", "dependencies": { - "@nx/cypress": "22.7.2", - "@nx/devkit": "22.7.2", - "@nx/eslint": "22.7.2", - "@nx/js": "22.7.2", + "@nx/cypress": "22.7.5", + "@nx/devkit": "22.7.5", + "@nx/eslint": "22.7.5", + "@nx/js": "22.7.5", "@phenomnomnominal/tsquery": "~6.2.0", "semver": "^7.6.3", "tslib": "^2.3.0" }, "peerDependencies": { - "@nx/web": "22.7.2", + "@nx/web": "22.7.5", "storybook": ">=7.0.0 <11.0.0" }, "peerDependenciesMeta": { @@ -10484,26 +10484,26 @@ } }, "node_modules/@nx/web": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/web/-/web-22.7.2.tgz", - "integrity": "sha512-DgjlnOlPOpRFHJuItUbm3+DRZqZQkqVUTRhxS/Ep5QtMx/KeO6jbULHFS4BTDV54/I20ejjsWvADYcYhsZaY1g==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/web/-/web-22.7.5.tgz", + "integrity": "sha512-mJOx3BknJhdr2T7UD4b4LuWyTa+MyXXJvYymBjHvBuCmWC5o68wuDm2y5kXrZ1WxHJYlqoLZ2281QPVyB+SZ7A==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.7.2", - "@nx/js": "22.7.2", + "@nx/devkit": "22.7.5", + "@nx/js": "22.7.5", "detect-port": "^2.1.0", "http-server": "^14.1.0", "picocolors": "^1.1.0", "tslib": "^2.3.0" }, "peerDependencies": { - "@nx/cypress": "22.7.2", - "@nx/eslint": "22.7.2", - "@nx/jest": "22.7.2", - "@nx/playwright": "22.7.2", - "@nx/vite": "22.7.2", - "@nx/webpack": "22.7.2" + "@nx/cypress": "22.7.5", + "@nx/eslint": "22.7.5", + "@nx/jest": "22.7.5", + "@nx/playwright": "22.7.5", + "@nx/vite": "22.7.5", + "@nx/webpack": "22.7.5" }, "peerDependenciesMeta": { "@nx/cypress": { @@ -10527,15 +10527,15 @@ } }, "node_modules/@nx/webpack": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/webpack/-/webpack-22.7.2.tgz", - "integrity": "sha512-XVJcf2Bn1P7GoxJRESKFF5bXqWGPyE6+Bw1BraUmtM7bBRiF3tSXaYrzdwQlyNxZdK82RH+Y8hkBSXX3VZ8Rkg==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/webpack/-/webpack-22.7.5.tgz", + "integrity": "sha512-R6LUNAiwQANzqnRDVG2Z4nBMOUQX8Pud1BzllK+CgJkT8qK5eRjVjkBMCEm42togZ8Ax5/BYzO5w4gqUVKfvOQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.23.2", - "@nx/devkit": "22.7.2", - "@nx/js": "22.7.2", + "@nx/devkit": "22.7.5", + "@nx/js": "22.7.5", "@phenomnomnominal/tsquery": "~6.2.0", "ajv": "^8.12.0", "autoprefixer": "^10.4.9", @@ -10747,17 +10747,17 @@ } }, "node_modules/@nx/workspace": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/@nx/workspace/-/workspace-22.7.2.tgz", - "integrity": "sha512-xTEQMkeltIS6V5Qb6QRA7O+HIJQjIZSxLm6SvBNczJqAxckuYwMdbrb2IkDSE0XnQqR3gYg7Isz6UuBUHjz66Q==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@nx/workspace/-/workspace-22.7.5.tgz", + "integrity": "sha512-f3zx8EAOl0ANd2UXZIniBoHfDvNvi2Uy65R9Rp6emdcx7rxsuTU5Eaidryleo9wIQ5cZAcMx7Wvzp5Srj8diKA==", "dev": true, "license": "MIT", "dependencies": { - "@nx/devkit": "22.7.2", + "@nx/devkit": "22.7.5", "@zkochan/js-yaml": "0.0.7", "chalk": "^4.1.0", "enquirer": "~2.3.6", - "nx": "22.7.2", + "nx": "22.7.5", "picomatch": "4.0.4", "semver": "^7.6.3", "tslib": "^2.3.0", @@ -19078,9 +19078,9 @@ } }, "node_modules/css-minimizer-webpack-plugin/node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -19098,7 +19098,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -30316,9 +30316,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "funding": [ { "type": "github", @@ -30863,9 +30863,9 @@ "license": "MIT" }, "node_modules/nx": { - "version": "22.7.2", - "resolved": "https://registry.npmjs.org/nx/-/nx-22.7.2.tgz", - "integrity": "sha512-Gh7gGO1t/TvgbKuVJMYWbxUwZC+E+PuRRVUeoOeVe82yEvBNl40EKiVHIbbi6GID0s9Zwzflo07UrKGLoDSVGw==", + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/nx/-/nx-22.7.5.tgz", + "integrity": "sha512-zoxsJabb33jl1QYnalDn0bicryrEBgSzdKp90d7VGGv/jDgzKrcLg/hw2ZxeYiOjWPIT/o8QNT9G9vTs4dv3AQ==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -30887,7 +30887,7 @@ "balanced-match": "4.0.3", "base64-js": "1.5.1", "bl": "4.1.0", - "brace-expansion": "5.0.5", + "brace-expansion": "5.0.6", "buffer": "5.7.1", "call-bind-apply-helpers": "1.0.2", "chalk": "4.1.2", @@ -30968,7 +30968,7 @@ "strip-bom": "3.0.0", "supports-color": "7.2.0", "tar-stream": "2.2.0", - "tmp": "0.2.4", + "tmp": "0.2.6", "tree-kill": "1.2.2", "tsconfig-paths": "4.2.0", "tslib": "2.8.1", @@ -30977,7 +30977,7 @@ "wrap-ansi": "7.0.0", "wrappy": "1.0.2", "y18n": "5.0.8", - "yaml": "2.8.0", + "yaml": "2.9.0", "yargs": "17.7.2", "yargs-parser": "21.1.1" }, @@ -30986,16 +30986,16 @@ "nx-cloud": "dist/bin/nx-cloud.js" }, "optionalDependencies": { - "@nx/nx-darwin-arm64": "22.7.2", - "@nx/nx-darwin-x64": "22.7.2", - "@nx/nx-freebsd-x64": "22.7.2", - "@nx/nx-linux-arm-gnueabihf": "22.7.2", - "@nx/nx-linux-arm64-gnu": "22.7.2", - "@nx/nx-linux-arm64-musl": "22.7.2", - "@nx/nx-linux-x64-gnu": "22.7.2", - "@nx/nx-linux-x64-musl": "22.7.2", - "@nx/nx-win32-arm64-msvc": "22.7.2", - "@nx/nx-win32-x64-msvc": "22.7.2" + "@nx/nx-darwin-arm64": "22.7.5", + "@nx/nx-darwin-x64": "22.7.5", + "@nx/nx-freebsd-x64": "22.7.5", + "@nx/nx-linux-arm-gnueabihf": "22.7.5", + "@nx/nx-linux-arm64-gnu": "22.7.5", + "@nx/nx-linux-arm64-musl": "22.7.5", + "@nx/nx-linux-x64-gnu": "22.7.5", + "@nx/nx-linux-x64-musl": "22.7.5", + "@nx/nx-win32-arm64-msvc": "22.7.5", + "@nx/nx-win32-x64-msvc": "22.7.5" }, "peerDependencies": { "@swc-node/register": "^1.11.1", @@ -31072,9 +31072,9 @@ } }, "node_modules/nx/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -31405,19 +31405,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/nx/node_modules/yaml": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", - "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", - "dev": true, - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - } - }, "node_modules/nx/node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -37102,9 +37089,9 @@ "peer": true }, "node_modules/tmp": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.4.tgz", - "integrity": "sha512-UdiSoX6ypifLmrfQ/XfiawN6hkjSBpCjhKxxZcWlUUmoXLaCKQU0bx4HF/tdDK2uzRuchf1txGvrWBzYREssoQ==", + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.6.tgz", + "integrity": "sha512-5sJPdPjfI5Kx+qbrDesxkglRBxW//g7hCsqspEjwkewGvBMGIKMOTKzLt1hFVJzyadba3lDUN20O9qhvbQUSTA==", "dev": true, "license": "MIT", "engines": { @@ -37284,15 +37271,15 @@ } }, "node_modules/ts-checker-rspack-plugin": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/ts-checker-rspack-plugin/-/ts-checker-rspack-plugin-1.3.0.tgz", - "integrity": "sha512-89oK/BtApjdid1j9CGjPGiYry+EZBhsnTAM481/8ipgr/y2IOgCbW1HPnan+fs5FnzlpUgf9dWGNZ4Ayw3Bd8A==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/ts-checker-rspack-plugin/-/ts-checker-rspack-plugin-1.3.1.tgz", + "integrity": "sha512-4VBjKblnJwypq+2aWZ9V65HENAmU/2s04d717YhLjC65MKitTTnqKeHE6GGB5C4S+2BnqZ9MtJt5AvS7nldaLQ==", "dev": true, "license": "MIT", "dependencies": { "@rspack/lite-tapable": "^1.1.0", "chokidar": "^3.6.0", - "memfs": "^4.56.10", + "memfs": "^4.57.2", "picocolors": "^1.1.1" }, "peerDependencies": { @@ -37340,14 +37327,14 @@ } }, "node_modules/ts-checker-rspack-plugin/node_modules/@jsonjoy.com/fs-core": { - "version": "4.57.2", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-core/-/fs-core-4.57.2.tgz", - "integrity": "sha512-SVjwklkpIV5wrynpYtuYnfYH1QF4/nDuLBX7VXdb+3miglcAgBVZb/5y0cOsehRV/9Vb+3UqhkMq3/NR3ztdkQ==", + "version": "4.57.3", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-core/-/fs-core-4.57.3.tgz", + "integrity": "sha512-IvO50vkGydDZwS1e9rz/JXEtCCt9XvqxoGI6FlrVIvVm4/HpygMKW4ETtREWtMTsN5CLJ9FR6GuCduoQPZLBiw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-node-builtins": "4.57.2", - "@jsonjoy.com/fs-node-utils": "4.57.2", + "@jsonjoy.com/fs-node-builtins": "4.57.3", + "@jsonjoy.com/fs-node-utils": "4.57.3", "thingies": "^2.5.0" }, "engines": { @@ -37362,15 +37349,15 @@ } }, "node_modules/ts-checker-rspack-plugin/node_modules/@jsonjoy.com/fs-fsa": { - "version": "4.57.2", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-fsa/-/fs-fsa-4.57.2.tgz", - "integrity": "sha512-fhO8+iR2I+OCw668ISDJdn1aArc9zx033sWejIyzQ8RBeXa9bDSaUeA3ix0poYOfrj1KdOzytmYNv2/uLDfV6g==", + "version": "4.57.3", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-fsa/-/fs-fsa-4.57.3.tgz", + "integrity": "sha512-JlIDGUWPl7Y6zl+/ISnZuh8z2aMr/xoR66D18zlaVAuL192CvlNJEzOlzp27x4P52HRtDnCSOk6f59vTsmp5vw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-core": "4.57.2", - "@jsonjoy.com/fs-node-builtins": "4.57.2", - "@jsonjoy.com/fs-node-utils": "4.57.2", + "@jsonjoy.com/fs-core": "4.57.3", + "@jsonjoy.com/fs-node-builtins": "4.57.3", + "@jsonjoy.com/fs-node-utils": "4.57.3", "thingies": "^2.5.0" }, "engines": { @@ -37385,17 +37372,17 @@ } }, "node_modules/ts-checker-rspack-plugin/node_modules/@jsonjoy.com/fs-node": { - "version": "4.57.2", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node/-/fs-node-4.57.2.tgz", - "integrity": "sha512-nX2AdL6cOFwLdju9G4/nbRnYevmCJbh7N7hvR3gGm97Cs60uEjyd0rpR+YBS7cTg175zzl22pGKXR5USaQMvKg==", + "version": "4.57.3", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node/-/fs-node-4.57.3.tgz", + "integrity": "sha512-089gZoKvbeOsT2jeBaVKSz91oFXQWFG7a62sMY6gVMHnoWbyGzTb6OVUP/V7G3wLQLJ555BEsHt8SD1nj1dgaQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-core": "4.57.2", - "@jsonjoy.com/fs-node-builtins": "4.57.2", - "@jsonjoy.com/fs-node-utils": "4.57.2", - "@jsonjoy.com/fs-print": "4.57.2", - "@jsonjoy.com/fs-snapshot": "4.57.2", + "@jsonjoy.com/fs-core": "4.57.3", + "@jsonjoy.com/fs-node-builtins": "4.57.3", + "@jsonjoy.com/fs-node-utils": "4.57.3", + "@jsonjoy.com/fs-print": "4.57.3", + "@jsonjoy.com/fs-snapshot": "4.57.3", "glob-to-regex.js": "^1.0.0", "thingies": "^2.5.0" }, @@ -37411,9 +37398,9 @@ } }, "node_modules/ts-checker-rspack-plugin/node_modules/@jsonjoy.com/fs-node-builtins": { - "version": "4.57.2", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.57.2.tgz", - "integrity": "sha512-xhiegylRmhw43Ki2HO1ZBL7DQ5ja/qpRsL29VtQ2xuUHiuDGbgf2uD4p9Qd8hJI5P6RCtGYD50IXHXVq/Ocjcg==", + "version": "4.57.3", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.57.3.tgz", + "integrity": "sha512-JAI3PqNuY8BR7ovy4h0bADLrqJLIcUauONNZfyTxUnj3Wf3tpTYe39eJ6z7FzYyA+tdMt33VpiQQUikGr3QOBw==", "dev": true, "license": "Apache-2.0", "engines": { @@ -37428,15 +37415,15 @@ } }, "node_modules/ts-checker-rspack-plugin/node_modules/@jsonjoy.com/fs-node-to-fsa": { - "version": "4.57.2", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-to-fsa/-/fs-node-to-fsa-4.57.2.tgz", - "integrity": "sha512-18LmWTSONhoAPW+IWRuf8w/+zRolPFGPeGwMxlAhhfY11EKzX+5XHDBPAw67dBF5dxDErHJbl40U+3IXSDRXSQ==", + "version": "4.57.3", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-to-fsa/-/fs-node-to-fsa-4.57.3.tgz", + "integrity": "sha512-uZGxyC0zDmcmW5bfHd4YivAZ54BLlbF9G0K5rBaksI/tZdJSGM7/AC+1TY7yvFu0Wc6gUHR7mFwf6SbQ3J1BTQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-fsa": "4.57.2", - "@jsonjoy.com/fs-node-builtins": "4.57.2", - "@jsonjoy.com/fs-node-utils": "4.57.2" + "@jsonjoy.com/fs-fsa": "4.57.3", + "@jsonjoy.com/fs-node-builtins": "4.57.3", + "@jsonjoy.com/fs-node-utils": "4.57.3" }, "engines": { "node": ">=10.0" @@ -37450,13 +37437,13 @@ } }, "node_modules/ts-checker-rspack-plugin/node_modules/@jsonjoy.com/fs-node-utils": { - "version": "4.57.2", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.57.2.tgz", - "integrity": "sha512-rsPSJgekz43IlNbLyAM/Ab+ouYLWGp5DDBfYBNNEqDaSpsbXfthBn29Q4muFA9L0F+Z3mKo+CWlgSCXrf+mOyQ==", + "version": "4.57.3", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.57.3.tgz", + "integrity": "sha512-quCil8AvfcOxob4pn0drGdcQWpkPVgkt9q1+EjeyXXT40/L3l5lvYrr6hR8LmHu0eg+DNNaUwqjLT6Hr7V4sdQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-node-builtins": "4.57.2" + "@jsonjoy.com/fs-node-builtins": "4.57.3" }, "engines": { "node": ">=10.0" @@ -37470,13 +37457,13 @@ } }, "node_modules/ts-checker-rspack-plugin/node_modules/@jsonjoy.com/fs-print": { - "version": "4.57.2", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-print/-/fs-print-4.57.2.tgz", - "integrity": "sha512-wK9NSow48i4DbDl9F1CQE5TqnyZOJ04elU3WFG5aJ76p+YxO/ulyBBQvKsessPxdo381Bc2pcEoyPujMOhcRqQ==", + "version": "4.57.3", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-print/-/fs-print-4.57.3.tgz", + "integrity": "sha512-ITwaLZpGIqD9jHndwMvDFZDIvbVzGRsJZDQ5HKln0vyMculu1c1nb7zbEBgY8BVSBZ9S2xO138OWIBGeRsrF3Q==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-node-utils": "4.57.2", + "@jsonjoy.com/fs-node-utils": "4.57.3", "tree-dump": "^1.1.0" }, "engines": { @@ -37491,14 +37478,14 @@ } }, "node_modules/ts-checker-rspack-plugin/node_modules/@jsonjoy.com/fs-snapshot": { - "version": "4.57.2", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.57.2.tgz", - "integrity": "sha512-GdduDZuoP5V/QCgJkx9+BZ6SC0EZ/smXAdTS7PfMqgMTGXLlt/bH/FqMYaqB9JmLf05sJPtO0XRbAwwkEEPbVw==", + "version": "4.57.3", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.57.3.tgz", + "integrity": "sha512-wdNaG2DxCtvj9lKldAnEV3ycYPEpk+p2cP2lHD1qdxkoQGlWUtQverqvG9KZSkm6BHFha4PP6XRZbpARNfHRxA==", "dev": true, "license": "Apache-2.0", "dependencies": { "@jsonjoy.com/buffers": "^17.65.0", - "@jsonjoy.com/fs-node-utils": "4.57.2", + "@jsonjoy.com/fs-node-utils": "4.57.3", "@jsonjoy.com/json-pack": "^17.65.0", "@jsonjoy.com/util": "^17.65.0" }, @@ -37641,20 +37628,20 @@ } }, "node_modules/ts-checker-rspack-plugin/node_modules/memfs": { - "version": "4.57.2", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.57.2.tgz", - "integrity": "sha512-2nWzSsJzrukurSDna4Z0WywuScK4Id3tSKejgu74u8KCdW4uNrseKRSIDg75C6Yw5ZRqBe0F0EtMNlTbUq8bAQ==", + "version": "4.57.3", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.57.3.tgz", + "integrity": "sha512-dlvqataP1zUOlfj6pv9wgCSC5pRIooNntXgdLfR7FWlcKi1p8fMfJADtHp/+8Dhu5JFvMHNh7L0QVcuaaBKqqA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-core": "4.57.2", - "@jsonjoy.com/fs-fsa": "4.57.2", - "@jsonjoy.com/fs-node": "4.57.2", - "@jsonjoy.com/fs-node-builtins": "4.57.2", - "@jsonjoy.com/fs-node-to-fsa": "4.57.2", - "@jsonjoy.com/fs-node-utils": "4.57.2", - "@jsonjoy.com/fs-print": "4.57.2", - "@jsonjoy.com/fs-snapshot": "4.57.2", + "@jsonjoy.com/fs-core": "4.57.3", + "@jsonjoy.com/fs-fsa": "4.57.3", + "@jsonjoy.com/fs-node": "4.57.3", + "@jsonjoy.com/fs-node-builtins": "4.57.3", + "@jsonjoy.com/fs-node-to-fsa": "4.57.3", + "@jsonjoy.com/fs-node-utils": "4.57.3", + "@jsonjoy.com/fs-print": "4.57.3", + "@jsonjoy.com/fs-snapshot": "4.57.3", "@jsonjoy.com/json-pack": "^1.11.0", "@jsonjoy.com/util": "^1.9.0", "glob-to-regex.js": "^1.0.1", @@ -37773,9 +37760,9 @@ } }, "node_modules/ts-loader": { - "version": "9.5.7", - "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.7.tgz", - "integrity": "sha512-/ZNrKgA3K3PtpMYOC71EeMWIloGw3IYEa5/t1cyz2r5/PyUwTXGzYJvcD3kfUvmhlfpz1rhV8B2O6IVTQ0avsg==", + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.6.0.tgz", + "integrity": "sha512-dsJO0S+T7grTDWTc4a0nTygXGjKncVUpx8Y+af8EvI/D5WgTJby5UEk5eoMCB9EcLQmnvitqh99MqtjtHgAwFQ==", "dev": true, "license": "MIT", "dependencies": { @@ -37789,8 +37776,14 @@ "node": ">=12.0.0" }, "peerDependencies": { + "loader-utils": "*", "typescript": "*", - "webpack": "^5.0.0" + "webpack": "^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "loader-utils": { + "optional": true + } } }, "node_modules/ts-node": { @@ -39941,12 +39934,11 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "devOptional": true, "license": "ISC", - "optional": true, - "peer": true, "bin": { "yaml": "bin.mjs" }, diff --git a/package.json b/package.json index 22e39c437..49cc4e793 100644 --- a/package.json +++ b/package.json @@ -158,16 +158,16 @@ "@eslint/js": "9.35.0", "@nestjs/schematics": "11.1.0", "@nestjs/testing": "11.1.21", - "@nx/angular": "22.7.2", - "@nx/eslint-plugin": "22.7.2", - "@nx/jest": "22.7.2", - "@nx/js": "22.7.2", - "@nx/module-federation": "22.7.2", - "@nx/nest": "22.7.2", - "@nx/node": "22.7.2", - "@nx/storybook": "22.7.2", - "@nx/web": "22.7.2", - "@nx/workspace": "22.7.2", + "@nx/angular": "22.7.5", + "@nx/eslint-plugin": "22.7.5", + "@nx/jest": "22.7.5", + "@nx/js": "22.7.5", + "@nx/module-federation": "22.7.5", + "@nx/nest": "22.7.5", + "@nx/node": "22.7.5", + "@nx/storybook": "22.7.5", + "@nx/web": "22.7.5", + "@nx/workspace": "22.7.5", "@schematics/angular": "21.2.6", "@storybook/addon-docs": "10.1.10", "@storybook/addon-themes": "10.1.10", @@ -194,7 +194,7 @@ "jest": "30.2.0", "jest-environment-jsdom": "30.2.0", "jest-preset-angular": "16.0.0", - "nx": "22.7.2", + "nx": "22.7.5", "prettier": "3.8.3", "prettier-plugin-organize-attributes": "1.0.0", "prisma": "7.8.0", From 9996d8a8d2e91983fedf4980ff23c42e34ae8e06 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Tue, 2 Jun 2026 19:56:10 +0200 Subject: [PATCH 130/132] Bugfix/base currency in impersonation mode (#6964) * Fix base currency in impersonation mode * Update changelog --- CHANGELOG.md | 1 + .../api/src/app/account/account.controller.ts | 11 +++++-- apps/api/src/app/account/account.module.ts | 4 ++- .../src/app/portfolio/portfolio.controller.ts | 11 +++++-- .../src/app/portfolio/portfolio.service.ts | 7 +++-- apps/api/src/app/user/user.service.ts | 31 +++++++++++++------ 6 files changed, 46 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60a33a4b2..aa0e05aa4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Resolved an issue in the impersonation mode where the values did not match the owner’s currency - Fixed the environment variable expansion in the `.env` file when debugging via _Visual Studio Code_ ## 3.6.0 - 2026-05-28 diff --git a/apps/api/src/app/account/account.controller.ts b/apps/api/src/app/account/account.controller.ts index 052720176..d44b716c0 100644 --- a/apps/api/src/app/account/account.controller.ts +++ b/apps/api/src/app/account/account.controller.ts @@ -1,5 +1,6 @@ import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; +import { UserService } from '@ghostfolio/api/app/user/user.service'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; @@ -50,7 +51,8 @@ export class AccountController { private readonly apiService: ApiService, private readonly impersonationService: ImpersonationService, private readonly portfolioService: PortfolioService, - @Inject(REQUEST) private readonly request: RequestWithUser + @Inject(REQUEST) private readonly request: RequestWithUser, + private readonly userService: UserService ) {} @Delete(':id') @@ -137,11 +139,14 @@ export class AccountController { ): Promise { const impersonationUserId = await this.impersonationService.validateImpersonationId(impersonationId); + const userId = impersonationUserId || this.request.user.id; + + const { settings } = await this.userService.user({ id: userId }); return this.accountBalanceService.getAccountBalances({ + userId, filters: [{ id, type: 'ACCOUNT' }], - userCurrency: this.request.user.settings.settings.baseCurrency, - userId: impersonationUserId || this.request.user.id + userCurrency: settings.settings.baseCurrency }); } diff --git a/apps/api/src/app/account/account.module.ts b/apps/api/src/app/account/account.module.ts index fb89bb2b6..253c7fb1d 100644 --- a/apps/api/src/app/account/account.module.ts +++ b/apps/api/src/app/account/account.module.ts @@ -1,5 +1,6 @@ import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module'; import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'; +import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; @@ -23,7 +24,8 @@ import { AccountService } from './account.service'; ImpersonationModule, PortfolioModule, PrismaModule, - RedactValuesInResponseModule + RedactValuesInResponseModule, + UserModule ], providers: [AccountService] }) diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 9515c7a21..ca94605f9 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -1,4 +1,5 @@ import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; +import { UserService } from '@ghostfolio/api/app/user/user.service'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { @@ -70,7 +71,8 @@ export class PortfolioController { private readonly configurationService: ConfigurationService, private readonly impersonationService: ImpersonationService, private readonly portfolioService: PortfolioService, - @Inject(REQUEST) private readonly request: RequestWithUser + @Inject(REQUEST) private readonly request: RequestWithUser, + private readonly userService: UserService ) {} @Get('details') @@ -340,7 +342,10 @@ export class PortfolioController { const impersonationUserId = await this.impersonationService.validateImpersonationId(impersonationId); - const userCurrency = this.request.user.settings.settings.baseCurrency; + const userId = impersonationUserId || this.request.user.id; + + const { settings } = await this.userService.user({ id: userId }); + const userCurrency = settings.settings.baseCurrency; const { endDate, startDate } = getIntervalFromDateRange({ dateRange }); @@ -349,7 +354,7 @@ export class PortfolioController { filters, startDate, userCurrency, - userId: impersonationUserId || this.request.user.id, + userId, types: ['DIVIDEND'] }); diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index ab39a00a5..37d76bcfa 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -164,7 +164,7 @@ export class PortfolioService { }; } - const [accounts, details] = await Promise.all([ + const [accounts, details, user] = await Promise.all([ this.accountService.accounts({ where, include: { @@ -178,10 +178,11 @@ export class PortfolioService { withExcludedAccounts, impersonationId: userId, userId: this.request.user.id - }) + }), + this.userService.user({ id: userId }) ]); - const userCurrency = this.request.user.settings.settings.baseCurrency; + const userCurrency = this.getUserCurrency(user); return Promise.all( accounts.map(async (account) => { diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 4a0e1598b..9d8d9da9d 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -49,7 +49,7 @@ import { PerformanceCalculationType } from '@ghostfolio/common/types/performance import { Injectable } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; -import { Prisma, Role, User } from '@prisma/client'; +import { Prisma, Role, Settings, User } from '@prisma/client'; import { differenceInDays, subDays } from 'date-fns'; import { without } from 'lodash'; import { createHmac } from 'node:crypto'; @@ -109,7 +109,14 @@ export class UserService { }): Promise { const { id, permissions, settings, subscription } = user; - const userData = await Promise.all([ + const [ + access, + accounts, + activitiesCount, + firstActivity, + impersonationUserSettings, + tagsForUser + ] = await Promise.all([ this.prismaService.access.findMany({ include: { user: true @@ -134,16 +141,17 @@ export class UserService { }, where: { userId: impersonationUserId || user.id } }), + impersonationUserId + ? this.prismaService.settings.findUnique({ + where: { userId: impersonationUserId } + }) + : Promise.resolve(null), this.tagService.getTagsForUser(impersonationUserId || user.id) ]); - const access = userData[0]; - const accounts = userData[1]; - const activitiesCount = userData[2]; - const firstActivity = userData[3]; - let tags = userData[4].filter((tag) => { - return tag.id !== TAG_ID_EXCLUDE_FROM_ANALYSIS; - }); + const baseCurrency = + (impersonationUserSettings?.settings as UserSettings)?.baseCurrency ?? + (settings.settings as UserSettings)?.baseCurrency; let systemMessage: SystemMessage; @@ -156,6 +164,10 @@ export class UserService { systemMessage = systemMessageProperty; } + let tags = tagsForUser.filter((tag) => { + return tag.id !== TAG_ID_EXCLUDE_FROM_ANALYSIS; + }); + if ( this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && subscription.type === SubscriptionType.Basic @@ -183,6 +195,7 @@ export class UserService { dateOfFirstActivity: firstActivity?.date ?? new Date(), settings: { ...(settings.settings as UserSettings), + baseCurrency, locale: (settings.settings as UserSettings)?.locale ?? locale } }; From 8a93f1f3013aff1a6c7ac3351257c16cb8dc4bda Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Tue, 2 Jun 2026 19:56:44 +0200 Subject: [PATCH 131/132] Bugfix/asset class chart on allocations page (#6979) Fix asset class chart --- libs/ui/src/lib/services/data.service.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/libs/ui/src/lib/services/data.service.ts b/libs/ui/src/lib/services/data.service.ts index 44cef1aed..2ae07708d 100644 --- a/libs/ui/src/lib/services/data.service.ts +++ b/libs/ui/src/lib/services/data.service.ts @@ -556,13 +556,11 @@ export class DataService { map((response) => { if (response.holdings) { for (const symbol of Object.keys(response.holdings)) { - response.holdings[symbol].assetClassLabel = translate( - response.holdings[symbol].assetClass - ); + response.holdings[symbol].assetProfile.assetClassLabel = + translate(response.holdings[symbol].assetProfile.assetClass); - response.holdings[symbol].assetSubClassLabel = translate( - response.holdings[symbol].assetSubClass - ); + response.holdings[symbol].assetProfile.assetSubClassLabel = + translate(response.holdings[symbol].assetProfile.assetSubClass); response.holdings[symbol].dateOfFirstActivity = response.holdings[ symbol From f14f98f766d70dd6c9b5e2c13cf0d67ab927a33a Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Tue, 2 Jun 2026 20:00:36 +0200 Subject: [PATCH 132/132] Release 3.7.0 (#6980) --- CHANGELOG.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa0e05aa4..451390256 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## Unreleased +## 3.7.0 - 2026-06-02 ### Added diff --git a/package-lock.json b/package-lock.json index 6330ef21f..e7b5a2bca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ghostfolio", - "version": "3.6.0", + "version": "3.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ghostfolio", - "version": "3.6.0", + "version": "3.7.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/package.json b/package.json index 49cc4e793..4fa3e522a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostfolio", - "version": "3.6.0", + "version": "3.7.0", "homepage": "https://ghostfol.io", "license": "AGPL-3.0", "repository": "https://github.com/ghostfolio/ghostfolio",