Browse Source

Merge branch 'main' into fix/6057-import-duplicate-detection

pull/7507/head
CoderVJain 4 weeks ago
committed by GitHub
parent
commit
b75e8b8dd3
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 33
      CHANGELOG.md
  2. 43
      apps/api/src/app/activities/activities-filter.dto.ts
  3. 83
      apps/api/src/app/activities/activities.controller.ts
  4. 9
      apps/api/src/app/activities/activities.service.ts
  5. 27
      apps/api/src/app/activities/get-activities.dto.ts
  6. 7
      apps/api/src/app/auth/api-key.strategy.ts
  7. 7
      apps/api/src/app/auth/jwt.strategy.ts
  8. 12
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts
  9. 21
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts
  10. 39
      apps/api/src/app/export/export.controller.ts
  11. 17
      apps/api/src/app/export/export.service.ts
  12. 14
      apps/api/src/app/export/get-export.dto.ts
  13. 21
      apps/api/src/app/import/import.service.ts
  14. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts
  15. 90
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  16. 2
      apps/api/src/app/portfolio/portfolio.service.ts
  17. 72
      apps/api/src/app/user/user.service.ts
  18. 1
      apps/api/src/helper/account.helper.ts
  19. 7
      apps/api/src/services/data-provider/data-provider.service.ts
  20. 4
      apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts
  21. 5
      apps/client/src/app/app.component.ts
  22. 1
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
  23. 1
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  24. 1
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  25. 4
      apps/client/src/app/components/user-account-settings/user-account-settings.component.ts
  26. 3
      apps/client/src/app/pages/accounts/accounts-page.component.ts
  27. 4
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts
  28. 5
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html
  29. 39
      apps/client/src/app/pages/portfolio/activities/activities-page.component.ts
  30. 2
      apps/client/src/index.html
  31. 296
      apps/client/src/locales/messages.ca.xlf
  32. 296
      apps/client/src/locales/messages.de.xlf
  33. 296
      apps/client/src/locales/messages.es.xlf
  34. 296
      apps/client/src/locales/messages.fr.xlf
  35. 296
      apps/client/src/locales/messages.it.xlf
  36. 296
      apps/client/src/locales/messages.ja.xlf
  37. 296
      apps/client/src/locales/messages.ko.xlf
  38. 296
      apps/client/src/locales/messages.nl.xlf
  39. 296
      apps/client/src/locales/messages.pl.xlf
  40. 296
      apps/client/src/locales/messages.pt.xlf
  41. 296
      apps/client/src/locales/messages.tr.xlf
  42. 296
      apps/client/src/locales/messages.uk.xlf
  43. 292
      apps/client/src/locales/messages.xlf
  44. 296
      apps/client/src/locales/messages.zh.xlf
  45. 11
      apps/client/src/styles.scss
  46. 6
      apps/client/src/styles/theme.scss
  47. 24
      libs/common/src/lib/calculation-helper.spec.ts
  48. 13
      libs/common/src/lib/calculation-helper.ts
  49. 19
      libs/common/src/lib/config.ts
  50. 10
      libs/common/src/lib/dtos/create-account-with-balances.dto.ts
  51. 8
      libs/common/src/lib/dtos/create-account.dto.ts
  52. 8
      libs/common/src/lib/dtos/update-account.dto.ts
  53. 11
      libs/common/src/lib/helper.ts
  54. 6
      libs/common/src/lib/interfaces/responses/portfolio-performance-response.interface.ts
  55. 12
      libs/common/src/lib/types/date-range.type.ts
  56. 3
      libs/ui/src/lib/accounts-table/accounts-table.component.stories.ts
  57. 20
      libs/ui/src/lib/activities-table/activities-table.component.html
  58. 5
      libs/ui/src/lib/activities-table/activities-table.component.stories.ts
  59. 19
      libs/ui/src/lib/activities-table/activities-table.component.ts
  60. 9
      libs/ui/src/lib/assistant/assistant.component.ts
  61. 53
      libs/ui/src/lib/services/data.service.ts
  62. 8
      libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.ts
  63. 596
      package-lock.json
  64. 10
      package.json
  65. 21
      prisma/migrations/20260801103609_removed_is_excluded_from_account/migration.sql
  66. 3
      prisma/schema.prisma

33
CHANGELOG.md

@ -5,18 +5,49 @@ 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.39.0 - 2026-08-01
### Changed
- Harmonized the data format of the export functionality
- Removed the deprecated `firstOrderDate` attribute from the `GET api/v2/portfolio/performance` endpoint response
- Removed the deprecated `isExcluded` attribute of the account in favor of the _Exclude from Analysis_ tag including a data migration
- Improved the language localization for German (`de`)
- Upgraded `prisma` from version `7.8.0` to `7.9.1`
### Fixed
- Fixed the scroll behavior of the page content behind an open dialog
- Fixed the export functionality to only include the accounts of the exported activities if a filter is applied
## 3.38.0 - 2026-07-31
### Added
- Added support for the date range filter in the export functionality
- Added support for the date range filter on the portfolio activities page
### Changed
- Improved the style of the tabs in the account detail dialog on mobile
- Improved the style of the tabs in the holding detail dialog on mobile
- Improved the style of the tabs in the asset profile dialog of the admin control panel on mobile
- Improved the style of the empty state in the _Fear & Greed Index_ component
- Added the activity count to the delete menu item of the activities table
- Added the activity count to the deletion confirmation dialog of the activities table
- Improved the style of the type filter in the activities table component (experimental)
- Improved the search functionality by trimming the query
- Improved the log output in the search functionality of the _Yahoo Finance_ service for unsupported queries
- Improved the performance of the property service by caching the properties in memory
- Improved the validation of the query parameters in the activities endpoints
- Improved the language localization for German (`de`)
### Fixed
- Fixed the duplicate detection during the activity import so it treats an omitted comment the same as null and matches holdings imported by ISIN against holdings imported by ticker symbol
- Fixed the calendar year date range in time zones with a negative _UTC_ offset
- Fixed the deletion of activities to respect the activity type filter on the activities page (experimental)
- Fixed the deletion of activities to respect the date range filter on the activities page
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Asset Class Cluster Risks_ (Equity)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Asset Class Cluster Risks_ (Fixed Income)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Currency Cluster Risks_ (Investment)

43
apps/api/src/app/activities/activities-filter.dto.ts

@ -0,0 +1,43 @@
import { DATE_RANGES } from '@ghostfolio/common/config';
import { DateRange } from '@ghostfolio/common/types';
import { Type as ActivityType } from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer';
import { IsEnum, IsOptional, IsString, Matches } from 'class-validator';
import { isString } from 'lodash';
// A named date range or a calendar year like '2024', '2023', '2022', etc.
const DATE_RANGE_PATTERN = new RegExp(`^(${DATE_RANGES.join('|')}|\\d{4})$`);
export class ActivitiesFilterDto {
@IsOptional()
@IsString()
accounts?: string;
@IsEnum(ActivityType, { each: true })
@IsOptional()
@Transform(({ value }: TransformFnParams) => {
return isString(value) ? value.split(',') : value;
})
activityTypes?: ActivityType[];
@IsOptional()
@IsString()
assetClasses?: string;
@IsOptional()
@IsString()
dataSource?: string;
@IsOptional()
@Matches(DATE_RANGE_PATTERN)
range?: DateRange;
@IsOptional()
@IsString()
symbol?: string;
@IsOptional()
@IsString()
tags?: string;
}

83
apps/api/src/app/activities/activities.controller.ts

@ -18,7 +18,7 @@ import {
ActivityResponse
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
@ -29,7 +29,6 @@ import {
HttpException,
Inject,
Param,
ParseIntPipe,
Post,
Put,
Query,
@ -38,11 +37,13 @@ import {
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Order, Prisma, Type as ActivityType } from '@prisma/client';
import { Order } from '@prisma/client';
import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { ActivitiesFilterDto } from './activities-filter.dto';
import { ActivitiesService } from './activities.service';
import { GetActivitiesDto } from './get-activities.dto';
@Controller('activities')
export class ActivitiesController {
@ -60,22 +61,38 @@ export class ActivitiesController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async deleteActivities(
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query() query: ActivitiesFilterDto
): Promise<number> {
if (impersonationId) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
let endDate: Date;
let startDate: Date;
if (query.range) {
({ endDate, startDate } = getIntervalFromDateRange({
dateRange: query.range
}));
}
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags
filterByAccounts: query.accounts,
filterByAssetClasses: query.assetClasses,
filterByDataSource: query.dataSource,
filterBySymbol: query.symbol,
filterByTags: query.tags
});
return this.activitiesService.deleteActivities({
endDate,
filters,
startDate,
types: query.activityTypes,
userId: this.request.user.id
});
}
@ -108,51 +125,41 @@ export class ActivitiesController {
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAllActivities(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('activityTypes') filterByTypes?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('range') dateRange?: DateRange,
@Query('skip', new ParseIntPipe({ optional: true })) skip?: number,
@Query('sortColumn') sortColumn?: string,
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string,
@Query('take', new ParseIntPipe({ optional: true })) take?: number
@Query() query: GetActivitiesDto
): Promise<ActivitiesResponse> {
let endDate: Date;
let startDate: Date;
if (dateRange) {
({ endDate, startDate } = getIntervalFromDateRange({ dateRange }));
if (query.range) {
({ endDate, startDate } = getIntervalFromDateRange({
dateRange: query.range
}));
}
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags
filterByAccounts: query.accounts,
filterByAssetClasses: query.assetClasses,
filterByDataSource: query.dataSource,
filterBySymbol: query.symbol,
filterByTags: query.tags
});
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
const types = (filterByTypes?.split(',') as ActivityType[]) ?? [];
const userCurrency = this.request.user.settings.settings.baseCurrency;
const { activities, count } = await this.activitiesService.getActivities({
endDate,
filters,
skip,
sortColumn,
sortDirection,
startDate,
take,
types,
userCurrency,
includeDrafts: true,
skip: query.skip,
sortColumn: query.sortColumn,
sortDirection: query.sortDirection,
take: query.take,
types: query.activityTypes,
userId: impersonationUserId || this.request.user.id,
withExcludedAccountsAndActivities: true
});

9
apps/api/src/app/activities/activities.service.ts

@ -361,14 +361,23 @@ export class ActivitiesService {
}
public async deleteActivities({
endDate,
filters,
startDate,
types,
userId
}: {
endDate?: Date;
filters?: Filter[];
startDate?: Date;
types?: ActivityType[];
userId: string;
}): Promise<number> {
const { activities } = await this.getActivities({
endDate,
filters,
startDate,
types,
userId,
includeDrafts: true,
userCurrency: undefined,

27
apps/api/src/app/activities/get-activities.dto.ts

@ -0,0 +1,27 @@
import { Prisma } from '@prisma/client';
import { Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, Min } from 'class-validator';
import { ActivitiesFilterDto } from './activities-filter.dto';
export class GetActivitiesDto extends ActivitiesFilterDto {
@IsInt()
@IsOptional()
@Min(0)
@Type(() => Number)
skip?: number;
@IsIn(Object.values(Prisma.OrderScalarFieldEnum))
@IsOptional()
sortColumn?: keyof typeof Prisma.OrderScalarFieldEnum;
@IsIn(['asc', 'desc'] as Prisma.SortOrder[])
@IsOptional()
sortDirection?: Prisma.SortOrder;
@IsInt()
@IsOptional()
@Min(0)
@Type(() => Number)
take?: number;
}

7
apps/api/src/app/auth/api-key.strategy.ts

@ -35,6 +35,13 @@ export class ApiKeyStrategy extends PassportStrategy(
);
}
if (await this.userService.isDailyRequestLimitExceeded({ user })) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
await this.prismaService.analytics.upsert({
create: { user: { connect: { id: user.id } } },
update: {

7
apps/api/src/app/auth/jwt.strategy.ts

@ -42,6 +42,13 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
);
}
if (await this.userService.isDailyRequestLimitExceeded({ user })) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
const country =
countriesAndTimezones.getCountryForTimezone(timezone)?.id;

12
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts

@ -51,7 +51,7 @@ export class GhostfolioController {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
@ -95,7 +95,7 @@ export class GhostfolioController {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
@ -135,7 +135,7 @@ export class GhostfolioController {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
@ -176,7 +176,7 @@ export class GhostfolioController {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
@ -215,7 +215,7 @@ export class GhostfolioController {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
@ -252,7 +252,7 @@ export class GhostfolioController {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),

21
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts

@ -17,7 +17,10 @@ import {
DERIVED_CURRENCIES
} from '@ghostfolio/common/config';
import { PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS } from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import {
getAssetProfileIdentifier,
isValidSearchQuery
} from '@ghostfolio/common/helper';
import {
DataProviderGhostfolioAssetProfileResponse,
DataProviderHistoricalResponse,
@ -326,8 +329,12 @@ export class GhostfolioService {
}
public async incrementDailyRequests({ userId }: { userId: string }) {
await this.prismaService.analytics.update({
data: {
await this.prismaService.analytics.upsert({
create: {
dataProviderGhostfolioDailyRequests: 1,
user: { connect: { id: userId } }
},
update: {
dataProviderGhostfolioDailyRequests: { increment: 1 }
},
where: { userId }
@ -340,7 +347,9 @@ export class GhostfolioService {
}: GetSearchParams): Promise<LookupResponse> {
const results: LookupResponse = { items: [] };
if (!query) {
query = query?.trim();
if (!isValidSearchQuery(query)) {
return results;
}
@ -348,10 +357,6 @@ export class GhostfolioService {
let lookupItems: LookupItem[] = [];
const promises: Promise<{ items: LookupItem[] }>[] = [];
if (query?.length < 2) {
return { items: lookupItems };
}
for (const dataProviderService of this.getDataProviderServices()) {
promises.push(
dataProviderService.search({

39
apps/api/src/app/export/export.controller.ts

@ -2,6 +2,7 @@ import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { ExportResponse } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
@ -15,9 +16,9 @@ import {
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Type as ActivityType } from '@prisma/client';
import { ExportService } from './export.service';
import { GetExportDto } from './get-export.dto';
@Controller('export')
export class ExportController {
@ -31,30 +32,30 @@ export class ExportController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async export(
@Query('accounts') filterByAccounts?: string,
@Query('activityIds') filterByActivityIds?: string,
@Query('activityTypes') filterByTypes?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string
): Promise<ExportResponse> {
const activityIds = filterByActivityIds?.split(',') ?? [];
const activityTypes = (filterByTypes?.split(',') as ActivityType[]) ?? [];
public async export(@Query() query: GetExportDto): Promise<ExportResponse> {
let endDate: Date;
let startDate: Date;
if (query.range) {
({ endDate, startDate } = getIntervalFromDateRange({
dateRange: query.range
}));
}
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags
filterByAccounts: query.accounts,
filterByAssetClasses: query.assetClasses,
filterByDataSource: query.dataSource,
filterBySymbol: query.symbol,
filterByTags: query.tags
});
return this.exportService.export({
activityIds,
activityTypes,
endDate,
filters,
startDate,
activityIds: query.activityIds,
activityTypes: query.activityTypes,
userId: this.request.user.id,
userSettings: this.request.user.settings.settings
});

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

@ -25,13 +25,17 @@ export class ExportService {
public async export({
activityIds,
activityTypes,
endDate,
filters,
startDate,
userId,
userSettings
}: {
activityIds?: string[];
activityTypes?: ActivityType[];
endDate?: Date;
filters?: Filter[];
startDate?: Date;
userId: string;
userSettings: UserSettings;
}): Promise<ExportResponse> {
@ -41,7 +45,9 @@ export class ExportService {
const platformsMap: { [platformId: string]: Platform } = {};
let { activities } = await this.activitiesService.getActivities({
endDate,
filters,
startDate,
userId,
includeDrafts: true,
sortColumn: 'date',
@ -67,6 +73,13 @@ export class ExportService {
};
}
const isFilteredExport =
activityIds?.length > 0 ||
activityTypes?.length > 0 ||
filters?.length > 0 ||
!!endDate ||
!!startDate;
const accounts = (
await this.accountService.accounts({
where,
@ -81,7 +94,7 @@ export class ExportService {
})
)
.filter(({ id }) => {
return activityIds?.length > 0
return isFilteredExport
? activities.some(({ accountId }) => {
return accountId === id;
})
@ -94,7 +107,6 @@ export class ExportService {
comment,
currency,
id,
isExcluded,
name,
platform,
platformId,
@ -112,7 +124,6 @@ export class ExportService {
comment,
currency,
id,
isExcluded,
name,
platformId,
tags: tags.map(({ id: tagId }) => {

14
apps/api/src/app/export/get-export.dto.ts

@ -0,0 +1,14 @@
import { ActivitiesFilterDto } from '@ghostfolio/api/app/activities/activities-filter.dto';
import { Transform, TransformFnParams } from 'class-transformer';
import { IsOptional, IsUUID } from 'class-validator';
import { isString } from 'lodash';
export class GetExportDto extends ActivitiesFilterDto {
@IsOptional()
@IsUUID(undefined, { each: true })
@Transform(({ value }: TransformFnParams) => {
return isString(value) ? value.split(',') : value;
})
activityIds?: string[];
}

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

@ -9,7 +9,10 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data/market-d
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config';
import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config';
import { CreateAssetProfileDto, CreateOrderDto } from '@ghostfolio/common/dtos';
import {
getAssetProfileIdentifier,
@ -272,7 +275,11 @@ export class ImportService {
// If there is no account or if the account belongs to a different user then create a new account
if (!accountWithSameId || accountWithSameId.userId !== user.id) {
const account = omit(accountWithBalances, ['balances', 'tags']);
const account = omit(accountWithBalances, [
'balances',
'isExcluded',
'tags'
]);
let oldAccountId: string;
const platformId = account.platformId;
@ -292,6 +299,16 @@ export class ImportService {
return existingTagIds.has(tagId);
});
// Map the legacy isExcluded attribute of old export files to
// the "Exclude from Analysis" tag
if (
accountWithBalances.isExcluded &&
existingTagIds.has(TAG_ID_EXCLUDE_FROM_ANALYSIS) &&
!tagIds.includes(TAG_ID_EXCLUDE_FROM_ANALYSIS)
) {
tagIds.push(TAG_ID_EXCLUDE_FROM_ANALYSIS);
}
let accountObject: Prisma.AccountCreateInput = {
...account,
balances: {

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts

@ -186,7 +186,6 @@ describe('PortfolioCalculator', () => {
createdAt: parseDate('2023-12-31'),
currency: 'USD',
id: accountId,
isExcluded: false,
name: 'USD',
platformId: null,
updatedAt: parseDate('2023-12-31'),
@ -362,7 +361,6 @@ describe('PortfolioCalculator', () => {
createdAt: parseDate('2023-12-31'),
currency: 'CHF',
id: accountId,
isExcluded: false,
name: 'CHF',
platformId: null,
updatedAt: parseDate('2023-12-31'),
@ -494,7 +492,6 @@ describe('PortfolioCalculator', () => {
createdAt: parseDate('2023-12-31'),
currency: 'CHF',
id: accountId,
isExcluded: false,
name: 'CHF',
platformId: null,
updatedAt: parseDate('2023-12-31'),

90
apps/api/src/app/portfolio/portfolio.service.spec.ts

@ -1,6 +1,7 @@
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 { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
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';
@ -11,7 +12,10 @@ import { ImpersonationService } from '@ghostfolio/api/services/impersonation/imp
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { parseDate } from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import {
AssetProfileIdentifier,
PortfolioSummary
} from '@ghostfolio/common/interfaces';
import { Account, DataSource } from '@prisma/client';
import { Big } from 'big.js';
@ -92,6 +96,7 @@ describe('PortfolioService', () => {
null,
null,
null,
null,
null
);
@ -187,7 +192,6 @@ describe('PortfolioService', () => {
createdAt: parseDate('2024-01-01'),
currency: 'USD',
id: randomUUID(),
isExcluded: false,
name: 'USD',
platformId: null,
updatedAt: parseDate('2024-01-01'),
@ -221,7 +225,6 @@ describe('PortfolioService', () => {
createdAt: parseDate('2024-01-01'),
currency: 'USD',
id: accountId,
isExcluded: false,
name: 'USD',
platformId: null,
updatedAt: parseDate('2024-01-01'),
@ -337,6 +340,86 @@ describe('PortfolioService', () => {
});
});
describe('getSummary', () => {
const getSummary = (args: object) => {
return (
portfolioService as unknown as {
getSummary: (aArgs: object) => Promise<PortfolioSummary>;
}
).getSummary(args);
};
function createPortfolioCalculator() {
return {
getDividendInBaseCurrency: jest.fn().mockResolvedValue(new Big(0)),
getFeesInBaseCurrency: jest.fn().mockResolvedValue(new Big(0)),
getInterestInBaseCurrency: jest.fn().mockResolvedValue(new Big(0)),
getLiabilitiesInBaseCurrency: jest.fn().mockResolvedValue(new Big(0)),
getSnapshot: jest.fn().mockResolvedValue({
currentValueInBaseCurrency: new Big(3000),
totalCashInBaseCurrency: new Big(1000),
totalInvestment: new Big(2000),
totalInvestmentWithCurrencyEffect: new Big(2000)
}),
getStartDate: jest.fn().mockReturnValue(parseDate('2024-01-01'))
} as unknown as PortfolioCalculator;
}
beforeEach(() => {
jest
.spyOn(activitiesService, 'getActivities')
.mockResolvedValue({ activities: [], count: 0 });
jest
.spyOn(impersonationService, 'validateImpersonationId')
.mockResolvedValue(null);
jest.spyOn(portfolioService, 'getPerformance').mockResolvedValue({
performance: {
currentValueInBaseCurrency: 3000,
netPerformance: 500,
netPerformancePercentage: 0.2,
netPerformancePercentageWithCurrencyEffect: 0.2,
netPerformanceWithCurrencyEffect: 500
}
} as Awaited<ReturnType<typeof portfolioService.getPerformance>>);
jest.spyOn(userService, 'user').mockResolvedValue({
id: userDummyData.id,
settings: {
settings: {
baseCurrency: 'CHF'
}
}
} as unknown as Awaited<ReturnType<typeof userService.user>>);
});
it('should derive the cash and net worth from the account balance when there are no excluded accounts, no emergency fund and no liabilities', async () => {
jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({
accounts: [],
balanceInBaseCurrency: 1000
});
const portfolioCalculator = createPortfolioCalculator();
const summary = await getSummary({
portfolioCalculator,
balanceInBaseCurrency: 1000,
emergencyFundHoldingsValueInBaseCurrency: 0,
filteredValueInBaseCurrency: new Big(3000),
impersonationId: undefined,
userCurrency: 'CHF',
userId: userDummyData.id
});
expect(summary.cash).toBe(1000);
expect(summary.emergencyFund.total).toBe(0);
expect(summary.excludedAccountsAndActivities).toBe(0);
expect(summary.totalAssetsInBaseCurrency).toBe(3000);
expect(summary.totalValueInBaseCurrency).toBe(3000);
});
});
describe('getValueOfAccountsAndPlatforms', () => {
const getValueOfAccountsAndPlatforms = (args: object) => {
return (
@ -353,7 +436,6 @@ describe('PortfolioService', () => {
balance: 100,
currency: 'USD',
id: randomUUID(),
isExcluded: false,
name: 'Account 1',
platform: { name: 'Platform 1' },
platformId: randomUUID()

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

@ -1035,7 +1035,6 @@ export class PortfolioService {
return {
chart: [],
dateOfFirstActivity: undefined,
firstOrderDate: undefined,
hasErrors: false,
performance: {
currentNetWorth: 0,
@ -1093,7 +1092,6 @@ export class PortfolioService {
errors,
hasErrors,
dateOfFirstActivity: parseDate(historicalData[0]?.date),
firstOrderDate: parseDate(historicalData[0]?.date),
performance: {
netPerformance,
netPerformanceWithCurrencyEffect,

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

@ -31,9 +31,12 @@ import {
DEFAULT_LOCALE,
PROPERTY_API_KEY_GHOSTFOLIO,
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_MAX_DAILY_REQUESTS,
PROPERTY_REFERRAL_PARTNERS,
PROPERTY_SYSTEM_MESSAGE,
TAG_ID_EXCLUDE_FROM_ANALYSIS
TAG_ID_EXCLUDE_FROM_ANALYSIS,
THROTTLE_DAILY_KEY,
THROTTLE_DAILY_TTL
} from '@ghostfolio/common/config';
import { SubscriptionType } from '@ghostfolio/common/enums';
import {
@ -50,15 +53,18 @@ import {
import { UserWithSettings } from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectThrottlerStorage, ThrottlerStorage } from '@nestjs/throttler';
import { Prisma, Role, Settings, User } from '@prisma/client';
import { differenceInDays, subDays } from 'date-fns';
import { without } from 'lodash';
import { isNil, without } from 'lodash';
import { createHmac } from 'node:crypto';
@Injectable()
export class UserService {
private readonly logger = new Logger(UserService.name);
public constructor(
private readonly activitiesService: ActivitiesService,
private readonly configurationService: ConfigurationService,
@ -67,7 +73,9 @@ export class UserService {
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService,
private readonly tagService: TagService
private readonly tagService: TagService,
@InjectThrottlerStorage()
private readonly throttlerStorage: ThrottlerStorage
) {}
public async count(args?: Prisma.UserCountArgs) {
@ -228,6 +236,38 @@ export class UserService {
return usersWithAdminRole.length > 0;
}
public async isDailyRequestLimitExceeded({
user
}: {
user: UserWithSettings;
}) {
if (user.subscription?.type === SubscriptionType.Premium) {
return false;
}
const maxDailyRequests = await this.getMaxDailyRequests();
if (maxDailyRequests === undefined) {
return false;
}
try {
const { isBlocked } = await this.throttlerStorage.increment(
`${THROTTLE_DAILY_KEY}-${user.id}`,
THROTTLE_DAILY_TTL,
maxDailyRequests,
THROTTLE_DAILY_TTL,
THROTTLE_DAILY_KEY
);
return isBlocked;
} catch (error) {
this.logger.error(error);
return false;
}
}
public async user(
userWhereUniqueInput: Prisma.UserWhereUniqueInput
): Promise<UserWithSettings | null> {
@ -286,7 +326,7 @@ export class UserService {
updatedAt,
activityCount: analytics?.activityCount,
dataProviderGhostfolioDailyRequests:
analytics?.dataProviderGhostfolioDailyRequests
analytics?.dataProviderGhostfolioDailyRequests ?? 0
};
if (user.settings) {
@ -782,4 +822,26 @@ export class UserService {
return settings;
}
private async getMaxDailyRequests() {
const value = await this.propertyService.getByKey<string>(
PROPERTY_MAX_DAILY_REQUESTS
);
if (isNil(value) || value === '') {
return undefined;
}
const maxDailyRequests = Number(value);
if (!Number.isInteger(maxDailyRequests) || maxDailyRequests < 0) {
this.logger.warn(
`The property ${PROPERTY_MAX_DAILY_REQUESTS} is not a non-negative integer ("${value}"), the daily request limit is not applied`
);
return undefined;
}
return maxDailyRequests;
}
}

1
apps/api/src/helper/account.helper.ts

@ -4,7 +4,6 @@ import { Prisma } from '@prisma/client';
import { endOfToday, isAfter } from 'date-fns';
export const WHERE_ACCOUNT_NOT_EXCLUDED: Prisma.AccountWhereInput = {
isExcluded: false,
tags: {
none: {
tagId: TAG_ID_EXCLUDE_FROM_ANALYSIS

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

@ -20,7 +20,8 @@ import {
getCurrencyFromSymbol,
getStartOfUtcDate,
isCurrency,
isDerivedCurrency
isDerivedCurrency,
isValidSearchQuery
} from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
@ -838,7 +839,9 @@ export class DataProviderService implements OnModuleInit {
let lookupItems: LookupItem[] = [];
const promises: Promise<LookupResponse>[] = [];
if (query?.length < 2) {
query = query?.trim();
if (!isValidSearchQuery(query)) {
return { items: lookupItems };
}

4
apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts

@ -330,8 +330,12 @@ export class YahooFinanceService implements DataProviderInterface {
});
}
} catch (error) {
if (error?.name === 'BadRequestError') {
this.logger.warn(`Could not search for "${query}": ${error.message}`);
} else {
this.logger.error(error);
}
}
return { items };
}

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

@ -135,7 +135,10 @@ export class GfAppComponent implements OnInit {
this.currentSubRoute ===
internalRoutes.home.subRoutes?.holdings.path) ||
(this.currentRoute === internalRoutes.portfolio.path &&
!this.currentSubRoute)) &&
!this.currentSubRoute) ||
(this.currentRoute === internalRoutes.portfolio.path &&
this.currentSubRoute ===
internalRoutes.portfolio.subRoutes?.activities.path)) &&
this.user?.settings?.viewMode !== 'ZEN'
) {
this.hasPermissionToChangeDateRange = true;

1
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html

@ -33,6 +33,7 @@
<mat-tab-group
animationDuration="0ms"
class="mb-4"
[disablePagination]="true"
[mat-stretch-tabs]="false"
>
<mat-tab>

1
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html

@ -105,6 +105,7 @@
<mat-tab-group
animationDuration="0ms"
[disablePagination]="true"
[dynamicHeight]="true"
[mat-stretch-tabs]="false"
>

1
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html

@ -39,6 +39,7 @@
<mat-tab-group
animationDuration="0ms"
class="mb-4"
[disablePagination]="true"
[mat-stretch-tabs]="false"
>
<mat-tab>

4
apps/client/src/app/components/user-account-settings/user-account-settings.component.ts

@ -266,10 +266,6 @@ export class GfUserAccountSettingsComponent implements OnInit {
.fetchExport()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((data) => {
for (const activity of data.activities) {
delete (activity as Omit<typeof activity, 'id'> & { id?: string }).id;
}
downloadAsFile({
content: data,
fileName: `ghostfolio-export-${format(

3
apps/client/src/app/pages/accounts/accounts-page.component.ts

@ -191,7 +191,6 @@ export class GfAccountsPageComponent implements OnInit {
comment,
currency,
id,
isExcluded,
name,
platformId,
tags
@ -206,7 +205,6 @@ export class GfAccountsPageComponent implements OnInit {
comment,
currency,
id,
isExcluded,
name,
platformId,
tags
@ -288,7 +286,6 @@ export class GfAccountsPageComponent implements OnInit {
comment: null,
currency: this.user?.settings?.baseCurrency ?? null,
id: null,
isExcluded: false,
name: null,
platformId: null,
tags: []

4
apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts

@ -27,7 +27,6 @@ import {
} from '@angular/forms';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import {
MAT_DIALOG_DATA,
MatDialogModule,
@ -51,7 +50,6 @@ import { CreateOrUpdateAccountDialogParams } from './interfaces/interfaces';
GfTagsSelectorComponent,
MatAutocompleteModule,
MatButtonModule,
MatCheckboxModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
@ -106,7 +104,6 @@ export class GfCreateOrUpdateAccountDialogComponent {
balance: [this.data.account.balance, Validators.required],
comment: [this.data.account.comment],
currency: [this.data.account.currency, Validators.required],
isExcluded: [this.data.account.isExcluded],
name: [this.data.account.name, Validators.required],
platformId: [null, this.autocompleteObjectValidator()],
tags: [
@ -204,7 +201,6 @@ export class GfCreateOrUpdateAccountDialogComponent {
comment: this.accountForm.get('comment')?.value ?? null,
currency: this.accountForm.get('currency')?.value,
id: this.accountForm.get('accountId')?.value,
isExcluded: this.accountForm.get('isExcluded')?.value,
name: this.accountForm.get('name')?.value,
platformId: this.accountForm.get('platformId')?.value?.id ?? null,
tags: this.accountForm

5
apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html

@ -92,11 +92,6 @@
[tagsAvailable]="tagsAvailable"
/>
</div>
<div class="mb-3 px-2">
<mat-checkbox color="primary" formControlName="isExcluded" i18n
>Exclude from Analysis</mat-checkbox
>
</div>
@if (data.account.id) {
<div>
<mat-form-field appearance="outline" class="w-100">

39
apps/client/src/app/pages/portfolio/activities/activities-page.component.ts

@ -10,7 +10,6 @@ import {
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
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';
@ -92,8 +91,14 @@ export class GfActivitiesPageComponent implements OnInit {
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => {
if (state?.user) {
const previousDateRange = this.getDateRange();
this.updateUser(state.user);
if (previousDateRange !== this.getDateRange()) {
this.pageIndex = 0;
}
this.fetchActivities();
this.changeDetectorRef.markForCheck();
@ -120,7 +125,11 @@ export class GfActivitiesPageComponent implements OnInit {
protected onDeleteActivities() {
this.dataService
.deleteActivities({
filters: this.userService.getFilters()
activityTypes: this.activityTypesFilter.length
? this.activityTypesFilter
: undefined,
filters: this.userService.getFilters(),
range: this.getDateRange()
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
@ -159,7 +168,8 @@ export class GfActivitiesPageComponent implements OnInit {
activityTypes: this.activityTypesFilter.length
? this.activityTypesFilter
: undefined,
filters: this.userService.getFilters()
filters: this.userService.getFilters(),
range: this.getDateRange()
};
}
@ -167,10 +177,6 @@ export class GfActivitiesPageComponent implements OnInit {
.fetchExport(fetchExportParams)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((data) => {
for (const activity of data.activities) {
delete (activity as Omit<typeof activity, 'id'> & { id?: string }).id;
}
downloadAsFile({
content: data,
fileName: `ghostfolio-export-${format(
@ -184,7 +190,7 @@ export class GfActivitiesPageComponent implements OnInit {
protected onExportDrafts(activityIds?: string[]) {
this.dataService
.fetchExport({ activityIds })
.fetchExport({ activityIds, withActivityIds: true })
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((data) => {
downloadAsFile({
@ -277,16 +283,13 @@ export class GfActivitiesPageComponent implements OnInit {
this.dataSource = undefined;
this.totalItems = undefined;
const dateRange = this.user?.settings?.dateRange;
const range = this.isCalendarYear(dateRange) ? dateRange : undefined;
this.dataService
.fetchActivities({
range,
activityTypes: this.activityTypesFilter.length
? this.activityTypesFilter
: undefined,
filters: this.userService.getFilters(),
range: this.getDateRange(),
skip: this.pageIndex * this.pageSize,
sortColumn: this.sortColumn,
sortDirection: this.sortDirection,
@ -311,12 +314,16 @@ export class GfActivitiesPageComponent implements OnInit {
});
}
private isCalendarYear(dateRange?: DateRange) {
if (!dateRange) {
return false;
private getDateRange() {
const dateRange = this.user?.settings?.dateRange;
// Omit the date ranges which do not apply to activities: '1d' spans today
// only, while 'max' would exclude drafts dated in the future
if (!dateRange || ['1d', 'max'].includes(dateRange)) {
return undefined;
}
return /^\d{4}$/.test(dateRange);
return dateRange;
}
private updateUser(aUser: User) {

2
apps/client/src/index.html

@ -1,5 +1,5 @@
<!doctype html>
<html class="h-100 position-relative" lang="${languageCode}">
<html class="h-100" lang="${languageCode}">
<head>
<title>Ghostfolio</title>
<base href="/" />

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

296
apps/client/src/locales/messages.ja.xlf

File diff suppressed because it is too large

296
apps/client/src/locales/messages.ko.xlf

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

11
apps/client/src/styles.scss

@ -411,6 +411,17 @@ ngx-skeleton-loader {
.mdc-dialog__content {
--mat-dialog-supporting-text-color: rgba(var(--dark-primary-text));
}
@media (max-width: 575.98px) {
// Tabs fill the available width on mobile
.mat-mdc-tab-group {
.mat-mdc-tab {
flex-grow: 1;
min-width: unset;
padding: 0 0.5rem;
}
}
}
}
.mat-mdc-fab,

6
apps/client/src/styles/theme.scss

@ -364,6 +364,12 @@ $gf-typography: (
)
);
@include mat.pseudo-checkbox-overrides(
(
full-selected-icon-color: var(--gf-theme-primary-500)
)
);
@include mat.slide-toggle-overrides(
(
selected-focus-handle-color: var(--gf-theme-primary-500),

24
libs/common/src/lib/calculation-helper.spec.ts

@ -1,8 +1,30 @@
import { Big } from 'big.js';
import { format } from 'date-fns';
import { getAnnualizedPerformancePercent } from './calculation-helper';
import {
getAnnualizedPerformancePercent,
getIntervalFromDateRange
} from './calculation-helper';
import { DATE_FORMAT } from './helper';
describe('CalculationHelper', () => {
describe('interval from date range', () => {
it('Get interval of a calendar year', async () => {
const { endDate, startDate } = getIntervalFromDateRange({
dateRange: '2024'
});
// The boundaries are expressed in the local time zone, therefore the
// calendar days must hold independently of the time zone the tests run in
expect(format(startDate, DATE_FORMAT)).toEqual('2023-12-31');
expect(format(endDate, DATE_FORMAT)).toEqual('2024-12-31');
// The start date is exclusive, hence the first instant of the year is
// part of the interval
expect(startDate.getTime()).toEqual(new Date(2024, 0, 1).getTime() - 1);
});
});
describe('annualized performance percentage', () => {
it('Get annualized performance', async () => {
expect(

13
libs/common/src/lib/calculation-helper.ts

@ -7,6 +7,7 @@ import {
startOfWeek,
startOfYear,
subDays,
subMilliseconds,
subYears
} from 'date-fns';
import { isFinite, isNumber } from 'lodash';
@ -75,10 +76,16 @@ export function getIntervalFromDateRange(params: {
break;
case 'max':
break;
default:
default: {
// '2024', '2023', '2022', etc.
endDate = endOfYear(new Date(dateRange));
startDate = max([startDate, new Date(dateRange)]);
const yearStartDate = new Date(Number(dateRange), 0, 1);
// Derive the boundaries of the calendar year in the local time zone, as
// the consumers apply local time zone semantics. As the start date is
// exclusive, the last millisecond of the preceding year is used.
endDate = endOfYear(yearStartDate);
startDate = max([startDate, subMilliseconds(yearStartDate, 1)]);
}
}
return { endDate, startDate };

19
libs/common/src/lib/config.ts

@ -67,6 +67,20 @@ export const DATA_GATHERING_QUEUE_PRIORITY_MEDIUM = Math.round(
DATA_GATHERING_QUEUE_PRIORITY_LOW / 2
);
/**
* The named date ranges, complemented by the calendar years like '2024',
* '2023', '2022', etc.
*/
export const DATE_RANGES = [
'1d',
'1y',
'5y',
'max',
'mtd',
'wtd',
'ytd'
] as const;
export const PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE =
'PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE';
export const PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_HIGH = 1;
@ -253,6 +267,7 @@ export const PROPERTY_DEMO_USER_ID = 'DEMO_USER_ID';
export const PROPERTY_IS_DATA_GATHERING_ENABLED = 'IS_DATA_GATHERING_ENABLED';
export const PROPERTY_IS_READ_ONLY_MODE = 'IS_READ_ONLY_MODE';
export const PROPERTY_IS_USER_SIGNUP_ENABLED = 'IS_USER_SIGNUP_ENABLED';
export const PROPERTY_MAX_DAILY_REQUESTS = 'MAX_DAILY_REQUESTS';
export const PROPERTY_OPENROUTER_MODEL = 'OPENROUTER_MODEL';
export const PROPERTY_OPENROUTER_MODEL_WEB_FETCH = 'OPENROUTER_MODEL_WEB_FETCH';
export const PROPERTY_PROXY_ROUTES = 'PROXY_ROUTES';
@ -287,6 +302,8 @@ export const REPLACE_NAME_PARTS = [
'Xtrackers (IE) Plc -'
];
export const SEARCH_QUERY_MINIMUM_LENGTH = 2;
export const SECTORS = [
'Basic Materials',
'Communication Services',
@ -326,6 +343,8 @@ export const TAG_ID_EXCLUDE_FROM_ANALYSIS =
'f2e868af-8333-459f-b161-cbc6544c24bd';
export const TAG_ID_DEMO = 'efa08cb3-9b9d-4974-ac68-db13a19c4874';
export const THROTTLE_DAILY_KEY = 'daily';
export const THROTTLE_DAILY_TTL = ms('1 day');
export const THROTTLE_DEFAULT_LIMIT = 10;
export const THROTTLE_DEFAULT_TTL = ms('1 minute');
export const THROTTLE_SIGNUP_LIMIT = 5;

10
libs/common/src/lib/dtos/create-account-with-balances.dto.ts

@ -1,6 +1,6 @@
import { AccountBalance } from '@ghostfolio/common/interfaces';
import { IsArray, IsOptional } from 'class-validator';
import { IsArray, IsBoolean, IsOptional } from 'class-validator';
import { CreateAccountDto } from './create-account.dto';
@ -8,4 +8,12 @@ export class CreateAccountWithBalancesDto extends CreateAccountDto {
@IsArray()
@IsOptional()
balances?: AccountBalance[];
/**
* @deprecated Accepted for backward compatibility with old export files
* and mapped to the "Exclude from Analysis" tag (`TAG_ID_EXCLUDE_FROM_ANALYSIS`)
*/
@IsBoolean()
@IsOptional()
isExcluded?: boolean;
}

8
libs/common/src/lib/dtos/create-account.dto.ts

@ -4,7 +4,6 @@ import { Transform, TransformFnParams } from 'class-transformer';
import {
ArrayUnique,
IsArray,
IsBoolean,
IsNumber,
IsOptional,
IsString,
@ -30,13 +29,6 @@ export class CreateAccountDto {
@IsString()
id?: string;
/**
* @deprecated Use the "Exclude from Analysis" tag (`TAG_ID_EXCLUDE_FROM_ANALYSIS`) instead
*/
@IsBoolean()
@IsOptional()
isExcluded?: boolean;
@IsString()
name: string;

8
libs/common/src/lib/dtos/update-account.dto.ts

@ -4,7 +4,6 @@ import { Transform, TransformFnParams } from 'class-transformer';
import {
ArrayUnique,
IsArray,
IsBoolean,
IsNumber,
IsOptional,
IsString,
@ -29,13 +28,6 @@ export class UpdateAccountDto {
@IsString()
id: string;
/**
* @deprecated Use the "Exclude from Analysis" tag (`TAG_ID_EXCLUDE_FROM_ANALYSIS`) instead
*/
@IsBoolean()
@IsOptional()
isExcluded?: boolean;
@IsString()
name: string;

11
libs/common/src/lib/helper.ts

@ -41,6 +41,7 @@ import {
DERIVED_CURRENCIES,
ghostfolioFearAndGreedIndexSymbolCryptocurrencies,
ghostfolioFearAndGreedIndexSymbolStocks,
SEARCH_QUERY_MINIMUM_LENGTH,
TAG_ID_EXCLUDE_FROM_ANALYSIS
} from './config';
import {
@ -455,12 +456,8 @@ export function interpolate(template: string, context: any) {
});
}
export function isAccountExcluded(account: {
isExcluded: boolean;
tags?: { id: string }[];
}) {
export function isAccountExcluded(account: { tags?: { id: string }[] }) {
return (
account.isExcluded ||
account.tags?.some(({ id }) => {
return id === TAG_ID_EXCLUDE_FROM_ANALYSIS;
}) === true
@ -509,6 +506,10 @@ export function isRootCurrency(aCurrency: string) {
});
}
export function isValidSearchQuery(aQuery: string) {
return aQuery?.trim().length >= SEARCH_QUERY_MINIMUM_LENGTH;
}
export function parseDate(date: string): Date | undefined {
if (!date) {
return undefined;

6
libs/common/src/lib/interfaces/responses/portfolio-performance-response.interface.ts

@ -5,11 +5,5 @@ import { ResponseError } from './errors.interface';
export interface PortfolioPerformanceResponse extends ResponseError {
chart?: HistoricalDataItem[];
dateOfFirstActivity: Date;
/**
* @deprecated Use `dateOfFirstActivity` instead
*/
firstOrderDate: Date;
performance: PortfolioPerformance;
}

12
libs/common/src/lib/types/date-range.type.ts

@ -1,9 +1,3 @@
export type DateRange =
| '1d'
| '1y'
| '5y'
| 'max'
| 'mtd'
| 'wtd'
| 'ytd'
| string; // '2024', '2023', '2022', etc.
import type { DATE_RANGES } from '../config';
export type DateRange = (typeof DATE_RANGES)[number] | string; // '2024', '2023', '2022', etc.

3
libs/ui/src/lib/accounts-table/accounts-table.component.stories.ts

@ -24,7 +24,6 @@ const accounts = [
createdAt: new Date('2025-06-01T06:52:49.063Z'),
currency: 'USD',
id: '460d7401-ca43-4ed4-b08e-349f1822e9db',
isExcluded: false,
name: 'Coinbase Account',
platform: {
id: '8dc24b88-bb92-4152-af25-fe6a31643e26',
@ -46,7 +45,6 @@ const accounts = [
createdAt: new Date('2025-06-01T06:48:53.055Z'),
currency: 'USD',
id: '6d773e31-0583-4c85-a247-e69870b4f1ee',
isExcluded: false,
name: 'Private Banking Account',
platform: {
id: '43e8fcd1-5b79-4100-b678-d2229bd1660d',
@ -68,7 +66,6 @@ const accounts = [
createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD',
id: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
isExcluded: false,
name: 'Trading Account',
platform: {
id: '9da3a8a7-4795-43e3-a6db-ccb914189737',

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

@ -58,12 +58,19 @@
<button
class="align-items-center d-flex"
mat-menu-item
[disabled]="dataSource()?.data.length === 0"
[disabled]="!canExportActivities()"
(click)="onExport()"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="cloud-download-outline" />
@if (canExportActivities()) {
<span i18n
>Export {{ totalItems > 1 ? totalItems : '' }}
{totalItems, plural, =1 {Activity} other {Activities}}</span
>
} @else {
<span i18n>Export Activities</span>
}
</span>
</button>
}
@ -84,14 +91,19 @@
<button
class="align-items-center d-flex"
mat-menu-item
[disabled]="
dataSource()?.data.length === 0 || !hasPermissionToDeleteActivity
"
[disabled]="!canDeleteActivities()"
(click)="onDeleteActivities()"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline" />
@if (canDeleteActivities()) {
<span i18n
>Delete {{ totalItems > 1 ? totalItems : '' }}
{totalItems, plural, =1 {Activity} other {Activities}}</span
>
} @else {
<span i18n>Delete Activities</span>
}
</span>
</button>
</mat-menu>

5
libs/ui/src/lib/activities-table/activities-table.component.stories.ts

@ -44,7 +44,6 @@ const activities: Activity[] = [
createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD',
id: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
isExcluded: false,
name: 'Trading Account',
platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737',
updatedAt: new Date('2025-06-01T06:53:10.569Z'),
@ -111,7 +110,6 @@ const activities: Activity[] = [
createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD',
id: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
isExcluded: false,
name: 'Trading Account',
platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737',
updatedAt: new Date('2025-06-01T06:53:10.569Z'),
@ -178,7 +176,6 @@ const activities: Activity[] = [
createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD',
id: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
isExcluded: false,
name: 'Trading Account',
platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737',
updatedAt: new Date('2025-06-01T06:53:10.569Z'),
@ -245,7 +242,6 @@ const activities: Activity[] = [
createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD',
id: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
isExcluded: false,
name: 'Trading Account',
platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737',
updatedAt: new Date('2025-06-01T06:53:10.569Z'),
@ -312,7 +308,6 @@ const activities: Activity[] = [
createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD',
id: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
isExcluded: false,
name: 'Trading Account',
platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737',
updatedAt: new Date('2025-06-01T06:53:10.569Z'),

19
libs/ui/src/lib/activities-table/activities-table.component.ts

@ -282,6 +282,20 @@ export class GfActivitiesTableComponent implements AfterViewInit, OnInit {
);
}
public canDeleteActivities() {
return (
(this.dataSource()?.data.length ?? 0) > 0 &&
this.hasPermissionToDeleteActivity
);
}
public canExportActivities() {
return (
(this.dataSource()?.data.length ?? 0) > 0 &&
this.hasPermissionToExportActivities
);
}
public isExcludedFromAnalysis(activity: Activity) {
return (
(activity.account && isAccountExcluded(activity.account)) ??
@ -314,7 +328,10 @@ export class GfActivitiesTableComponent implements AfterViewInit, OnInit {
this.activitiesDeleted.emit();
},
confirmType: ConfirmationDialogType.Warn,
title: $localize`Do you really want to delete these activities?`
title:
this.totalItems === 1
? $localize`Do you really want to delete this activity?`
: $localize`Do you really want to delete these ${this.totalItems}:count: activities?`
});
}

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

@ -202,6 +202,11 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
this.searchFormControl.valueChanges
.pipe(
map((searchTerm) => {
return searchTerm?.trim();
}),
debounceTime(300),
distinctUntilChanged(),
tap(() => {
this.isLoading = {
accounts: true,
assetProfiles: true,
@ -216,11 +221,7 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
};
this.changeDetectorRef.markForCheck();
return searchTerm?.trim();
}),
debounceTime(300),
distinctUntilChanged(),
switchMap((searchTerm) => {
const results = {
accounts: [],

53
libs/ui/src/lib/services/data.service.ts

@ -329,8 +329,24 @@ export class DataService {
return this.http.delete<AccountBalance>(`/api/v1/account-balance/${aId}`);
}
public deleteActivities({ filters }: { filters?: Filter[] }) {
const params = this.buildFiltersAsQueryParams({ filters });
public deleteActivities({
activityTypes,
filters,
range
}: {
activityTypes?: string[];
filters?: Filter[];
range?: DateRange;
}) {
let params = this.buildFiltersAsQueryParams({ filters });
if (activityTypes?.length) {
params = params.append('activityTypes', activityTypes.join(','));
}
if (range) {
params = params.append('range', range);
}
return this.http.delete<number>('/api/v1/activities', { params });
}
@ -459,11 +475,15 @@ export class DataService {
public fetchExport({
activityIds,
activityTypes,
filters
filters,
range,
withActivityIds = false
}: {
activityIds?: string[];
activityTypes?: string[];
filters?: Filter[];
range?: DateRange;
withActivityIds?: boolean;
} = {}) {
let params = this.buildFiltersAsQueryParams({ filters });
@ -475,9 +495,26 @@ export class DataService {
params = params.append('activityTypes', activityTypes.join(','));
}
return this.http.get<ExportResponse>('/api/v1/export', {
if (range) {
params = params.append('range', range);
}
return this.http
.get<ExportResponse>('/api/v1/export', {
params
});
})
.pipe(
map((exportResponse) => {
if (!withActivityIds) {
for (const activity of exportResponse.activities) {
delete (activity as Omit<typeof activity, 'id'> & { id?: string })
.id;
}
}
return exportResponse;
})
);
}
public fetchHoldingDetail({
@ -509,8 +546,7 @@ export class DataService {
public fetchInfo(): InfoItem {
const info = cloneDeep((window as any).info);
const utmSource = window.localStorage.getItem('utm_source') as
| 'ios'
| 'trusted-web-activity';
'ios' | 'trusted-web-activity';
info.globalPermissions = filterGlobalPermissions(
info.globalPermissions,
@ -949,8 +985,7 @@ export class DataService {
public updateInfo() {
this.http.get<InfoItem>('/api/v1/info').subscribe((info) => {
const utmSource = window.localStorage.getItem('utm_source') as
| 'ios'
| 'trusted-web-activity';
'ios' | 'trusted-web-activity';
info.globalPermissions = filterGlobalPermissions(
info.globalPermissions,

8
libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.ts

@ -41,6 +41,7 @@ import {
debounceTime,
distinctUntilChanged,
filter,
map,
switchMap
} from 'rxjs/operators';
@ -128,6 +129,9 @@ export class GfSymbolAutocompleteComponent
this.control.valueChanges
.pipe(
map((query) => {
return isString(query) ? query.trim() : query;
}),
filter((query) => {
if (query?.length === 0) {
this.showDefaultOptions();
@ -137,13 +141,13 @@ export class GfSymbolAutocompleteComponent
return isString(query);
}),
debounceTime(400),
distinctUntilChanged(),
tap(() => {
this.isLoading = true;
this.changeDetectorRef.markForCheck();
}),
debounceTime(400),
distinctUntilChanged(),
takeUntilDestroyed(this.destroyRef),
switchMap((query: string) => {
return this.dataService.fetchSymbols({

596
package-lock.json

@ -1,12 +1,12 @@
{
"name": "ghostfolio",
"version": "3.37.0",
"version": "3.39.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ghostfolio",
"version": "3.37.0",
"version": "3.39.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
@ -43,8 +43,8 @@
"@nestjs/serve-static": "5.0.5",
"@nestjs/throttler": "6.5.0",
"@openrouter/ai-sdk-provider": "3.0.0",
"@prisma/adapter-pg": "7.8.0",
"@prisma/client": "7.8.0",
"@prisma/adapter-pg": "7.9.1",
"@prisma/client": "7.9.1",
"@simplewebauthn/browser": "13.3.0",
"@simplewebauthn/server": "13.3.1",
"ai": "7.0.37",
@ -127,7 +127,7 @@
"@nx/storybook": "23.0.2",
"@nx/web": "23.0.2",
"@nx/workspace": "23.0.2",
"@prisma/config": "7.8.0",
"@prisma/config": "7.9.1",
"@schematics/angular": "21.2.6",
"@storybook/addon-docs": "10.1.10",
"@storybook/addon-themes": "10.1.10",
@ -157,7 +157,7 @@
"nx": "23.0.2",
"prettier": "3.9.6",
"prettier-plugin-organize-attributes": "1.0.0",
"prisma": "7.8.0",
"prisma": "7.9.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"replace-in-file": "8.4.0",
@ -3908,33 +3908,33 @@
}
},
"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==",
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.3.tgz",
"integrity": "sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ==",
"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==",
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.3.tgz",
"integrity": "sha512-LAciWM0M1dCL8hlsxu2venbVZcdxema0BtDfpWYVqr+Y468UADw0pFWidhKw1M8sfJ8rdLT71tjMmnirf/IZRQ==",
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"pglite-server": "dist/scripts/server.js"
},
"peerDependencies": {
"@electric-sql/pglite": "0.4.1"
"@electric-sql/pglite": "0.4.3"
}
},
"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==",
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.3.3.tgz",
"integrity": "sha512-AlzLJTRJ8+UFgK8CmxIpyIpJ0+YaFw02IiOSdYrqxwPXdSyeIShz8aa9Tq+tYFXdPwcaMp/Fc80mQZ1dkOQ/wg==",
"devOptional": true,
"license": "Apache-2.0",
"peerDependencies": {
"@electric-sql/pglite": "0.4.1"
"@electric-sql/pglite": "0.4.3"
}
},
"node_modules/@emnapi/core": {
@ -10041,24 +10041,24 @@
}
},
"node_modules/@prisma/adapter-pg": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.8.0.tgz",
"integrity": "sha512-ygb3UkerK3v8MDpXVgCISdRNDozpxh6+JVJgiIGbSr5KBgz10LLf5ejUskPGoXlsIjxsOu6nuy1JVQr2EKGSlg==",
"version": "7.9.1",
"resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.9.1.tgz",
"integrity": "sha512-Ho2RK1KanQxLNSC0sR5bpiiVep10sWPLXCcxK+KXfI/Q69TMRbiafSvLPv3V9snimX72rMCqGlyJ4sBO4lKTAw==",
"license": "Apache-2.0",
"dependencies": {
"@prisma/driver-adapter-utils": "7.8.0",
"@prisma/driver-adapter-utils": "7.9.1",
"@types/pg": "^8.16.0",
"pg": "^8.16.3",
"postgres-array": "3.0.4"
}
},
"node_modules/@prisma/client": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.8.0.tgz",
"integrity": "sha512-HFp3Dawv/3sU3JtlPha90IB+48lS7zHiH4LKZPjmcE8YH5P9DOXGPvo8dqOtO7MqLDd1p2hOWMcFlRT1DMblHw==",
"version": "7.9.1",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.9.1.tgz",
"integrity": "sha512-+xgrh2EhJVF79wC0yX5G4PI1Rdcm7Qn/nekNQ+t/O153wtNggruHal+fXHSa0QE+Tp/Cw5wvxeCEhZZ59xGm8Q==",
"license": "Apache-2.0",
"dependencies": {
"@prisma/client-runtime-utils": "7.8.0"
"@prisma/client-runtime-utils": "7.9.1"
},
"engines": {
"node": "^20.19 || ^22.12 || >=24.0"
@ -10077,15 +10077,15 @@
}
},
"node_modules/@prisma/client-runtime-utils": {
"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==",
"version": "7.9.1",
"resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.9.1.tgz",
"integrity": "sha512-mVIBGYdO5CFmK0HvjxrtfIyQQcPdb88pSCeVQriVQPVZyDovIWblpHfOgcS8QO187j3QF0ePArH8qPhp0AU2vg==",
"license": "Apache-2.0"
},
"node_modules/@prisma/config": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.8.0.tgz",
"integrity": "sha512-HFESzd9rx2ZQxlK+TL7tu1HPvCqrHiL6LCxYykI2c34mvaUuIVVl3lYuicJD/MNnzgPnyeBEMlK4WTomJCV5jw==",
"version": "7.9.1",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.9.1.tgz",
"integrity": "sha512-4znKhxTmXmuPye9Z6pbIyYb5VZlkZ05qG1L6Dr4g+7oTwc6V50Bs9XirFBDdjWt+H/AabMn9aUnxBcvj8z05aA==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
@ -10096,97 +10096,95 @@
}
},
"node_modules/@prisma/debug": {
"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==",
"version": "7.9.1",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.9.1.tgz",
"integrity": "sha512-/cpVZ4itxtcgB8GHBvZtcmuEjq+lWsLrRJxFMbwZrT1RIdtuKmUm7PPGo/wzfbYpBrk+9WmmBE8CHJw2rybKDQ==",
"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==",
"version": "0.24.17",
"resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.17.tgz",
"integrity": "sha512-UvdZzmpFwknnfreh6Jije84ekkYGPYEJhXG1tFzCsCfQyzJifrOo38eZc0qajzvaC6OLUOrN9ML5XfCnEZL9DA==",
"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",
"@electric-sql/pglite": "0.4.3",
"@electric-sql/pglite-socket": "0.1.3",
"@electric-sql/pglite-tools": "0.3.3",
"@prisma/get-platform": "7.2.0",
"@prisma/query-plan-executor": "7.2.0",
"@prisma/streams-local": "0.1.2",
"@prisma/streams-local": "0.1.11",
"find-my-way": "9.7.0",
"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",
"valibot": "1.4.2",
"zeptomatch": "2.1.0"
}
},
"node_modules/@prisma/driver-adapter-utils": {
"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==",
"version": "7.9.1",
"resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.9.1.tgz",
"integrity": "sha512-vmHehG7nn/heW32DXXpp13DxxAxVVe6n250oEt3dOL2E/4bt3olktKZN0mzSuxMMronyMSkbeW2uCOn3F4g8RQ==",
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "7.8.0"
"@prisma/debug": "7.9.1"
}
},
"node_modules/@prisma/engines": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.8.0.tgz",
"integrity": "sha512-jx3rCnNNrt5uzbkKlegtQ2GZHxSlihMCzutgT/BP6UIDF1r9tDI39hV/0T/cHZgzJ3ELbuQPXlVZy+Y1n0pcgw==",
"version": "7.9.1",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.9.1.tgz",
"integrity": "sha512-UprXSMNXx2NF5ow4pqaQtE8OuBz6K78B0wc0tn2L28G5r933iWp1DR9Do2qWrsNvvFIP3x6mpEWnQtckMO0Uhg==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@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"
"@prisma/debug": "7.9.1",
"@prisma/engines-version": "7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad",
"@prisma/fetch-engine": "7.9.1",
"@prisma/get-platform": "7.9.1"
}
},
"node_modules/@prisma/engines-version": {
"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==",
"version": "7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad.tgz",
"integrity": "sha512-2BsPPFksz3CQUXG6af3rVCtJKg6+JJGJTtfgu2fU8DdXhOfkBjulCq8mwybCd6ge0/jhZq2kOtLAbmUDMyI1nA==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/engines/node_modules/@prisma/get-platform": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz",
"integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==",
"version": "7.9.1",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.9.1.tgz",
"integrity": "sha512-PK8R60YZRQvYxBrGG9i7l2/rFyzy+2MuI1dKtmtrCqPH8YpiJx/MfiC7LRzX5786rZDEv7BngcjfIJW4/9ADuw==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "7.8.0"
"@prisma/debug": "7.9.1"
}
},
"node_modules/@prisma/fetch-engine": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.8.0.tgz",
"integrity": "sha512-gwB0Euiz/DDRyxFRpLXYlK3RfaZUj1c5dAYMuhZYfApg7arknJlcb9bIsOHDppJmbqYaVA+yBIiFMDBfprsNPQ==",
"version": "7.9.1",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.9.1.tgz",
"integrity": "sha512-9DwxrNTeT25Orbu9CWh0CZvVlyY1lmscpbaeLZcOnuR7zcuFrt91YSmmOfIm7zJ08YOZ6mVzURKwLoMwEBcK8w==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "7.8.0",
"@prisma/engines-version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a",
"@prisma/get-platform": "7.8.0"
"@prisma/debug": "7.9.1",
"@prisma/engines-version": "7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad",
"@prisma/get-platform": "7.9.1"
}
},
"node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz",
"integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==",
"version": "7.9.1",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.9.1.tgz",
"integrity": "sha512-PK8R60YZRQvYxBrGG9i7l2/rFyzy+2MuI1dKtmtrCqPH8YpiJx/MfiC7LRzX5786rZDEv7BngcjfIJW4/9ADuw==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "7.8.0"
"@prisma/debug": "7.9.1"
}
},
"node_modules/@prisma/get-platform": {
@ -10214,9 +10212,9 @@
"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==",
"version": "0.1.11",
"resolved": "https://registry.npmjs.org/@prisma/streams-local/-/streams-local-0.1.11.tgz",
"integrity": "sha512-0TcebL559MByKqTJ+SsrFIEg228iw8UCVRFckzgfRSiJqczhs+MuAgWOF9lnOIV/IVqvu+KMnFTH0eDeTQMpUg==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
@ -10226,7 +10224,7 @@
"proper-lockfile": "^4.1.2"
},
"engines": {
"bun": ">=1.3.6",
"bun": ">=1.2.0",
"node": ">=22.0.0"
}
},
@ -10244,14 +10242,23 @@
}
},
"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==",
"version": "0.33.0",
"resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.33.0.tgz",
"integrity": "sha512-V2fX/nKEymNTrHXwfP26PGjoLStO35Ogu+ex7CFJbLrMYEcZxxZpiSNOs7px23Hk5mzLWvM5RsqG6Ka+rha+wg==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@radix-ui/react-toggle": "1.1.10",
"chart.js": "4.5.1"
"@visx/curve": "4.0.1-alpha.0",
"@visx/event": "4.0.1-alpha.0",
"@visx/grid": "4.0.1-alpha.0",
"@visx/group": "4.0.1-alpha.0",
"@visx/responsive": "4.0.1-alpha.0",
"@visx/scale": "4.0.1-alpha.0",
"@visx/shape": "4.0.1-alpha.0",
"d3-array": "3.2.4",
"d3-shape": "3.2.0",
"elkjs": "0.11.1"
},
"engines": {
"node": "^20.19 || ^22.12 || >=24.0",
@ -12501,9 +12508,8 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
"devOptional": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@types/geojson": "*"
}
@ -12531,9 +12537,8 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT",
"optional": true,
"peer": true
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/d3-polygon": {
"version": "3.0.2",
@ -12756,9 +12761,8 @@
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT",
"optional": true,
"peer": true
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/google-spreadsheet": {
"version": "3.1.5",
@ -12902,7 +12906,7 @@
"version": "4.17.24",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
"integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/luxon": {
@ -13056,7 +13060,6 @@
"integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@ -14168,6 +14171,265 @@
"node": ">= 20"
}
},
"node_modules/@visx/curve": {
"version": "4.0.1-alpha.0",
"resolved": "https://registry.npmjs.org/@visx/curve/-/curve-4.0.1-alpha.0.tgz",
"integrity": "sha512-jRu61Uz274pV1zyioXmboyrLutYbnKsgjj4njSGCnhdXj5GkZvZbg+ThDb6oOzoAnJOBRLz4rzPlWvNJOzuVMg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@visx/vendor": "4.0.0-alpha.0"
}
},
"node_modules/@visx/event": {
"version": "4.0.1-alpha.0",
"resolved": "https://registry.npmjs.org/@visx/event/-/event-4.0.1-alpha.0.tgz",
"integrity": "sha512-EQqCMSv/s8NbFjo+hz3FKsvvYfP+2QslsFJ/24/O5l/W+7UC6J6aAvO0ujVwrTwdYbuQ+vhxKi1xdPdKR/qj1g==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/react": "*",
"@visx/point": "4.0.1-alpha.0"
}
},
"node_modules/@visx/grid": {
"version": "4.0.1-alpha.0",
"resolved": "https://registry.npmjs.org/@visx/grid/-/grid-4.0.1-alpha.0.tgz",
"integrity": "sha512-rycutGmTHO+znNdPumheWMglm7YfpffvRwUkVy5zy4WoORIuKTMkDxwnOzHG2xMxU3EE/YCd37xFV5AxA30yeg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/react": "*",
"@visx/curve": "4.0.1-alpha.0",
"@visx/group": "4.0.1-alpha.0",
"@visx/point": "4.0.1-alpha.0",
"@visx/scale": "4.0.1-alpha.0",
"@visx/shape": "4.0.1-alpha.0",
"classnames": "^2.3.1"
},
"peerDependencies": {
"react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0"
}
},
"node_modules/@visx/group": {
"version": "4.0.1-alpha.0",
"resolved": "https://registry.npmjs.org/@visx/group/-/group-4.0.1-alpha.0.tgz",
"integrity": "sha512-V19l7iQ7jccBv8kao/EByuI6o4xtxzzLV9nqVI1hRvmdzTVsuLpqlwzYCZUXJaTVvUWf8s4D2SQFjGkj/Nw+0w==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/react": "*",
"classnames": "^2.3.1"
},
"peerDependencies": {
"react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0"
}
},
"node_modules/@visx/point": {
"version": "4.0.1-alpha.0",
"resolved": "https://registry.npmjs.org/@visx/point/-/point-4.0.1-alpha.0.tgz",
"integrity": "sha512-ijTfr/Nx09f03vIj9nyTr3z4Xth4Y75427UaogJh6dnIRLMEFHQOwNu791sbfiNj0a+ZXuaE32h0vKrFe4/8Qg==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@visx/responsive": {
"version": "4.0.1-alpha.0",
"resolved": "https://registry.npmjs.org/@visx/responsive/-/responsive-4.0.1-alpha.0.tgz",
"integrity": "sha512-o+1zGywQZY0+yOx3Iw87wc4bbPJRr/HnIukTwfOz4UVyj9pB1OQNVHB7OORO1+LBHJceWpB31co/ZV9KHncKrA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/lodash": "^4.17.13",
"@types/react": "*",
"lodash": "^4.17.21"
},
"peerDependencies": {
"react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0"
}
},
"node_modules/@visx/scale": {
"version": "4.0.1-alpha.0",
"resolved": "https://registry.npmjs.org/@visx/scale/-/scale-4.0.1-alpha.0.tgz",
"integrity": "sha512-nzjeE87vFSAXGWFiiNfBpNLAf0Q8Qmf6syvKLjqNi4kGZkdhbUll3E/59YsgWXmjM8+llPLWzGsP+JPvo5eq1A==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@visx/vendor": "4.0.0-alpha.0"
}
},
"node_modules/@visx/shape": {
"version": "4.0.1-alpha.0",
"resolved": "https://registry.npmjs.org/@visx/shape/-/shape-4.0.1-alpha.0.tgz",
"integrity": "sha512-62QeiVNmPlterQGwhkEDcbq7M0MqY0lBsK5QKXtM9ZoPZWkuGV3aykA3+Xu20B2FAvyJq4LqJzBc7Sxr+EAdbA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/lodash": "^4.17.13",
"@types/react": "*",
"@visx/curve": "4.0.1-alpha.0",
"@visx/group": "4.0.1-alpha.0",
"@visx/scale": "4.0.1-alpha.0",
"@visx/vendor": "4.0.0-alpha.0",
"classnames": "^2.3.1",
"lodash": "^4.17.21"
},
"peerDependencies": {
"react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0"
}
},
"node_modules/@visx/vendor": {
"version": "4.0.0-alpha.0",
"resolved": "https://registry.npmjs.org/@visx/vendor/-/vendor-4.0.0-alpha.0.tgz",
"integrity": "sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ==",
"devOptional": true,
"license": "MIT and ISC",
"dependencies": {
"@types/d3-array": "3.0.3",
"@types/d3-color": "3.1.0",
"@types/d3-delaunay": "6.0.1",
"@types/d3-format": "3.0.1",
"@types/d3-geo": "3.1.0",
"@types/d3-interpolate": "3.0.1",
"@types/d3-path": "3.1.1",
"@types/d3-scale": "4.0.2",
"@types/d3-shape": "3.1.7",
"@types/d3-time": "3.0.0",
"@types/d3-time-format": "2.1.0",
"d3-array": "3.2.1",
"d3-color": "3.1.0",
"d3-delaunay": "6.0.2",
"d3-format": "3.1.0",
"d3-geo": "3.1.0",
"d3-interpolate": "3.0.1",
"d3-path": "3.1.0",
"d3-scale": "4.0.2",
"d3-shape": "3.2.0",
"d3-time": "3.1.0",
"d3-time-format": "4.1.0",
"internmap": "2.0.3"
}
},
"node_modules/@visx/vendor/node_modules/@types/d3-array": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz",
"integrity": "sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@visx/vendor/node_modules/@types/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@visx/vendor/node_modules/@types/d3-delaunay": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.1.tgz",
"integrity": "sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@visx/vendor/node_modules/@types/d3-format": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.1.tgz",
"integrity": "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@visx/vendor/node_modules/@types/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@visx/vendor/node_modules/@types/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@visx/vendor/node_modules/@types/d3-shape": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@visx/vendor/node_modules/@types/d3-time": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.0.tgz",
"integrity": "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@visx/vendor/node_modules/@types/d3-time-format": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-2.1.0.tgz",
"integrity": "sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@visx/vendor/node_modules/d3-array": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.1.tgz",
"integrity": "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==",
"devOptional": true,
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@visx/vendor/node_modules/d3-delaunay": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.2.tgz",
"integrity": "sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ==",
"devOptional": true,
"license": "ISC",
"dependencies": {
"delaunator": "5"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@visx/vendor/node_modules/d3-format": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
"devOptional": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/@visx/vendor/node_modules/d3-geo": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.0.tgz",
"integrity": "sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==",
"devOptional": true,
"license": "ISC",
"dependencies": {
"d3-array": "2.5.0 - 3"
},
"engines": {
"node": ">=12"
}
},
"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",
@ -15462,9 +15724,9 @@
}
},
"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==",
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/better-result/-/better-result-2.10.0.tgz",
"integrity": "sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw==",
"devOptional": true,
"license": "MIT"
},
@ -16392,6 +16654,13 @@
"validator": "^13.15.22"
}
},
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
"devOptional": true,
"license": "MIT"
},
"node_modules/clean-css": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz",
@ -18049,8 +18318,7 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/cytoscape": {
"version": "3.33.2",
@ -18157,9 +18425,8 @@
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"devOptional": true,
"license": "ISC",
"optional": true,
"peer": true,
"dependencies": {
"internmap": "1 - 2"
},
@ -18214,9 +18481,8 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"devOptional": true,
"license": "ISC",
"optional": true,
"peer": true,
"engines": {
"node": ">=12"
}
@ -18372,9 +18638,8 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"devOptional": true,
"license": "ISC",
"optional": true,
"peer": true,
"engines": {
"node": ">=12"
}
@ -18408,9 +18673,8 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"devOptional": true,
"license": "ISC",
"optional": true,
"peer": true,
"dependencies": {
"d3-color": "1 - 3"
},
@ -18422,9 +18686,8 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"devOptional": true,
"license": "ISC",
"optional": true,
"peer": true,
"engines": {
"node": ">=12"
}
@ -18516,9 +18779,8 @@
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"devOptional": true,
"license": "ISC",
"optional": true,
"peer": true,
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
@ -18560,9 +18822,8 @@
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"devOptional": true,
"license": "ISC",
"optional": true,
"peer": true,
"dependencies": {
"d3-path": "^3.1.0"
},
@ -18574,9 +18835,8 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"devOptional": true,
"license": "ISC",
"optional": true,
"peer": true,
"dependencies": {
"d3-array": "2 - 3"
},
@ -18588,9 +18848,8 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"devOptional": true,
"license": "ISC",
"optional": true,
"peer": true,
"dependencies": {
"d3-time": "1 - 3"
},
@ -18943,9 +19202,8 @@
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz",
"integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==",
"devOptional": true,
"license": "ISC",
"optional": true,
"peer": true,
"dependencies": {
"robust-predicates": "^3.0.2"
}
@ -19286,6 +19544,13 @@
"integrity": "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==",
"license": "ISC"
},
"node_modules/elkjs": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.11.1.tgz",
"integrity": "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==",
"devOptional": true,
"license": "EPL-2.0"
},
"node_modules/emittery": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz",
@ -20460,6 +20725,13 @@
],
"license": "MIT"
},
"node_modules/fast-decode-uri-component": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
"integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==",
"devOptional": true,
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -20510,6 +20782,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-querystring": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz",
"integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"fast-decode-uri-component": "^1.0.1"
}
},
"node_modules/fast-redact": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.5.0.tgz",
@ -20898,6 +21180,21 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/find-my-way": {
"version": "9.7.0",
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.7.0.tgz",
"integrity": "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-querystring": "^1.0.0",
"safe-regex2": "^5.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@ -21768,9 +22065,9 @@
"license": "ISC"
},
"node_modules/grammex": {
"version": "3.1.12",
"resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz",
"integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==",
"version": "3.1.13",
"resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.13.tgz",
"integrity": "sha512-LnPnhOBLEJEVKS8WFDVaA397L9Kq55Q9oSITJiVLHVdhAclfUkWzQv74KhvZHKL2Q09Pb1XdsrOsZ4LfTFFTEg==",
"devOptional": true,
"license": "MIT"
},
@ -22645,9 +22942,8 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"devOptional": true,
"license": "ISC",
"optional": true,
"peer": true,
"engines": {
"node": ">=12"
}
@ -29188,17 +29484,17 @@
}
},
"node_modules/prisma": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-7.8.0.tgz",
"integrity": "sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw==",
"version": "7.9.1",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-7.9.1.tgz",
"integrity": "sha512-aPqePoZIqwlAchbgbFDO/wHqGB+7H1nj9gaM+OsL9h77S5S3TnLd9BgD3LnoeDikULo7cl2HSUrEyQ55Z7DYbg==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/config": "7.8.0",
"@prisma/dev": "0.24.3",
"@prisma/engines": "7.8.0",
"@prisma/studio-core": "0.27.3",
"@prisma/config": "7.9.1",
"@prisma/dev": "0.24.17",
"@prisma/engines": "7.9.1",
"@prisma/studio-core": "0.33.0",
"mysql2": "3.15.3",
"postgres": "3.4.7"
},
@ -30152,6 +30448,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ret": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz",
"integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/retry": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
@ -30183,9 +30489,8 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
"integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==",
"license": "Unlicense",
"optional": true,
"peer": true
"devOptional": true,
"license": "Unlicense"
},
"node_modules/rolldown": {
"version": "1.0.0-rc.4",
@ -30532,6 +30837,29 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safe-regex2": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz",
"integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==",
"devOptional": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"ret": "~0.5.0"
},
"bin": {
"safe-regex2": "bin/safe-regex2.js"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@ -34374,9 +34702,9 @@
"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==",
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.2.tgz",
"integrity": "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==",
"devOptional": true,
"license": "MIT",
"peerDependencies": {

10
package.json

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "3.37.0",
"version": "3.39.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio",
@ -87,8 +87,8 @@
"@nestjs/serve-static": "5.0.5",
"@nestjs/throttler": "6.5.0",
"@openrouter/ai-sdk-provider": "3.0.0",
"@prisma/adapter-pg": "7.8.0",
"@prisma/client": "7.8.0",
"@prisma/adapter-pg": "7.9.1",
"@prisma/client": "7.9.1",
"@simplewebauthn/browser": "13.3.0",
"@simplewebauthn/server": "13.3.1",
"ai": "7.0.37",
@ -171,7 +171,7 @@
"@nx/storybook": "23.0.2",
"@nx/web": "23.0.2",
"@nx/workspace": "23.0.2",
"@prisma/config": "7.8.0",
"@prisma/config": "7.9.1",
"@schematics/angular": "21.2.6",
"@storybook/addon-docs": "10.1.10",
"@storybook/addon-themes": "10.1.10",
@ -201,7 +201,7 @@
"nx": "23.0.2",
"prettier": "3.9.6",
"prettier-plugin-organize-attributes": "1.0.0",
"prisma": "7.8.0",
"prisma": "7.9.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"replace-in-file": "8.4.0",

21
prisma/migrations/20260801103609_removed_is_excluded_from_account/migration.sql

@ -0,0 +1,21 @@
-- Create the "EXCLUDE_FROM_ANALYSIS" tag if it does not exist yet
INSERT INTO "Tag" ("id", "name")
VALUES ('f2e868af-8333-459f-b161-cbc6544c24bd', 'EXCLUDE_FROM_ANALYSIS')
ON CONFLICT DO NOTHING;
-- Migrate accounts with "isExcluded" to the "EXCLUDE_FROM_ANALYSIS" tag
INSERT INTO "TagsOnAccounts" ("accountId", "tagId", "updatedAt", "userId")
SELECT
"id",
'f2e868af-8333-459f-b161-cbc6544c24bd',
CURRENT_TIMESTAMP,
"userId"
FROM "Account"
WHERE "isExcluded" = true
ON CONFLICT DO NOTHING;
-- DropIndex
DROP INDEX "Account_isExcluded_idx";
-- AlterTable
ALTER TABLE "Account" DROP COLUMN "isExcluded";

3
prisma/schema.prisma

@ -33,8 +33,6 @@ model Account {
createdAt DateTime @default(now())
currency String?
id String @default(uuid())
/// @deprecated Use the "Exclude from Analysis" tag (`TAG_ID_EXCLUDE_FROM_ANALYSIS`) instead
isExcluded Boolean @default(false)
name String?
platform Platform? @relation(fields: [platformId], references: [id])
platformId String?
@ -46,7 +44,6 @@ model Account {
@@id([id, userId])
@@index([currency])
@@index([id])
@@index([isExcluded])
@@index([name])
@@index([userId])
}

Loading…
Cancel
Save