diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ac5b9d22..5b26f59d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,36 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Fixed + +- Fixed the internal server error on a failed social login by redirecting to the login page + +## 3.52.0 - 2026-08-15 + +### Added + +- Added the business logic and tests for stock splits of an asset profile (experimental) +- Added the scopes to the access +- Added an index for `symbolProfileId` to the order database table + +### Changed + +- Restricted the creation of tags to unique names in the tags selector component +- Changed the redaction of the monetary values in impersonation mode to be based on the scopes of the access +- Deprecated the `permissions` attribute of the access in favor of the scopes +- Extended the `GET api/v1/access` endpoint by the scopes +- Extended the `GET api/v1/user` endpoint by the scopes +- Improved the performance of deleting activities by loading only the required data + +### Fixed + +- Fixed the missing currency conversion of the dividends on the analysis page +- Fixed the missing error state in the watchlist +- Fixed the missing loading indicator in the benchmarks of the markets overview +- Fixed the incorrect error log output when deleting activities + ## 3.51.0 - 2026-08-14 ### Changed diff --git a/apps/api/src/app/access/access.controller.ts b/apps/api/src/app/access/access.controller.ts index 54fadec68..8bd22fc25 100644 --- a/apps/api/src/app/access/access.controller.ts +++ b/apps/api/src/app/access/access.controller.ts @@ -6,6 +6,7 @@ import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos'; import { SubscriptionType } from '@ghostfolio/common/enums'; import { Access, AccessSettings } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; +import { getScopesOfAccess } from '@ghostfolio/common/scopes'; import type { RequestWithUser } from '@ghostfolio/common/types'; import { @@ -47,29 +48,32 @@ export class AccessController { where: { userId: this.request.user.id } }); - return accessesWithGranteeUser.map( - ({ alias, granteeUser, id, permissions, settings }) => { - if (granteeUser) { - return { - alias, - id, - permissions, - grantee: granteeUser?.id, - settings: settings as AccessSettings, - type: 'PRIVATE' - }; - } + return accessesWithGranteeUser.map((accessItem) => { + const { alias, granteeUser, id, permissions, settings } = accessItem; + const scopes = getScopesOfAccess(accessItem); + if (granteeUser) { return { alias, id, permissions, - grantee: 'Public', + scopes, + grantee: granteeUser?.id, settings: settings as AccessSettings, - type: 'PUBLIC' + type: 'PRIVATE' }; } - ); + + return { + alias, + id, + permissions, + scopes, + grantee: 'Public', + settings: settings as AccessSettings, + type: 'PUBLIC' + }; + }); } @HasPermission(permissions.createAccess) @@ -95,6 +99,10 @@ export class AccessController { ? { connect: { id: data.granteeUserId } } : undefined, permissions: data.permissions, + scopes: getScopesOfAccess({ + granteeUserId: data.granteeUserId, + permissions: data.permissions + }), settings: this.accessService.buildSettings(data.filters), user: { connect: { id: this.request.user.id } } }); @@ -164,6 +172,10 @@ export class AccessController { ? { connect: { id: data.granteeUserId } } : { disconnect: true }, permissions: data.permissions, + scopes: getScopesOfAccess({ + granteeUserId: data.granteeUserId, + permissions: data.permissions ?? originalAccess.permissions + }), settings: this.accessService.buildSettings(data.filters) }, where: { id } diff --git a/apps/api/src/app/account/account.controller.ts b/apps/api/src/app/account/account.controller.ts index ffb8ec6ab..8b6692503 100644 --- a/apps/api/src/app/account/account.controller.ts +++ b/apps/api/src/app/account/account.controller.ts @@ -2,8 +2,8 @@ import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/accou import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; +import { RequiresScope } from '@ghostfolio/api/decorators/requires-scope.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; -import { ImpersonationGuard } from '@ghostfolio/api/guards/impersonation.guard'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; @@ -18,6 +18,7 @@ import { AccountsResponse } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; +import { scopes } from '@ghostfolio/common/scopes'; import type { ImpersonationContext, RequestWithUser @@ -84,7 +85,7 @@ export class AccountController { } @Get() - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @RequiresScope(scopes.accountRead) @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor) public async getAllAccounts( @@ -107,7 +108,7 @@ export class AccountController { } @Get(':id') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @RequiresScope(scopes.accountRead) @UseInterceptors(RedactValuesInResponseInterceptor) public async getAccountById( @Impersonation() { userId }: ImpersonationContext, @@ -124,7 +125,7 @@ export class AccountController { } @Get(':id/balances') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @RequiresScope(scopes.accountRead) @UseInterceptors(RedactValuesInResponseInterceptor) public async getAccountBalancesById( @Impersonation() { userId, userSettings }: ImpersonationContext, diff --git a/apps/api/src/app/activities/activities.controller.ts b/apps/api/src/app/activities/activities.controller.ts index aa44857ad..21adf7c19 100644 --- a/apps/api/src/app/activities/activities.controller.ts +++ b/apps/api/src/app/activities/activities.controller.ts @@ -1,7 +1,7 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; +import { RequiresScope } from '@ghostfolio/api/decorators/requires-scope.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; -import { ImpersonationGuard } from '@ghostfolio/api/guards/impersonation.guard'; import { isActivityInFuture } from '@ghostfolio/api/helper/activity.helper'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; @@ -17,6 +17,7 @@ import { ActivityResponse } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; +import { scopes } from '@ghostfolio/common/scopes'; import type { ImpersonationContext, RequestWithUser @@ -120,7 +121,7 @@ export class ActivitiesController { } @Get() - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @RequiresScope(scopes.activityRead) @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) @@ -177,7 +178,7 @@ export class ActivitiesController { } @Get(':id') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @RequiresScope(scopes.activityRead) @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getActivityById( diff --git a/apps/api/src/app/activities/activities.module.ts b/apps/api/src/app/activities/activities.module.ts index 34091ba5e..1508917f0 100644 --- a/apps/api/src/app/activities/activities.module.ts +++ b/apps/api/src/app/activities/activities.module.ts @@ -6,6 +6,7 @@ import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redac import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module'; +import { AssetProfileSplitModule } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.module'; import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; @@ -26,6 +27,7 @@ import { ActivitiesService } from './activities.service'; exports: [ActivitiesService], imports: [ ApiModule, + AssetProfileSplitModule, BenchmarkModule, CacheModule, DataGatheringQueueModule, diff --git a/apps/api/src/app/activities/activities.service.spec.ts b/apps/api/src/app/activities/activities.service.spec.ts new file mode 100644 index 000000000..4e935efb3 --- /dev/null +++ b/apps/api/src/app/activities/activities.service.spec.ts @@ -0,0 +1,337 @@ +import { AccountService } from '@ghostfolio/api/app/account/account.service'; +import { + activityDummyData, + assetProfileDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service'; +import { + INVESTMENT_ACTIVITY_TYPES, + NON_INVESTMENT_ACTIVITY_TYPES +} from '@ghostfolio/common/config'; +import { parseDate } from '@ghostfolio/common/helper'; +import { Activity, Filter } from '@ghostfolio/common/interfaces'; + +import { AssetProfileSplit, DataSource } from '@prisma/client'; +import { Big } from 'big.js'; + +import { ActivitiesService } from './activities.service'; + +describe('ActivitiesService', () => { + let activitiesService: ActivitiesService; + let getSplitsByUserId: jest.Mock; + let accountService: { getCashDetails: jest.Mock }; + + beforeEach(() => { + getSplitsByUserId = jest.fn().mockResolvedValue([]); + accountService = { getCashDetails: jest.fn() }; + + activitiesService = new ActivitiesService( + null, + accountService as unknown as AccountService, + { getSplitsByUserId } as unknown as AssetProfileSplitService, + null, + null, + null, + null, + null, + null, + null, + null, + null + ); + }); + + describe('getActivitiesForPortfolioCalculator', () => { + it('leaves an activity unchanged when no splits exist', async () => { + const activity = createActivity({ symbol: 'AAPL' }); + + const result = await getAdjustedActivity(activity, []); + + expect(result).toEqual(activity); + expect(result).toBe(activity); + }); + + it.each([ + { denominator: 1, expectedPrice: 50, expectedQuantity: 20, numerator: 2 }, + { + denominator: 10, + expectedPrice: 1000, + expectedQuantity: 1, + numerator: 1 + } + ])( + 'applies a $numerator:$denominator split to quantity and price', + async ({ denominator, expectedPrice, expectedQuantity, numerator }) => { + const result = await getAdjustedActivity( + createActivity({ symbol: 'AAPL' }), + [createSplit('2021-01-01', numerator, denominator)] + ); + + expect(result.quantity).toBe(expectedQuantity); + expect(result.unitPrice).toBe(expectedPrice); + expect(result.unitPriceInAssetProfileCurrency).toBe(expectedPrice); + } + ); + + it('adjusts only activities before the split calendar date', async () => { + const split = createSplit('2021-01-01', 2, 1); + const activityBeforeSplit = createActivity({ + date: '2020-12-31T23:00:00.000Z', + symbol: 'AAPL' + }); + const activityOnSplitDate = createActivity({ + date: '2021-01-01T23:00:00.000Z', + symbol: 'AAPL' + }); + const activityAfterSplit = createActivity({ + date: '2021-01-02T00:00:00.000Z', + symbol: 'AAPL' + }); + + expect( + (await getAdjustedActivity(activityBeforeSplit, [split])).quantity + ).toBe(20); + expect( + (await getAdjustedActivity(activityOnSplitDate, [split])).quantity + ).toBe(10); + expect( + (await getAdjustedActivity(activityAfterSplit, [split])).quantity + ).toBe(10); + }); + + it('applies multiple splits cumulatively with exact ratio arithmetic', async () => { + const result = await getAdjustedActivity( + createActivity({ symbol: 'AAPL' }), + [createSplit('2021-01-01', 2, 1), createSplit('2022-01-01', 1, 3)] + ); + + expect(new Big(result.quantity).toFixed(15)).toBe( + new Big(20).div(3).toFixed(15) + ); + expect(result.unitPrice).toBe(150); + }); + + it('preserves fees and total activity value', async () => { + const activity = createActivity({ symbol: 'AAPL' }); + activity.feeInAssetProfileCurrency = 12; + activity.feeInBaseCurrency = 15; + + const result = await getAdjustedActivity(activity, [ + createSplit('2021-01-01', 2, 1) + ]); + + expect(result).toMatchObject({ + feeInAssetProfileCurrency: 12, + feeInBaseCurrency: 15, + value: 1000, + valueInBaseCurrency: 1000 + }); + expect(result.quantity * result.unitPrice).toBe(1000); + }); + + it('does not apply splits from another symbol or data source', async () => { + const activity = createActivity({ symbol: 'AAPL' }); + const split = createSplit('2021-01-01', 2, 1); + + jest.spyOn(activitiesService, 'getActivities').mockResolvedValue({ + activities: [activity], + count: 1 + }); + getSplitsByUserId.mockResolvedValue([ + { ...split, symbolProfileId: 'YAHOO-MSFT-profile' }, + { ...split, symbolProfileId: 'MANUAL-AAPL-profile' } + ]); + + const result = + await activitiesService.getActivitiesForPortfolioCalculator({ + userCurrency: 'USD', + userId: 'user-id' + }); + + expect(result.activities[0].quantity).toBe(10); + expect(result.activities[0].unitPrice).toBe(100); + }); + + it.each(INVESTMENT_ACTIVITY_TYPES)( + 'adjusts %s activities', + async (type) => { + const activity = createActivity({ symbol: 'AAPL' }); + activity.type = type as Activity['type']; + + const result = await getAdjustedActivity(activity, [ + createSplit('2021-01-01', 2, 1) + ]); + + expect(result.quantity).toBe(20); + expect(result.unitPrice).toBe(50); + expect(result.quantity * result.unitPrice).toBe(1000); + } + ); + + it.each(NON_INVESTMENT_ACTIVITY_TYPES)( + 'leaves %s activities unchanged', + async (type) => { + const activity = createActivity({ symbol: 'AAPL' }); + activity.type = type as Activity['type']; + + const result = await getAdjustedActivity(activity, [ + createSplit('2021-01-01', 2, 1) + ]); + + expect(result).toBe(activity); + expect(result.quantity).toBe(10); + expect(result.unitPrice).toBe(100); + } + ); + + it('loads and applies splits to standard activities while preserving filters', async () => { + const activity = createActivity({ symbol: 'AAPL' }); + const filters = [{ id: 'AAPL', type: 'SYMBOL' }] as Filter[]; + const split = createSplit(); + + jest.spyOn(activitiesService, 'getActivities').mockResolvedValue({ + activities: [activity], + count: 1 + }); + getSplitsByUserId.mockResolvedValue([split]); + + const result = + await activitiesService.getActivitiesForPortfolioCalculator({ + filters, + userCurrency: 'USD', + userId: 'user-id' + }); + + expect(activitiesService.getActivities).toHaveBeenCalledWith({ + filters, + userCurrency: 'USD', + userId: 'user-id', + withExcludedAccountsAndActivities: false + }); + expect(getSplitsByUserId).toHaveBeenCalledWith({ userId: 'user-id' }); + expect(result.activities[0]).toMatchObject({ + quantity: 20, + unitPrice: 50, + unitPriceInAssetProfileCurrency: 50 + }); + }); + + it('does not adjust synthetic cash activities', async () => { + const activity = createActivity({ symbol: 'AAPL' }); + const cashActivity = createActivity({ + assetSubClass: 'CASH', + currency: 'USD', + dataSource: DataSource.YAHOO, + quantity: 100, + symbol: 'USD', + unitPrice: 1 + }); + const split = createSplit(); + + jest.spyOn(activitiesService, 'getActivities').mockResolvedValue({ + activities: [activity], + count: 1 + }); + jest.spyOn(activitiesService, 'getCashActivities').mockResolvedValue({ + activities: [cashActivity], + count: 1 + }); + accountService.getCashDetails.mockResolvedValue({ accounts: [] }); + getSplitsByUserId.mockResolvedValue([split]); + + const result = + await activitiesService.getActivitiesForPortfolioCalculator({ + userCurrency: 'USD', + userId: 'user-id', + withCash: true + }); + + expect(getSplitsByUserId).toHaveBeenCalledWith({ userId: 'user-id' }); + expect(result.activities).toEqual([ + expect.objectContaining({ + assetProfile: expect.objectContaining({ symbol: 'AAPL' }), + quantity: 20 + }), + cashActivity + ]); + }); + + async function getAdjustedActivity( + activity: Activity, + splits: AssetProfileSplit[] + ) { + jest.spyOn(activitiesService, 'getActivities').mockResolvedValue({ + activities: [activity], + count: 1 + }); + getSplitsByUserId.mockResolvedValue( + splits.map((split) => { + return { ...split, symbolProfileId: activity.assetProfile.id }; + }) + ); + + const result = + await activitiesService.getActivitiesForPortfolioCalculator({ + userCurrency: 'USD', + userId: 'user-id' + }); + + return result.activities[0]; + } + }); +}); + +function createActivity({ + assetSubClass, + currency, + dataSource = DataSource.YAHOO, + date = '2020-01-01', + quantity = 10, + symbol, + unitPrice = 100 +}: { + assetSubClass?: string; + currency?: string; + dataSource?: DataSource; + date?: string; + quantity?: number; + symbol: string; + unitPrice?: number; +}): Activity { + return { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + assetSubClass, + currency, + dataSource, + id: `${dataSource}-${symbol}-profile`, + symbol + }, + date: parseDate(date), + quantity, + type: 'BUY', + unitPrice, + unitPriceInAssetProfileCurrency: unitPrice, + value: quantity * unitPrice, + valueInBaseCurrency: quantity * unitPrice + } as Activity; +} + +function createSplit( + dateString = '2021-01-01', + numerator = 2, + denominator = 1 +): AssetProfileSplit { + const date = parseDate(dateString); + + return { + createdAt: date, + date, + denominator, + id: `${dateString}-${numerator}-${denominator}`, + numerator, + symbolProfileId: 'YAHOO-AAPL-profile', + updatedAt: date + }; +} diff --git a/apps/api/src/app/activities/activities.service.ts b/apps/api/src/app/activities/activities.service.ts index 00ed2b407..3627de912 100644 --- a/apps/api/src/app/activities/activities.service.ts +++ b/apps/api/src/app/activities/activities.service.ts @@ -14,6 +14,8 @@ import { WHERE_ACTIVITY_NOT_DRAFT } from '@ghostfolio/api/helper/activity.helper'; import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; +import { adjustActivityBySplits } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.helper'; +import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; @@ -66,6 +68,7 @@ export class ActivitiesService { public constructor( private readonly accountBalanceService: AccountBalanceService, private readonly accountService: AccountService, + private readonly assetProfileSplitService: AssetProfileSplitService, private readonly benchmarkService: BenchmarkService, private readonly dataGatheringService: DataGatheringService, private readonly dataProviderService: DataProviderService, @@ -390,36 +393,32 @@ export class ActivitiesService { types?: ActivityType[]; userId: string; }): Promise { - const { activities } = await this.getActivities({ + const where = this.getWhereClause({ endDate, filters, startDate, types, userId, includeDrafts: true, - userCurrency: undefined, withExcludedAccountsAndActivities: true }); - const { count } = await this.prismaService.order.deleteMany({ - where: { - id: { - in: activities.map(({ id }) => { - return id; - }) - } - } + const activities = await this.prismaService.order.findMany({ + where, + distinct: ['symbolProfileId'], + select: { symbolProfileId: true } }); - const symbolProfiles = - await this.symbolProfileService.getSymbolProfilesByIds( + const { count } = await this.prismaService.order.deleteMany({ where }); + + const [benchmarkAssetProfiles, symbolProfiles] = await Promise.all([ + this.benchmarkService.getBenchmarkAssetProfiles(), + this.symbolProfileService.getSymbolProfilesByIds( activities.map(({ symbolProfileId }) => { return symbolProfileId; }) - ); - - const benchmarkAssetProfiles = - await this.benchmarkService.getBenchmarkAssetProfiles(); + ) + ]); for (const { activitiesCount, @@ -624,164 +623,19 @@ export class ActivitiesService { { date: 'asc' } ]; - const andConditions: Prisma.OrderWhereInput[] = []; - const where: Prisma.OrderWhereInput = { userId, AND: andConditions }; - - if (endDate) { - andConditions.push({ date: { lte: endDate } }); - } - - if (startDate) { - andConditions.push({ date: { gt: startDate } }); - } - - const { - ACCOUNT: filtersByAccount = [], - ASSET_CLASS: filtersByAssetClass = [], - DATA_SOURCE: [filterByDataSource] = [], - SEARCH_QUERY: [filterBySearchQuery] = [], - SYMBOL: [filterBySymbol] = [], - TAG: filtersByTag = [] - } = groupBy(filters, ({ type }) => { - return type; - }); - - if (filtersByAccount.length > 0) { - where.accountId = { - in: filtersByAccount.map(({ id }) => { - return id; - }) - }; - } - - const isFilteredByDraftTag = filtersByTag.some(({ id }) => { - return id === TAG_ID_DRAFT; - }); - - if (includeDrafts === false && !isFilteredByDraftTag) { - andConditions.push(WHERE_ACTIVITY_NOT_DRAFT); - } - - if (filtersByAssetClass.length > 0) { - where.SymbolProfile = { - OR: [ - { - AND: [ - { - OR: filtersByAssetClass.map(({ id }) => { - return { assetClass: AssetClass[id] }; - }) - }, - { - OR: [ - { assetProfileOverrides: { is: null } }, - { assetProfileOverrides: { assetClass: null } } - ] - } - ] - }, - { - assetProfileOverrides: { - OR: filtersByAssetClass.map(({ id }) => { - return { assetClass: AssetClass[id] }; - }) - } - } - ] - }; - } - - if (filterByDataSource && filterBySymbol) { - if (where.SymbolProfile) { - where.SymbolProfile = { - AND: [ - where.SymbolProfile, - { - AND: [ - { dataSource: filterByDataSource.id as DataSource }, - { symbol: filterBySymbol.id } - ] - } - ] - }; - } else { - where.SymbolProfile = { - AND: [ - { dataSource: filterByDataSource.id as DataSource }, - { symbol: filterBySymbol.id } - ] - }; - } - } - - if (filterBySearchQuery) { - const searchQueryWhereInput: Prisma.SymbolProfileWhereInput[] = [ - { id: { mode: 'insensitive', startsWith: filterBySearchQuery.id } }, - { isin: { mode: 'insensitive', startsWith: filterBySearchQuery.id } }, - { name: { mode: 'insensitive', startsWith: filterBySearchQuery.id } }, - { symbol: { mode: 'insensitive', startsWith: filterBySearchQuery.id } } - ]; - - if (where.SymbolProfile) { - where.SymbolProfile = { - AND: [ - where.SymbolProfile, - { - OR: searchQueryWhereInput - } - ] - }; - } else { - where.SymbolProfile = { - OR: searchQueryWhereInput - }; - } - } - - if (filtersByTag.length > 0) { - andConditions.push({ - OR: [ - { - tags: { - some: { - OR: filtersByTag.map(({ id }) => { - return { id }; - }) - } - } - }, - { - account: { - tags: { - some: { - OR: filtersByTag.map(({ id }) => { - return { tagId: id }; - }) - } - } - } - } - ] - }); - } - if (sortColumn) { orderBy = [{ [sortColumn]: sortDirection }]; } - if (types?.length > 0) { - where.type = { in: types }; - } - - if (withExcludedAccountsAndActivities === false) { - where.OR = [{ account: null }, { account: WHERE_ACCOUNT_NOT_EXCLUDED }]; - - where.tags = { - none: { - id: TAG_ID_EXCLUDE_FROM_ANALYSIS - } - }; - } + const where = this.getWhereClause({ + endDate, + filters, + includeDrafts, + startDate, + types, + userId, + withExcludedAccountsAndActivities + }); const [orders, count] = await Promise.all([ this.orders({ @@ -915,12 +769,26 @@ export class ActivitiesService { /** Whether to include cash activities in the result. */ withCash?: boolean; }) { - const activities = await this.getActivities({ - filters, - userCurrency, - userId, - withExcludedAccountsAndActivities: false // TODO - }); + const [activities, splits] = await Promise.all([ + this.getActivities({ + filters, + userCurrency, + userId, + withExcludedAccountsAndActivities: false // TODO + }), + this.assetProfileSplitService.getSplitsByUserId({ userId }) + ]); + + if (splits.length > 0) { + const splitsBySymbolProfileId = groupBy(splits, 'symbolProfileId'); + + activities.activities = activities.activities.map((activity) => { + return adjustActivityBySplits( + activity, + splitsBySymbolProfileId[activity.assetProfile.id] ?? [] + ); + }); + } if (withCash && !this.areCashActivitiesExcludedByFilters(filters)) { const cashDetails = await this.accountService.getCashDetails({ @@ -963,6 +831,25 @@ export class ActivitiesService { }; } + /** + * Returns the id of every user who has an activity for the given asset + * profile, including draft activities and activities of excluded accounts + */ + public async getUserIdsBySymbolProfileId( + symbolProfileId: string + ): Promise { + const activitiesByUser = await this.prismaService.order.groupBy({ + by: ['userId'], + where: { + symbolProfileId + } + }); + + return activitiesByUser.map(({ userId }) => { + return userId; + }); + } + public async order( orderWhereUniqueInput: Prisma.OrderWhereUniqueInput ): Promise { @@ -1077,6 +964,181 @@ export class ActivitiesService { return activity; } + private getWhereClause({ + endDate, + filters, + includeDrafts, + startDate, + types, + userId, + withExcludedAccountsAndActivities + }: { + endDate?: Date; + filters?: Filter[]; + includeDrafts: boolean; + startDate?: Date; + types?: ActivityType[]; + userId: string; + withExcludedAccountsAndActivities: boolean; + }): Prisma.OrderWhereInput { + const andConditions: Prisma.OrderWhereInput[] = []; + const where: Prisma.OrderWhereInput = { userId, AND: andConditions }; + + if (endDate) { + andConditions.push({ date: { lte: endDate } }); + } + + if (startDate) { + andConditions.push({ date: { gt: startDate } }); + } + + const { + ACCOUNT: filtersByAccount = [], + ASSET_CLASS: filtersByAssetClass = [], + DATA_SOURCE: [filterByDataSource] = [], + SEARCH_QUERY: [filterBySearchQuery] = [], + SYMBOL: [filterBySymbol] = [], + TAG: filtersByTag = [] + } = groupBy(filters, ({ type }) => { + return type; + }); + + if (filtersByAccount.length > 0) { + where.accountId = { + in: filtersByAccount.map(({ id }) => { + return id; + }) + }; + } + + const isFilteredByDraftTag = filtersByTag.some(({ id }) => { + return id === TAG_ID_DRAFT; + }); + + if (includeDrafts === false && !isFilteredByDraftTag) { + andConditions.push(WHERE_ACTIVITY_NOT_DRAFT); + } + + if (filtersByAssetClass.length > 0) { + where.SymbolProfile = { + OR: [ + { + AND: [ + { + OR: filtersByAssetClass.map(({ id }) => { + return { assetClass: AssetClass[id] }; + }) + }, + { + OR: [ + { assetProfileOverrides: { is: null } }, + { assetProfileOverrides: { assetClass: null } } + ] + } + ] + }, + { + assetProfileOverrides: { + OR: filtersByAssetClass.map(({ id }) => { + return { assetClass: AssetClass[id] }; + }) + } + } + ] + }; + } + + if (filterByDataSource && filterBySymbol) { + if (where.SymbolProfile) { + where.SymbolProfile = { + AND: [ + where.SymbolProfile, + { + AND: [ + { dataSource: filterByDataSource.id as DataSource }, + { symbol: filterBySymbol.id } + ] + } + ] + }; + } else { + where.SymbolProfile = { + AND: [ + { dataSource: filterByDataSource.id as DataSource }, + { symbol: filterBySymbol.id } + ] + }; + } + } + + if (filterBySearchQuery) { + const searchQueryWhereInput: Prisma.SymbolProfileWhereInput[] = [ + { id: { mode: 'insensitive', startsWith: filterBySearchQuery.id } }, + { isin: { mode: 'insensitive', startsWith: filterBySearchQuery.id } }, + { name: { mode: 'insensitive', startsWith: filterBySearchQuery.id } }, + { symbol: { mode: 'insensitive', startsWith: filterBySearchQuery.id } } + ]; + + if (where.SymbolProfile) { + where.SymbolProfile = { + AND: [ + where.SymbolProfile, + { + OR: searchQueryWhereInput + } + ] + }; + } else { + where.SymbolProfile = { + OR: searchQueryWhereInput + }; + } + } + + if (filtersByTag.length > 0) { + andConditions.push({ + OR: [ + { + tags: { + some: { + OR: filtersByTag.map(({ id }) => { + return { id }; + }) + } + } + }, + { + account: { + tags: { + some: { + OR: filtersByTag.map(({ id }) => { + return { tagId: id }; + }) + } + } + } + } + ] + }); + } + + if (types?.length > 0) { + where.type = { in: types }; + } + + if (withExcludedAccountsAndActivities === false) { + where.OR = [{ account: null }, { account: WHERE_ACCOUNT_NOT_EXCLUDED }]; + + where.tags = { + none: { + id: TAG_ID_EXCLUDE_FROM_ANALYSIS + } + }; + } + + return where; + } + private async orders(params: { include?: Prisma.OrderInclude; skip?: number; diff --git a/apps/api/src/app/auth/auth.controller.ts b/apps/api/src/app/auth/auth.controller.ts index ccf79ba99..da45f0071 100644 --- a/apps/api/src/app/auth/auth.controller.ts +++ b/apps/api/src/app/auth/auth.controller.ts @@ -2,6 +2,7 @@ import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; import { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { OAuthCallbackGuard } from '@ghostfolio/api/guards/oauth-callback.guard'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; import { @@ -62,13 +63,13 @@ export class AuthController { } @Get('google/callback') - @UseGuards(AuthGuard('google')) + @UseGuards(OAuthCallbackGuard('google')) @Version(VERSION_NEUTRAL) public googleLoginCallback( @Req() request: Request, @Res() response: Response ) { - const jwt: string = (request.user as any).jwt; + const jwt: string = (request.user as any)?.jwt; if (jwt) { response.redirect( @@ -98,10 +99,10 @@ export class AuthController { } @Get('oidc/callback') - @UseGuards(AuthGuard('oidc')) + @UseGuards(OAuthCallbackGuard('oidc')) @Version(VERSION_NEUTRAL) public oidcLoginCallback(@Req() request: Request, @Res() response: Response) { - const jwt: string = (request.user as any).jwt; + const jwt: string = (request.user as any)?.jwt; if (jwt) { response.redirect( diff --git a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts index 356544acb..e1452c548 100644 --- a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts +++ b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts @@ -169,7 +169,12 @@ export class AssetProfilesController { permissions.deleteAssetProfileSplitOfOwnAssetProfile }); - return this.assetProfilesService.deleteSplit({ id, symbolProfileId }); + return this.assetProfilesService.deleteSplit({ + dataSource, + id, + symbol, + symbolProfileId + }); } @HasPermission(permissions.accessAdminControl) diff --git a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.spec.ts b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.spec.ts new file mode 100644 index 000000000..909c96e8b --- /dev/null +++ b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.spec.ts @@ -0,0 +1,179 @@ +import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; +import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; +import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service'; +import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; + +import { NotFoundException } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { AssetProfileSplit, DataSource } from '@prisma/client'; + +import { AssetProfilesService } from './asset-profiles.service'; + +describe('AssetProfilesService', () => { + let assetProfilesService: AssetProfilesService; + let deleteById: jest.Mock; + let emit: jest.Mock; + let finished: jest.Mock; + let gatherSymbol: jest.Mock; + let getUserIdsBySymbolProfileId: jest.Mock; + let upsert: jest.Mock; + + beforeEach(() => { + deleteById = jest.fn(); + emit = jest.fn(); + finished = jest.fn().mockResolvedValue(undefined); + gatherSymbol = jest.fn().mockResolvedValue([{ finished }]); + getUserIdsBySymbolProfileId = jest.fn().mockResolvedValue([]); + upsert = jest.fn(); + + assetProfilesService = new AssetProfilesService( + { getUserIdsBySymbolProfileId } as unknown as ActivitiesService, + { + deleteById, + upsert + } as unknown as AssetProfileSplitService, + null, + { gatherSymbol } as unknown as DataGatheringService, + null, + { emit } as unknown as EventEmitter2, + null, + null, + null, + null + ); + }); + + describe('createSplit', () => { + it('upserts the split and refreshes the asset profile data', async () => { + const split = {} as AssetProfileSplit; + const data = { + dataSource: DataSource.YAHOO, + date: new Date('2024-06-15T18:30:00.000Z'), + denominator: 1, + numerator: 2, + symbol: 'AAPL', + symbolProfileId: 'profile-id' + }; + upsert.mockResolvedValue(split); + + const result = await assetProfilesService.createSplit(data); + + expect(upsert).toHaveBeenCalledWith({ + date: data.date, + denominator: data.denominator, + numerator: data.numerator, + symbolProfileId: data.symbolProfileId + }); + expect(gatherSymbol).toHaveBeenCalledWith({ + dataSource: data.dataSource, + symbol: data.symbol + }); + expect(result).toBe(split); + }); + + it('invalidates portfolio snapshots for users holding the asset', async () => { + upsert.mockResolvedValue({} as AssetProfileSplit); + getUserIdsBySymbolProfileId.mockResolvedValue(['user-1', 'user-2']); + + await assetProfilesService.createSplit({ + dataSource: DataSource.YAHOO, + date: new Date('2024-06-15T18:30:00.000Z'), + denominator: 1, + numerator: 2, + symbol: 'AAPL', + symbolProfileId: 'profile-id' + }); + await flushPendingPromises(); + + expect(getUserIdsBySymbolProfileId).toHaveBeenCalledWith('profile-id'); + expect(emit.mock.calls.map(([, event]) => event.getUserId())).toEqual([ + 'user-1', + 'user-2' + ]); + expect(emit.mock.calls[0][0]).toBe(PortfolioChangedEvent.getName()); + }); + + it('emits the events only once the market data has been gathered', async () => { + let completeJob: () => void; + + finished.mockReturnValue( + new Promise((resolve) => { + completeJob = resolve; + }) + ); + upsert.mockResolvedValue({} as AssetProfileSplit); + getUserIdsBySymbolProfileId.mockResolvedValue(['user-1']); + + await assetProfilesService.createSplit({ + dataSource: DataSource.YAHOO, + date: new Date('2024-06-15T18:30:00.000Z'), + denominator: 1, + numerator: 2, + symbol: 'AAPL', + symbolProfileId: 'profile-id' + }); + await flushPendingPromises(); + + expect(emit).not.toHaveBeenCalled(); + + completeJob(); + await flushPendingPromises(); + + expect(emit.mock.calls.map(([, event]) => event.getUserId())).toEqual([ + 'user-1' + ]); + }); + }); + + describe('deleteSplit', () => { + it('throws NotFoundException when the scoped split does not exist', async () => { + deleteById.mockResolvedValue(false); + + await expect( + assetProfilesService.deleteSplit({ + dataSource: DataSource.YAHOO, + id: 'split-id', + symbol: 'AAPL', + symbolProfileId: 'profile-id' + }) + ).rejects.toBeInstanceOf(NotFoundException); + await flushPendingPromises(); + + expect(gatherSymbol).not.toHaveBeenCalled(); + expect(emit).not.toHaveBeenCalled(); + }); + + it('deletes an existing split using its profile scope', async () => { + deleteById.mockResolvedValue(true); + getUserIdsBySymbolProfileId.mockResolvedValue(['user-1']); + + await expect( + assetProfilesService.deleteSplit({ + dataSource: DataSource.YAHOO, + id: 'split-id', + symbol: 'AAPL', + symbolProfileId: 'profile-id' + }) + ).resolves.toBeUndefined(); + await flushPendingPromises(); + + expect(deleteById).toHaveBeenCalledWith({ + id: 'split-id', + symbolProfileId: 'profile-id' + }); + expect(emit.mock.calls.map(([, event]) => event.getUserId())).toEqual([ + 'user-1' + ]); + expect(gatherSymbol).toHaveBeenCalledWith({ + dataSource: DataSource.YAHOO, + symbol: 'AAPL' + }); + }); + }); +}); + +function flushPendingPromises() { + return new Promise((resolve) => { + setImmediate(resolve); + }); +} diff --git a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts index 0d50e2223..e34feb649 100644 --- a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts +++ b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts @@ -1,4 +1,5 @@ import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; +import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; @@ -25,6 +26,7 @@ import { import { MarketDataPreset } from '@ghostfolio/common/types'; import { Injectable, NotFoundException } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client'; import { groupBy } from 'lodash'; @@ -36,6 +38,7 @@ export class AssetProfilesService { private readonly benchmarkService: BenchmarkService, private readonly dataGatheringService: DataGatheringService, private readonly dataProviderService: DataProviderService, + private readonly eventEmitter: EventEmitter2, private readonly exchangeRateDataService: ExchangeRateDataService, private readonly marketDataService: MarketDataService, private readonly prismaService: PrismaService, @@ -62,18 +65,24 @@ export class AssetProfilesService { symbolProfileId }); - await this.dataGatheringService.gatherSymbol({ dataSource, symbol }); + await this.gatherSymbolAndEmitPortfolioChangedEvents({ + dataSource, + symbol, + symbolProfileId + }); return assetProfileSplit; } public async deleteSplit({ + dataSource, id, + symbol, symbolProfileId }: { id: string; symbolProfileId: string; - }) { + } & AssetProfileIdentifier) { const isDeleted = await this.assetProfileSplitService.deleteById({ id, symbolProfileId @@ -82,6 +91,12 @@ export class AssetProfilesService { if (!isDeleted) { throw new NotFoundException(); } + + await this.gatherSymbolAndEmitPortfolioChangedEvents({ + dataSource, + symbol, + symbolProfileId + }); } public async getAssetProfile({ @@ -425,6 +440,43 @@ export class AssetProfilesService { return assetProfile; } + private async emitPortfolioChangedEvents(symbolProfileId: string) { + const userIds = + await this.activitiesService.getUserIdsBySymbolProfileId(symbolProfileId); + + for (const userId of userIds) { + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ userId }) + ); + } + } + + /** + * Gathers the market data of the given asset profile and invalidates the + * portfolio snapshots of the affected users as soon as it is available. + * Emitting the events earlier would recompute the snapshots from + * split-adjusted quantities and not yet split-adjusted market prices. + */ + private async gatherSymbolAndEmitPortfolioChangedEvents({ + dataSource, + symbol, + symbolProfileId + }: { symbolProfileId: string } & AssetProfileIdentifier) { + const jobs = await this.dataGatheringService.gatherSymbol({ + dataSource, + symbol + }); + + void Promise.allSettled( + jobs.map((job) => { + return job.finished(); + }) + ).then(() => { + return this.emitPortfolioChangedEvents(symbolProfileId); + }); + } + private getAssetProfileDataUpdate({ countries, holdings, diff --git a/apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts b/apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts index 79949a715..0930eb5b1 100644 --- a/apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts +++ b/apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts @@ -1,8 +1,8 @@ import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; +import { RequiresScope } from '@ghostfolio/api/decorators/requires-scope.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; -import { ImpersonationGuard } from '@ghostfolio/api/guards/impersonation.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'; @@ -14,6 +14,7 @@ import type { BenchmarkResponse } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; +import { scopes } from '@ghostfolio/common/scopes'; import type { ImpersonationContext } from '@ghostfolio/common/types'; import { @@ -111,7 +112,7 @@ export class BenchmarksController { } @Get(':dataSource/:symbol/:startDateString') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @RequiresScope(scopes.portfolioRead) @UseInterceptors(TransformDataSourceInRequestInterceptor) public async getBenchmarkMarketDataForUser( @Impersonation() { userId, userSettings }: ImpersonationContext, diff --git a/apps/api/src/app/endpoints/watchlist/watchlist.controller.ts b/apps/api/src/app/endpoints/watchlist/watchlist.controller.ts index 947612cb1..11f3ff84e 100644 --- a/apps/api/src/app/endpoints/watchlist/watchlist.controller.ts +++ b/apps/api/src/app/endpoints/watchlist/watchlist.controller.ts @@ -1,12 +1,13 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; +import { RequiresScope } from '@ghostfolio/api/decorators/requires-scope.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; -import { ImpersonationGuard } from '@ghostfolio/api/guards/impersonation.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 { CreateWatchlistItemDto } from '@ghostfolio/common/dtos'; import { WatchlistResponse } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; +import { scopes } from '@ghostfolio/common/scopes'; import { ImpersonationContext, RequestWithUser @@ -82,7 +83,7 @@ export class WatchlistController { @Get() @HasPermission(permissions.readWatchlist) - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @RequiresScope(scopes.watchlistRead) @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getWatchlistItems( @Impersonation() { userId }: ImpersonationContext diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts index 3b09bfd26..e50b132d2 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts @@ -11,6 +11,7 @@ import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.s import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; @@ -124,6 +125,9 @@ describe('PortfolioCalculator', () => { activitiesService = new ActivitiesService( accountBalanceService, accountService, + { + getSplitsByUserId: jest.fn().mockResolvedValue([]) + } as unknown as AssetProfileSplitService, null, null, dataProviderService, diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-stock-split.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-stock-split.spec.ts new file mode 100644 index 000000000..c71a3bda1 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-stock-split.spec.ts @@ -0,0 +1,276 @@ +import { + activityDummyData, + assetProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { adjustActivityBySplits } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.helper'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { Activity } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { AssetProfileSplit, DataSource } from '@prisma/client'; +import { Big } from 'big.js'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('RoaiPortfolioCalculator stock splits', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + + configurationService = new ConfigurationService(); + currentRateService = new CurrentRateService(null, null, null, null); + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + portfolioSnapshotService = new PortfolioSnapshotService(null, null); + redisCacheService = new RedisCacheService(null, null); + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + it('doubles the position and halves the average price for a 2:1 split', () => { + const activity = adjustActivityBySplits( + createActivity({ unitPrice: 100 }), + [createSplit({ denominator: 1, numerator: 2 })] + ); + + const position = getLastPosition(portfolioCalculatorFactory, [activity]); + + expect(position).toMatchObject({ + averagePrice: new Big(50), + investment: new Big(1000), + quantity: new Big(20) + }); + }); + + it('applies the inverse quantity and price changes for a reverse split', () => { + const activity = adjustActivityBySplits( + createActivity({ unitPrice: 100 }), + [createSplit({ denominator: 10, numerator: 1 })] + ); + + const position = getLastPosition(portfolioCalculatorFactory, [activity]); + + expect(position).toMatchObject({ + averagePrice: new Big(1000), + investment: new Big(1000), + quantity: new Big(1) + }); + }); + + it('uses adjusted quantities when selling after a split', () => { + const buy = adjustActivityBySplits( + createActivity({ date: '2020-01-01', unitPrice: 100 }), + [createSplit({ denominator: 1, numerator: 2 })] + ); + const sell = createActivity({ + date: '2021-01-01', + quantity: 5, + type: 'SELL', + unitPrice: 60 + }); + + const position = getLastPosition(portfolioCalculatorFactory, [buy, sell]); + + expect(position).toMatchObject({ + averagePrice: new Big(50), + investment: new Big(750), + quantity: new Big(15) + }); + }); + + it('applies multiple splits while preserving fractional precision', () => { + const activity = adjustActivityBySplits( + createActivity({ unitPrice: 100 }), + [ + createSplit({ denominator: 1, numerator: 2 }), + createSplit({ denominator: 3, numerator: 1, date: '2022-01-01' }) + ] + ); + + const position = getLastPosition(portfolioCalculatorFactory, [activity]); + + expect(position.averagePrice).toEqual(new Big(150)); + expect(position.quantity.toFixed(15)).toBe(new Big(20).div(3).toFixed(15)); + expect(position.investment.toNumber()).toBeCloseTo(1000, 12); + }); + + it('resets quantity and investment when the adjusted position is closed', () => { + const buy = adjustActivityBySplits( + createActivity({ date: '2020-01-01', unitPrice: 100 }), + [createSplit({ denominator: 1, numerator: 2 })] + ); + const sell = createActivity({ + date: '2021-01-01', + quantity: 20, + type: 'SELL', + unitPrice: 60 + }); + + const position = getLastPosition(portfolioCalculatorFactory, [buy, sell]); + + expect(position.quantity).toEqual(new Big(0)); + expect(position.investment).toEqual(new Big(0)); + }); + + it('preserves existing behavior when no splits exist', () => { + const position = getLastPosition(portfolioCalculatorFactory, [ + createActivity({ unitPrice: 100 }) + ]); + + expect(position).toMatchObject({ + averagePrice: new Big(100), + investment: new Big(1000), + quantity: new Big(10) + }); + }); + + it('uses provider market data without adjusting it a second time', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime()); + + const activity = adjustActivityBySplits( + createActivity({ date: '2023-07-09', unitPrice: 674.44 }), + [ + createSplit({ + date: '2023-07-10', + denominator: 1, + numerator: 2 + }) + ] + ); + const calculator = portfolioCalculatorFactory.createCalculator({ + activities: [activity], + calculationType: PerformanceCalculationType.ROAI, + currency: 'USD', + userId: userDummyData.id + }); + + const snapshot = await calculator.computeSnapshot(); + const [position] = snapshot.positions; + + expect(position).toMatchObject({ + investment: new Big(6744.4), + marketPrice: 331.83, + quantity: new Big(20), + valueInBaseCurrency: new Big(6636.6) + }); + }); +}); + +function getLastPosition( + portfolioCalculatorFactory: PortfolioCalculatorFactory, + activities: Activity[] +) { + const calculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'USD', + userId: userDummyData.id + }); + + return calculator.getTransactionPoints().at(-1).items[0]; +} + +function createActivity({ + date = '2020-01-01', + quantity = 10, + type = 'BUY', + unitPrice = 100 +}: { + date?: string; + quantity?: number; + type?: Activity['type']; + unitPrice?: number; +}): Activity { + return { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'USD', + dataSource: DataSource.YAHOO, + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, + date: parseDate(date), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity, + type, + unitPrice, + unitPriceInAssetProfileCurrency: unitPrice, + value: quantity * unitPrice, + valueInBaseCurrency: quantity * unitPrice + } as Activity; +} + +function createSplit({ + date = '2021-01-01', + denominator, + numerator +}: { + date?: string; + denominator: number; + numerator: number; +}): AssetProfileSplit { + const splitDate = parseDate(date); + + return { + denominator, + numerator, + createdAt: splitDate, + date: splitDate, + id: `${date}-${numerator}-${denominator}`, + symbolProfileId: 'msft-profile', + updatedAt: splitDate + }; +} diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 2edffc8d3..b2dd8b69a 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -1,8 +1,8 @@ import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; +import { RequiresScope } from '@ghostfolio/api/decorators/requires-scope.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; -import { ImpersonationGuard } from '@ghostfolio/api/guards/impersonation.guard'; import { hasNotDefinedValuesInObject, nullifyValuesInObject @@ -25,11 +25,8 @@ import { PortfolioPerformanceResponse, PortfolioReportResponse } from '@ghostfolio/common/interfaces'; -import { - hasReadRestrictedAccessPermission, - isRestrictedView, - permissions -} from '@ghostfolio/common/permissions'; +import { isRestrictedView, permissions } from '@ghostfolio/common/permissions'; +import { hasScope, scopes } from '@ghostfolio/common/scopes'; import type { ImpersonationContext, RequestWithUser @@ -73,12 +70,13 @@ export class PortfolioController { ) {} @Get('details') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @RequiresScope(scopes.portfolioRead) @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getDetails( - @Impersonation() { accessId, userId }: ImpersonationContext, + @Impersonation() + { scopes: impersonationScopes, userId }: ImpersonationContext, @Query() { accounts: filterByAccounts, @@ -130,10 +128,7 @@ export class PortfolioController { let portfolioSummary = summary; if ( - hasReadRestrictedAccessPermission({ - accesses: this.request.user?.accessesGet, - impersonationId: accessId - }) || + !hasScope(impersonationScopes, scopes.portfolioReadValues) || isRestrictedView(this.request.user) ) { const totalInvestment = Object.values(holdings) @@ -174,10 +169,7 @@ export class PortfolioController { if ( hasDetails === false || - hasReadRestrictedAccessPermission({ - accesses: this.request.user?.accessesGet, - impersonationId: accessId - }) || + !hasScope(impersonationScopes, scopes.portfolioReadValues) || isRestrictedView(this.request.user) ) { Object.values(markets ?? {}).forEach((market) => { @@ -319,10 +311,11 @@ export class PortfolioController { } @Get('dividends') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @RequiresScope(scopes.portfolioRead) @UseInterceptors(TransformDataSourceInRequestInterceptor) public async getDividends( - @Impersonation() { accessId, userId, userSettings }: ImpersonationContext, + @Impersonation() + { scopes: impersonationScopes, userId, userSettings }: ImpersonationContext, @Query() { accounts, @@ -359,15 +352,11 @@ export class PortfolioController { let dividends = this.portfolioService.getDividends({ activities, - groupBy, - userCurrency + groupBy }); if ( - hasReadRestrictedAccessPermission({ - accesses: this.request.user?.accessesGet, - impersonationId: accessId - }) || + !hasScope(impersonationScopes, scopes.portfolioReadValues) || isRestrictedView(this.request.user) ) { const maxDividend = dividends.reduce( @@ -397,7 +386,7 @@ export class PortfolioController { @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @RequiresScope(scopes.portfolioRead) public async getHolding( @Impersonation() { userId }: ImpersonationContext, @Param('dataSource') dataSource: DataSource, @@ -420,7 +409,7 @@ export class PortfolioController { } @Get('holdings') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @RequiresScope(scopes.portfolioRead) @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) @@ -458,10 +447,11 @@ export class PortfolioController { } @Get('investments') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @RequiresScope(scopes.portfolioRead) @UseInterceptors(TransformDataSourceInRequestInterceptor) public async getInvestments( - @Impersonation() { accessId, userId }: ImpersonationContext, + @Impersonation() + { scopes: impersonationScopes, userId }: ImpersonationContext, @Query() { accounts, @@ -490,10 +480,7 @@ export class PortfolioController { }); if ( - hasReadRestrictedAccessPermission({ - accesses: this.request.user?.accessesGet, - impersonationId: accessId - }) || + !hasScope(impersonationScopes, scopes.portfolioReadValues) || isRestrictedView(this.request.user) ) { const maxInvestment = investments.reduce( @@ -532,13 +519,14 @@ export class PortfolioController { } @Get('performance') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @RequiresScope(scopes.portfolioRead) @UseInterceptors(PerformanceLoggingInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) @Version('2') public async getPerformanceV2( - @Impersonation() { accessId, userId }: ImpersonationContext, + @Impersonation() + { scopes: impersonationScopes, userId }: ImpersonationContext, @Query() { accounts, @@ -566,10 +554,7 @@ export class PortfolioController { }); if ( - hasReadRestrictedAccessPermission({ - accesses: this.request.user?.accessesGet, - impersonationId: accessId - }) || + !hasScope(impersonationScopes, scopes.portfolioReadValues) || isRestrictedView(this.request.user) || this.request.user.settings.settings.viewMode === 'ZEN' ) { @@ -645,17 +630,15 @@ export class PortfolioController { } @Get('report') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @RequiresScope(scopes.portfolioRead) public async getReport( - @Impersonation() { accessId, userId }: ImpersonationContext + @Impersonation() + { scopes: impersonationScopes, userId }: ImpersonationContext ): Promise { const report = await this.portfolioService.getReport({ userId }); if ( - hasReadRestrictedAccessPermission({ - accesses: this.request.user?.accessesGet, - impersonationId: accessId - }) || + !hasScope(impersonationScopes, scopes.portfolioReadValues) || isRestrictedView(this.request.user) || (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.request.user.subscription?.type === SubscriptionType.Basic) @@ -676,7 +659,7 @@ export class PortfolioController { @HasPermission(permissions.updateActivity) @Put('holding/:dataSource/:symbol/tags') @UseInterceptors(TransformDataSourceInRequestInterceptor) - @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async updateHoldingTags( @Body() data: UpdateHoldingTagsDto, @Param('dataSource') dataSource: DataSource, diff --git a/apps/api/src/app/portfolio/portfolio.service.spec.ts b/apps/api/src/app/portfolio/portfolio.service.spec.ts index 9b6bacd44..970b13734 100644 --- a/apps/api/src/app/portfolio/portfolio.service.spec.ts +++ b/apps/api/src/app/portfolio/portfolio.service.spec.ts @@ -66,6 +66,7 @@ describe('PortfolioService', () => { accountService, null, null, + null, dataProviderService, null, exchangeRateDataService, @@ -245,7 +246,6 @@ describe('PortfolioService', () => { .mockResolvedValue([]); jest.spyOn(userService, 'user').mockResolvedValue({ - accessesGet: [], accounts: [], activityCount: 0, dataProviderGhostfolioDailyRequests: 0, diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 2cbafc076..30eb53c59 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -358,21 +358,15 @@ export class PortfolioService { public getDividends({ activities, - groupBy, - userCurrency + groupBy }: { activities: Activity[]; groupBy?: GroupBy; - userCurrency: string; }): InvestmentItem[] { - let dividends = activities.map(({ currency, date, value }) => { + let dividends = activities.map(({ date, valueInBaseCurrency }) => { return { date: format(date, DATE_FORMAT), - investment: this.exchangeRateDataService.toCurrency( - value, - currency, - userCurrency - ) + investment: valueInBaseCurrency }; }); diff --git a/apps/api/src/app/user/user.controller.ts b/apps/api/src/app/user/user.controller.ts index 50471c8bc..cf9043910 100644 --- a/apps/api/src/app/user/user.controller.ts +++ b/apps/api/src/app/user/user.controller.ts @@ -129,9 +129,10 @@ export class UserController { @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getUser( @Headers('accept-language') acceptLanguage: string, - @Impersonation() { isActive, userId }: ImpersonationContext + @Impersonation() { isActive, scopes, userId }: ImpersonationContext ): Promise { return this.userService.getUser({ + scopes, impersonationUserId: isActive ? userId : undefined, locale: acceptLanguage?.split(',')?.[0], user: this.request.user diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index efa02073f..0a4f21190 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -53,6 +53,7 @@ import { hasRole, permissions } from '@ghostfolio/common/permissions'; +import { getScopesOfAccess } from '@ghostfolio/common/scopes'; import { UserWithSettings } from '@ghostfolio/common/types'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; @@ -115,10 +116,12 @@ export class UserService { public async getUser({ impersonationUserId, locale = DEFAULT_LOCALE, + scopes, user }: { impersonationUserId: string; locale?: string; + scopes: string[]; user: UserWithSettings; }): Promise { const { id, permissions, settings, subscription } = user; @@ -209,6 +212,7 @@ export class UserService { id, permissions, referralPartners, + scopes, subscription, systemMessage, tags, @@ -216,7 +220,8 @@ export class UserService { return { alias: accessItem.alias, id: accessItem.id, - permissions: accessItem.permissions + permissions: accessItem.permissions, + scopes: getScopesOfAccess(accessItem) }; }), accounts: accounts.sort((a, b) => { @@ -285,7 +290,6 @@ export class UserService { activities: true } }, - accessesGet: true, accounts: { include: { platform: true } }, @@ -302,7 +306,6 @@ export class UserService { const { _count, - accessesGet, accessToken, accounts, analytics, @@ -320,7 +323,6 @@ export class UserService { const activitiesCount = _count?.activities ?? 0; const user: UserWithSettings = { - accessesGet, accessToken, accounts, authChallenge, diff --git a/apps/api/src/decorators/impersonation.decorator.ts b/apps/api/src/decorators/impersonation.decorator.ts index 190542459..3964388e5 100644 --- a/apps/api/src/decorators/impersonation.decorator.ts +++ b/apps/api/src/decorators/impersonation.decorator.ts @@ -1,3 +1,4 @@ +import { getScopesOfOwnAccess } from '@ghostfolio/common/scopes'; import type { ImpersonationContext, RequestWithUser @@ -18,6 +19,7 @@ export const Impersonation = createParamDecorator( return ( impersonation ?? { isActive: false, + scopes: getScopesOfOwnAccess(), userId: user?.id, userSettings: user?.settings?.settings ?? {} } diff --git a/apps/api/src/decorators/requires-scope.decorator.spec.ts b/apps/api/src/decorators/requires-scope.decorator.spec.ts new file mode 100644 index 000000000..a36ff9201 --- /dev/null +++ b/apps/api/src/decorators/requires-scope.decorator.spec.ts @@ -0,0 +1,41 @@ +import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { ImpersonationGuard } from '@ghostfolio/api/guards/impersonation.guard'; +import { ScopeGuard } from '@ghostfolio/api/guards/scope.guard'; +import { scopes } from '@ghostfolio/common/scopes'; + +import { GUARDS_METADATA } from '@nestjs/common/constants'; +import { AuthGuard } from '@nestjs/passport'; + +import { REQUIRES_SCOPE_KEY, RequiresScope } from './requires-scope.decorator'; + +class TestController { + @RequiresScope(scopes.portfolioRead) + public getPortfolio() { + return null; + } +} + +describe('Requires scope', () => { + it('Sets the required scopes', () => { + expect( + Reflect.getMetadata( + REQUIRES_SCOPE_KEY, + TestController.prototype.getPortfolio + ) + ).toEqual([scopes.portfolioRead]); + }); + + it('Applies the guards in the required order', () => { + expect( + Reflect.getMetadata( + GUARDS_METADATA, + TestController.prototype.getPortfolio + ) + ).toEqual([ + AuthGuard('jwt'), + HasPermissionGuard, + ImpersonationGuard, + ScopeGuard + ]); + }); +}); diff --git a/apps/api/src/decorators/requires-scope.decorator.ts b/apps/api/src/decorators/requires-scope.decorator.ts new file mode 100644 index 000000000..dab837955 --- /dev/null +++ b/apps/api/src/decorators/requires-scope.decorator.ts @@ -0,0 +1,26 @@ +import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { ImpersonationGuard } from '@ghostfolio/api/guards/impersonation.guard'; +import { ScopeGuard } from '@ghostfolio/api/guards/scope.guard'; +import { Scope } from '@ghostfolio/common/scopes'; + +import { applyDecorators, SetMetadata, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +export const REQUIRES_SCOPE_KEY = 'requires_scope'; + +/** + * Marks a route which requires the given scopes and applies the guards which + * resolve the impersonation context and evaluate it, hence the ScopeGuard + * cannot be applied without the ImpersonationGuard preceding it + */ +export function RequiresScope(...requiredScopes: Scope[]) { + return applyDecorators( + SetMetadata(REQUIRES_SCOPE_KEY, requiredScopes), + UseGuards( + AuthGuard('jwt'), + HasPermissionGuard, + ImpersonationGuard, + ScopeGuard + ) + ); +} diff --git a/apps/api/src/guards/oauth-callback.guard.ts b/apps/api/src/guards/oauth-callback.guard.ts new file mode 100644 index 000000000..cedbccde1 --- /dev/null +++ b/apps/api/src/guards/oauth-callback.guard.ts @@ -0,0 +1,21 @@ +import { Logger, mixin, Type } from '@nestjs/common'; +import { AuthGuard, IAuthGuard } from '@nestjs/passport'; + +export function OAuthCallbackGuard(strategy: string): Type { + class OAuthCallbackGuardMixin extends AuthGuard(strategy) { + private readonly logger = new Logger(OAuthCallbackGuard.name); + + public override handleRequest(error: Error, user: any) { + if (error) { + this.logger.error( + `Authentication with the ${strategy} strategy has failed: ${error.message}` + ); + } + + // Do not throw, the callback handler redirects to the login page instead + return user; + } + } + + return mixin(OAuthCallbackGuardMixin); +} diff --git a/apps/api/src/guards/scope.guard.ts b/apps/api/src/guards/scope.guard.ts new file mode 100644 index 000000000..025a85502 --- /dev/null +++ b/apps/api/src/guards/scope.guard.ts @@ -0,0 +1,50 @@ +import { REQUIRES_SCOPE_KEY } from '@ghostfolio/api/decorators/requires-scope.decorator'; +import { hasScope, Scope } from '@ghostfolio/common/scopes'; +import type { RequestWithUser } from '@ghostfolio/common/types'; + +import { + CanActivate, + ExecutionContext, + HttpException, + Injectable +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +/** + * Denies a request whose impersonation context does not cover the scopes + * required by the route. It has to be applied after the ImpersonationGuard, + * which resolves the context, hence the RequiresScope decorator applies both. + */ +@Injectable() +export class ScopeGuard implements CanActivate { + public constructor(private readonly reflector: Reflector) {} + + public canActivate(context: ExecutionContext): boolean { + const requiredScopes = this.reflector.getAllAndOverride( + REQUIRES_SCOPE_KEY, + [context.getHandler(), context.getClass()] + ); + + if (!requiredScopes?.length) { + return true; + } + + const { impersonation } = context + .switchToHttp() + .getRequest(); + + const hasRequiredScopes = requiredScopes.every((scope) => { + return hasScope(impersonation?.scopes, scope); + }); + + if (!hasRequiredScopes) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + return true; + } +} diff --git a/apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts b/apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts index 6a9596298..e575cbacd 100644 --- a/apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts +++ b/apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts @@ -1,13 +1,8 @@ import { redactPaths } from '@ghostfolio/api/helper/object.helper'; -import { - DEFAULT_REDACTED_PATHS, - HEADER_KEY_IMPERSONATION -} from '@ghostfolio/common/config'; -import { - hasReadRestrictedAccessPermission, - isRestrictedView -} from '@ghostfolio/common/permissions'; -import { UserWithSettings } from '@ghostfolio/common/types'; +import { DEFAULT_REDACTED_PATHS } from '@ghostfolio/common/config'; +import { isRestrictedView } from '@ghostfolio/common/permissions'; +import { hasScope, scopes } from '@ghostfolio/common/scopes'; +import type { RequestWithUser } from '@ghostfolio/common/types'; import { CallHandler, @@ -29,17 +24,15 @@ export class RedactValuesInResponseInterceptor implements NestInterceptor< ): Observable { return next.handle().pipe( map((data: any) => { - const { headers, user }: { headers: Headers; user: UserWithSettings } = - context.switchToHttp().getRequest(); - - const impersonationId = - headers?.[HEADER_KEY_IMPERSONATION.toLowerCase()]; + const { impersonation, user } = context + .switchToHttp() + .getRequest(); + // A missing impersonation context originates from a public request or + // from a route without the ImpersonationGuard, hence the monetary + // values are redacted to never expose them unintentionally if ( - hasReadRestrictedAccessPermission({ - impersonationId, - accesses: user?.accessesGet - }) || + !hasScope(impersonation?.scopes, scopes.portfolioReadValues) || isRestrictedView(user) ) { data = redactPaths({ diff --git a/apps/api/src/services/asset-profile-split/asset-profile-split.helper.spec.ts b/apps/api/src/services/asset-profile-split/asset-profile-split.helper.spec.ts new file mode 100644 index 000000000..6d674255d --- /dev/null +++ b/apps/api/src/services/asset-profile-split/asset-profile-split.helper.spec.ts @@ -0,0 +1,114 @@ +import { + activityDummyData, + assetProfileDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import * as commonHelper from '@ghostfolio/common/helper'; +import { parseDate } from '@ghostfolio/common/helper'; +import { Activity } from '@ghostfolio/common/interfaces'; + +import { AssetProfileSplit, DataSource } from '@prisma/client'; +import { Big } from 'big.js'; + +import { adjustActivityBySplits } from './asset-profile-split.helper'; + +describe('adjustActivityBySplits', () => { + it('adjusts quantity and prices using the cumulative split factor', () => { + const activity = createActivity('2020-01-01'); + const splits = [ + createSplit('2021-01-01', 2, 1), + createSplit('2022-01-01', 1, 3) + ]; + + const adjustedActivity = adjustActivityBySplits(activity, splits); + + expect(adjustedActivity).not.toBe(activity); + expect(adjustedActivity).toMatchObject({ + quantity: 20 / 3, + unitPrice: 150, + unitPriceInAssetProfileCurrency: 150, + value: 1000, + valueInBaseCurrency: 1000 + }); + expect(new Big(adjustedActivity.quantity).toFixed(15)).toBe( + new Big(20).div(3).toFixed(15) + ); + expect(activity).toMatchObject({ + quantity: 10, + unitPrice: 100, + unitPriceInAssetProfileCurrency: 100 + }); + }); + + it('only adjusts activities before the split calendar date', () => { + const split = createSplit('2024-06-15T00:00:00Z', 2, 1); + const activityOnSplitDate = createActivity('2024-06-15T18:00:00Z'); + const activityAfterSplit = createActivity('2024-06-16T00:00:00Z'); + + const adjustedActivityOnSplitDate = adjustActivityBySplits( + activityOnSplitDate, + [split] + ); + const adjustedActivityAfterSplit = adjustActivityBySplits( + activityAfterSplit, + [split] + ); + + expect(adjustedActivityOnSplitDate).toEqual(activityOnSplitDate); + expect(adjustedActivityOnSplitDate).toBe(activityOnSplitDate); + expect(adjustedActivityAfterSplit).toEqual(activityAfterSplit); + expect(adjustedActivityAfterSplit).toBe(activityAfterSplit); + }); + + it('compares stored UTC split dates without normalizing them locally', () => { + const resetHoursSpy = jest + .spyOn(commonHelper, 'resetHours') + .mockReturnValue(new Date('2024-06-14T00:00:00Z')); + + try { + const activity = createActivity('2024-06-14T12:00:00Z'); + const split = createSplit('2024-06-15T00:00:00Z', 2, 1); + + const adjustedActivity = adjustActivityBySplits(activity, [split]); + + expect(adjustedActivity.quantity).toBe(20); + } finally { + resetHoursSpy.mockRestore(); + } + }); +}); + +function createActivity(date: string): Activity { + return { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + dataSource: DataSource.YAHOO, + symbol: 'AAPL' + }, + date: parseDate(date), + quantity: 10, + type: 'BUY', + unitPrice: 100, + unitPriceInAssetProfileCurrency: 100, + value: 1000, + valueInBaseCurrency: 1000 + } as Activity; +} + +function createSplit( + date: string, + numerator: number, + denominator: number +): AssetProfileSplit { + const splitDate = new Date(date); + + return { + denominator, + numerator, + createdAt: splitDate, + date: splitDate, + id: `${date}-${numerator}-${denominator}`, + symbolProfileId: 'aapl-profile', + updatedAt: splitDate + }; +} diff --git a/apps/api/src/services/asset-profile-split/asset-profile-split.helper.ts b/apps/api/src/services/asset-profile-split/asset-profile-split.helper.ts new file mode 100644 index 000000000..dda77a5aa --- /dev/null +++ b/apps/api/src/services/asset-profile-split/asset-profile-split.helper.ts @@ -0,0 +1,58 @@ +import { INVESTMENT_ACTIVITY_TYPES } from '@ghostfolio/common/config'; +import { resetHours } from '@ghostfolio/common/helper'; +import { Activity } from '@ghostfolio/common/interfaces'; + +import { AssetProfileSplit } from '@prisma/client'; +import { Big } from 'big.js'; +import { isBefore } from 'date-fns'; + +export function adjustActivityBySplits( + activity: Activity, + splits: AssetProfileSplit[] +): Activity { + if (!INVESTMENT_ACTIVITY_TYPES.includes(activity.type)) { + return activity; + } + + const activityDate = resetHours(activity.date); + + // Accumulate both parts of the ratio and divide only once, so that the + // cumulative split factor of consecutive splits stays exact + let denominator = new Big(1); + let numerator = new Big(1); + + for (const split of splits) { + // Skip malformed splits to not break the portfolio calculation of every + // user holding this asset profile + if (split.denominator <= 0 || split.numerator <= 0) { + continue; + } + + if (isBefore(activityDate, split.date)) { + denominator = denominator.mul(split.denominator); + numerator = numerator.mul(split.numerator); + } + } + + if (numerator.eq(denominator)) { + return activity; + } + + return { + ...activity, + quantity: new Big(activity.quantity) + .mul(numerator) + .div(denominator) + .toNumber(), + unitPrice: new Big(activity.unitPrice) + .mul(denominator) + .div(numerator) + .toNumber(), + unitPriceInAssetProfileCurrency: new Big( + activity.unitPriceInAssetProfileCurrency + ) + .mul(denominator) + .div(numerator) + .toNumber() + }; +} diff --git a/apps/api/src/services/asset-profile-split/asset-profile-split.service.spec.ts b/apps/api/src/services/asset-profile-split/asset-profile-split.service.spec.ts new file mode 100644 index 000000000..be5d7caa3 --- /dev/null +++ b/apps/api/src/services/asset-profile-split/asset-profile-split.service.spec.ts @@ -0,0 +1,161 @@ +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; + +import { AssetProfileSplit, DataSource } from '@prisma/client'; + +import { AssetProfileSplitService } from './asset-profile-split.service'; + +describe('AssetProfileSplitService', () => { + let assetProfileSplitService: AssetProfileSplitService; + let deleteMany: jest.Mock; + let findMany: jest.Mock; + let upsert: jest.Mock; + + beforeEach(() => { + deleteMany = jest.fn(); + findMany = jest.fn(); + upsert = jest.fn(); + + assetProfileSplitService = new AssetProfileSplitService({ + assetProfileSplit: { deleteMany, findMany, upsert } + } as unknown as PrismaService); + }); + + describe('deleteById', () => { + it('scopes deletion by split and asset profile identifiers', async () => { + deleteMany.mockResolvedValue({ count: 1 }); + + const result = await assetProfileSplitService.deleteById({ + id: 'split-id', + symbolProfileId: 'profile-id' + }); + + expect(result).toBe(true); + expect(deleteMany).toHaveBeenCalledWith({ + where: { + id: 'split-id', + symbolProfileId: 'profile-id' + } + }); + }); + + it('returns false when the split belongs to another asset profile', async () => { + deleteMany.mockResolvedValue({ count: 0 }); + + const result = await assetProfileSplitService.deleteById({ + id: 'split-id', + symbolProfileId: 'other-profile-id' + }); + + expect(result).toBe(false); + expect(deleteMany).toHaveBeenCalledWith({ + where: { + id: 'split-id', + symbolProfileId: 'other-profile-id' + } + }); + }); + }); + + describe('getSplitsByUserId', () => { + it('fetches the splits of the asset profiles held by the user with one ordered query', async () => { + const splits = [ + createStoredSplit('2020-08-31'), + createStoredSplit('2021-09-16') + ]; + + findMany.mockResolvedValue(splits); + + const result = await assetProfileSplitService.getSplitsByUserId({ + userId: 'user-id' + }); + + expect(result).toBe(splits); + expect(findMany).toHaveBeenCalledTimes(1); + expect(findMany).toHaveBeenCalledWith({ + orderBy: [{ date: 'asc' }], + where: { + symbolProfile: { + activities: { + some: { + userId: 'user-id' + } + } + } + } + }); + }); + }); + + describe('upsert', () => { + it('normalizes the split date before persisting it', async () => { + const date = new Date('2024-06-15T18:30:00.000Z'); + const normalizedDate = new Date('2024-06-15T00:00:00.000Z'); + + await assetProfileSplitService.upsert({ + date, + denominator: 1, + numerator: 2, + symbolProfileId: 'profile-id' + }); + + expect(upsert).toHaveBeenCalledWith({ + create: { + date: normalizedDate, + denominator: 1, + numerator: 2, + symbolProfileId: 'profile-id' + }, + update: { + denominator: 1, + numerator: 2 + }, + where: { + symbolProfileId_date: { + date: normalizedDate, + symbolProfileId: 'profile-id' + } + } + }); + }); + }); + + describe('getSplits', () => { + it('filters by asset profile and orders splits by date ascending', async () => { + const splits = [ + createStoredSplit('2020-01-01'), + createStoredSplit('2021-01-01') + ]; + findMany.mockResolvedValue(splits); + + const result = await assetProfileSplitService.getSplits({ + dataSource: DataSource.YAHOO, + symbol: 'AAPL' + }); + + expect(result).toBe(splits); + expect(findMany).toHaveBeenCalledWith({ + orderBy: [{ date: 'asc' }], + where: { + symbolProfile: { + dataSource: DataSource.YAHOO, + symbol: 'AAPL' + } + } + }); + }); + }); +}); + +function createStoredSplit(date: string): AssetProfileSplit { + const splitDate = new Date(date); + + return { + createdAt: splitDate, + date: splitDate, + denominator: 1, + id: `${date}-split`, + numerator: 2, + symbolProfileId: 'aapl-profile', + updatedAt: splitDate + }; +} diff --git a/apps/api/src/services/asset-profile-split/asset-profile-split.service.ts b/apps/api/src/services/asset-profile-split/asset-profile-split.service.ts index ec842caaf..31dd7935c 100644 --- a/apps/api/src/services/asset-profile-split/asset-profile-split.service.ts +++ b/apps/api/src/services/asset-profile-split/asset-profile-split.service.ts @@ -52,6 +52,33 @@ export class AssetProfileSplitService { }); } + /** + * Returns the splits of all asset profiles the given user has activities + * for, in ascending order by date + */ + public async getSplitsByUserId({ + userId + }: { + userId: string; + }): Promise { + return this.prismaService.assetProfileSplit.findMany({ + orderBy: [ + { + date: 'asc' + } + ], + where: { + symbolProfile: { + activities: { + some: { + userId + } + } + } + } + }); + } + public async upsert({ date, denominator, diff --git a/apps/api/src/services/impersonation/impersonation.service.ts b/apps/api/src/services/impersonation/impersonation.service.ts index f3083e96c..1432c14aa 100644 --- a/apps/api/src/services/impersonation/impersonation.service.ts +++ b/apps/api/src/services/impersonation/impersonation.service.ts @@ -2,12 +2,18 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; import { UserSettings } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import { + getScopesOfAccess, + getScopesOfOwnAccess, + getScopesOfUnrestrictedImpersonation +} from '@ghostfolio/common/scopes'; import type { ImpersonationContext, UserWithSettings } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; +import { Access } from '@prisma/client'; @Injectable() export class ImpersonationService { @@ -20,14 +26,13 @@ export class ImpersonationService { impersonationId?: string; user?: UserWithSettings; }): Promise { - const impersonatedUserId = await this.validateImpersonationId({ - impersonationId, - user - }); + const { access, userId: impersonatedUserId } = + await this.validateImpersonation({ impersonationId, user }); if (!impersonatedUserId) { return { isActive: false, + scopes: getScopesOfOwnAccess(), userId: user?.id, userSettings: user?.settings?.settings ?? {} }; @@ -40,6 +45,11 @@ export class ImpersonationService { return { accessId: impersonationId, isActive: true, + // An access which has not been granted explicitly originates from the + // permission to impersonate all users + scopes: access + ? getScopesOfAccess(access) + : getScopesOfUnrestrictedImpersonation(), userId: impersonatedUserId, userSettings: { ...((settings?.settings ?? {}) as UserSettings), @@ -49,15 +59,15 @@ export class ImpersonationService { }; } - public async validateImpersonationId({ + private async validateImpersonation({ impersonationId, user }: { impersonationId?: string; user?: UserWithSettings; - }) { + }): Promise<{ access?: Access; userId: string | null }> { if (!impersonationId) { - return null; + return { userId: null }; } if (user) { @@ -69,7 +79,7 @@ export class ImpersonationService { }); if (accessObject?.userId) { - return accessObject.userId; + return { access: accessObject, userId: accessObject.userId }; } else if ( hasPermission(user.permissions, permissions.impersonateAllUsers) ) { @@ -79,7 +89,7 @@ export class ImpersonationService { where: { id: impersonationId } }); - return impersonatedUser?.id ?? null; + return { userId: impersonatedUser?.id ?? null }; } } else { // Public access @@ -91,10 +101,10 @@ export class ImpersonationService { }); if (accessObject?.userId) { - return accessObject.userId; + return { access: accessObject, userId: accessObject.userId }; } } - return null; + return { userId: null }; } } diff --git a/apps/api/src/services/queues/data-gathering/data-gathering.service.ts b/apps/api/src/services/queues/data-gathering/data-gathering.service.ts index d418c4f3f..17906a333 100644 --- a/apps/api/src/services/queues/data-gathering/data-gathering.service.ts +++ b/apps/api/src/services/queues/data-gathering/data-gathering.service.ts @@ -29,7 +29,7 @@ import { import { InjectQueue } from '@nestjs/bull'; import { Inject, Injectable, Logger } from '@nestjs/common'; import { Prisma } from '@prisma/client'; -import { JobOptions, Queue } from 'bull'; +import { Job, JobOptions, Queue } from 'bull'; import { format, min, subDays, subMilliseconds, subYears } from 'date-fns'; import { isEmpty } from 'lodash'; import ms, { StringValue } from 'ms'; @@ -285,7 +285,7 @@ export class DataGatheringService { date: date ?? item.date })); - await this.gatherSymbols({ + return this.gatherSymbols({ dataGatheringItems, force: true, priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH @@ -336,8 +336,8 @@ export class DataGatheringService { dataGatheringItems: DataGatheringItem[]; force?: boolean; priority: number; - }) { - await this.addJobsToQueue( + }): Promise { + return this.addJobsToQueue( dataGatheringItems.map(({ dataSource, date, symbol }) => { return { data: { diff --git a/apps/api/src/services/tag/tag.service.ts b/apps/api/src/services/tag/tag.service.ts index abcf349d9..0f23b03ea 100644 --- a/apps/api/src/services/tag/tag.service.ts +++ b/apps/api/src/services/tag/tag.service.ts @@ -10,9 +10,13 @@ export class TagService { public constructor(private readonly prismaService: PrismaService) {} public async createTag(data: Prisma.TagCreateInput) { - return this.prismaService.tag.create({ - data - }); + try { + return await this.prismaService.tag.create({ + data + }); + } catch (error) { + throw this.getExceptionForError(error); + } } public async deleteTag(where: Prisma.TagWhereUniqueInput): Promise { @@ -121,10 +125,14 @@ export class TagService { data: Prisma.TagUpdateInput; where: Prisma.TagWhereUniqueInput; }): Promise { - return this.prismaService.tag.update({ - data, - where - }); + try { + return await this.prismaService.tag.update({ + data, + where + }); + } catch (error) { + throw this.getExceptionForError(error); + } } public async validateTagIds({ @@ -180,4 +188,18 @@ export class TagService { return this.validateTagIds({ tagIds, userId }); } + + private getExceptionForError(error: unknown) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ) { + return new HttpException( + getReasonPhrase(StatusCodes.CONFLICT), + StatusCodes.CONFLICT + ); + } + + return error; + } } diff --git a/apps/client/src/app/components/access-table/access-table.component.html b/apps/client/src/app/components/access-table/access-table.component.html index 9d87cbd79..77da69d23 100644 --- a/apps/client/src/app/components/access-table/access-table.component.html +++ b/apps/client/src/app/components/access-table/access-table.component.html @@ -18,10 +18,10 @@ Permission
- @if (element.permissions.includes('READ')) { + @if (hasScopeToReadValues(element)) { View - } @else if (element.permissions.includes('READ_RESTRICTED')) { + } @else { Restricted view } diff --git a/apps/client/src/app/components/access-table/access-table.component.ts b/apps/client/src/app/components/access-table/access-table.component.ts index 00c5c1a28..3886fc59c 100644 --- a/apps/client/src/app/components/access-table/access-table.component.ts +++ b/apps/client/src/app/components/access-table/access-table.component.ts @@ -1,6 +1,7 @@ import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { Access, User } from '@ghostfolio/common/interfaces'; import { publicRoutes } from '@ghostfolio/common/routes/routes'; +import { hasScope, scopes } from '@ghostfolio/common/scopes'; import { NotificationService } from '@ghostfolio/ui/notifications'; import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard'; @@ -100,6 +101,10 @@ export class GfAccessTableComponent { return `${this.baseUrl}/${languageCode}/${publicRoutes.public.path}/${aId}`; } + protected hasScopeToReadValues({ scopes: scopesOfAccess }: Access) { + return hasScope(scopesOfAccess, scopes.portfolioReadValues); + } + protected onCopyUrlToClipboard(aId: string) { this.clipboard.copy(this.getPublicUrl(aId)); diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts index 9aa0f6964..518458e8a 100644 --- a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts @@ -14,11 +14,8 @@ import { PortfolioPosition, User } from '@ghostfolio/common/interfaces'; -import { - hasPermission, - hasReadRestrictedAccessPermission, - permissions -} from '@ghostfolio/common/permissions'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import { hasScope, scopes } from '@ghostfolio/common/scopes'; import { GfAccountBalancesComponent } from '@ghostfolio/ui/account-balances'; import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer'; @@ -233,10 +230,8 @@ export class GfAccountDetailDialogComponent implements OnInit { protected showValuesInPercentage() { return ( - hasReadRestrictedAccessPermission({ - accesses: this.user?.access, - impersonationId: this.data.impersonationId - }) || this.user?.settings?.isRestrictedView + !hasScope(this.user?.scopes, scopes.portfolioReadValues) || + this.user?.settings?.isRestrictedView ); } diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html index bcda048f8..b59b7e39f 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html @@ -511,9 +511,7 @@
- - @if (false && user?.settings?.isExperimentalFeatures) { + @if (user?.settings?.isExperimentalFeatures) { diff --git a/apps/client/src/app/components/home-watchlist/home-watchlist.component.ts b/apps/client/src/app/components/home-watchlist/home-watchlist.component.ts index 97fa6f744..7492e4e92 100644 --- a/apps/client/src/app/components/home-watchlist/home-watchlist.component.ts +++ b/apps/client/src/app/components/home-watchlist/home-watchlist.component.ts @@ -128,10 +128,17 @@ export class GfHomeWatchlistComponent implements OnInit { this.dataService .fetchWatchlist() .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(({ watchlist }) => { - this.watchlist = watchlist; + .subscribe({ + error: () => { + this.watchlist = []; + + this.changeDetectorRef.markForCheck(); + }, + next: ({ watchlist }) => { + this.watchlist = watchlist ?? []; - this.changeDetectorRef.markForCheck(); + this.changeDetectorRef.markForCheck(); + } }); } diff --git a/apps/client/src/app/components/markets/markets.component.ts b/apps/client/src/app/components/markets/markets.component.ts index 7c69fede0..0317ce648 100644 --- a/apps/client/src/app/components/markets/markets.component.ts +++ b/apps/client/src/app/components/markets/markets.component.ts @@ -43,7 +43,7 @@ import { DeviceDetectorService } from 'ngx-device-detector'; templateUrl: './markets.html' }) export class GfMarketsComponent implements OnInit { - protected readonly benchmarks = signal([]); + protected readonly benchmarks = signal(undefined); protected readonly deviceType = computed( () => this.deviceDetectorService.deviceInfo().deviceType @@ -117,8 +117,13 @@ export class GfMarketsComponent implements OnInit { this.dataService .fetchBenchmarks() .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(({ benchmarks }) => { - this.benchmarks.set(benchmarks); + .subscribe({ + error: () => { + this.benchmarks.set([]); + }, + next: ({ benchmarks }) => { + this.benchmarks.set(benchmarks ?? []); + } }); } diff --git a/apps/client/src/app/components/markets/markets.html b/apps/client/src/app/components/markets/markets.html index bd9013a00..d834dfd0e 100644 --- a/apps/client/src/app/components/markets/markets.html +++ b/apps/client/src/app/components/markets/markets.html @@ -56,7 +56,7 @@ [showSymbol]="false" [user]="user" /> - @if (benchmarks()?.length > 0) { + @if (benchmarks()?.length) {
diff --git a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/interfaces/interfaces.ts b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/interfaces/interfaces.ts index 8d1ac0ba9..b4fdd76d1 100644 --- a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/interfaces/interfaces.ts +++ b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/interfaces/interfaces.ts @@ -1,5 +1,6 @@ import { Access } from '@ghostfolio/common/interfaces'; export interface CreateOrUpdateAccessDialogParams { - access?: Access; + // TODO: Include the scopes once the dialog allows to configure them + access?: Omit; } diff --git a/apps/client/src/app/components/user-account-access/user-account-access.component.ts b/apps/client/src/app/components/user-account-access/user-account-access.component.ts index c9d74b6b1..b481d160a 100644 --- a/apps/client/src/app/components/user-account-access/user-account-access.component.ts +++ b/apps/client/src/app/components/user-account-access/user-account-access.component.ts @@ -247,15 +247,18 @@ export class GfUserAccountAccessComponent implements OnInit { } private update() { - this.accessesGet = this.user.access.map(({ alias, id, permissions }) => { - return { - id, - permissions, - alias: alias ?? '', - grantee: $localize`Me`, - type: 'PRIVATE' - }; - }); + this.accessesGet = this.user.access.map( + ({ alias, id, permissions, scopes }) => { + return { + id, + permissions, + scopes, + alias: alias ?? '', + grantee: $localize`Me`, + type: 'PRIVATE' + }; + } + ); this.dataService .fetchAccesses() diff --git a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts index be7d49bea..7d3c92645 100644 --- a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts +++ b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts @@ -17,11 +17,8 @@ import { PortfolioPosition, User } from '@ghostfolio/common/interfaces'; -import { - hasPermission, - hasReadRestrictedAccessPermission, - permissions -} from '@ghostfolio/common/permissions'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import { hasScope, scopes } from '@ghostfolio/common/scopes'; import { MarketAdvanced } from '@ghostfolio/common/types'; import { translate } from '@ghostfolio/ui/i18n'; import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart'; @@ -229,10 +226,8 @@ export class GfAllocationsPageComponent implements OnInit { protected showValuesInPercentage() { return ( - hasReadRestrictedAccessPermission({ - accesses: this.user?.access, - impersonationId: this.impersonationId - }) || this.user?.settings?.isRestrictedView + !hasScope(this.user?.scopes, scopes.portfolioReadValues) || + this.user?.settings?.isRestrictedView ); } diff --git a/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts b/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts index 2c34dbcd7..970f1379c 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts @@ -1,6 +1,5 @@ import { GfBenchmarkComparatorComponent } from '@ghostfolio/client/components/benchmark-comparator/benchmark-comparator.component'; import { GfInvestmentChartComponent } from '@ghostfolio/client/components/investment-chart/investment-chart.component'; -import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { DEFAULT_DATE_RANGE, @@ -15,11 +14,8 @@ import { PortfolioPosition, User } from '@ghostfolio/common/interfaces'; -import { - hasPermission, - hasReadRestrictedAccessPermission, - permissions -} from '@ghostfolio/common/permissions'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import { hasScope, scopes } from '@ghostfolio/common/scopes'; import type { AiPromptMode, GroupBy, @@ -88,7 +84,6 @@ export class GfAnalysisPageComponent implements OnInit { protected dividendsByGroup: InvestmentItem[]; protected readonly dividendTimelineDataLabel = $localize`Dividend`; protected hasPermissionToReadAiPrompt: boolean; - protected impersonationId: string | null; protected investments: InvestmentItem[]; protected readonly investmentTimelineDataLabel = $localize`Invested Capital`; protected investmentsByGroup: InvestmentItem[]; @@ -126,9 +121,6 @@ export class GfAnalysisPageComponent implements OnInit { private readonly dataService = inject(DataService); private readonly destroyRef = inject(DestroyRef); private readonly deviceDetectorService = inject(DeviceDetectorService); - private readonly impersonationStorageService = inject( - ImpersonationStorageService - ); private readonly snackBar = inject(MatSnackBar); private readonly userService = inject(UserService); @@ -150,15 +142,6 @@ export class GfAnalysisPageComponent implements OnInit { } public ngOnInit() { - this.impersonationStorageService - .onChangeHasImpersonation() - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((impersonationId) => { - this.impersonationId = impersonationId; - - this.changeDetectorRef.markForCheck(); - }); - this.userService.stateChanged .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((state) => { @@ -247,10 +230,8 @@ export class GfAnalysisPageComponent implements OnInit { protected showValuesInPercentage() { return ( - hasReadRestrictedAccessPermission({ - accesses: this.user?.access, - impersonationId: this.impersonationId - }) || this.user?.settings?.isRestrictedView + !hasScope(this.user?.scopes, scopes.portfolioReadValues) || + this.user?.settings?.isRestrictedView ); } diff --git a/libs/common/src/lib/dtos/create-access.dto.ts b/libs/common/src/lib/dtos/create-access.dto.ts index d4ba94fd2..370b1a2f8 100644 --- a/libs/common/src/lib/dtos/create-access.dto.ts +++ b/libs/common/src/lib/dtos/create-access.dto.ts @@ -16,6 +16,9 @@ export class CreateAccessDto { @IsUUID() granteeUserId?: string; + /** + * @deprecated Use the scopes instead + */ @IsEnum(AccessPermission, { each: true }) @IsOptional() permissions?: AccessPermission[]; diff --git a/libs/common/src/lib/dtos/update-access.dto.ts b/libs/common/src/lib/dtos/update-access.dto.ts index b5ed1db61..57d8fe9c0 100644 --- a/libs/common/src/lib/dtos/update-access.dto.ts +++ b/libs/common/src/lib/dtos/update-access.dto.ts @@ -19,6 +19,9 @@ export class UpdateAccessDto { @IsString() id: string; + /** + * @deprecated Use the scopes instead + */ @IsEnum(AccessPermission, { each: true }) @IsOptional() permissions?: AccessPermission[]; diff --git a/libs/common/src/lib/interfaces/access.interface.ts b/libs/common/src/lib/interfaces/access.interface.ts index 6b361d0b9..819dc60eb 100644 --- a/libs/common/src/lib/interfaces/access.interface.ts +++ b/libs/common/src/lib/interfaces/access.interface.ts @@ -8,7 +8,13 @@ export interface Access { alias: string | null; grantee?: string; id: string; + + /** + * @deprecated Use the scopes instead + */ permissions: AccessPermission[]; + + scopes: string[]; settings?: AccessSettings; type: AccessType; } diff --git a/libs/common/src/lib/interfaces/user.interface.ts b/libs/common/src/lib/interfaces/user.interface.ts index 619d4ee71..8ce8cbaa6 100644 --- a/libs/common/src/lib/interfaces/user.interface.ts +++ b/libs/common/src/lib/interfaces/user.interface.ts @@ -10,13 +10,14 @@ import { UserSettings } from './user-settings.interface'; // TODO: Compare with UserWithSettings export interface User { - access: Pick[]; + access: Pick[]; accounts: AccountWithPlatform[]; activitiesCount: number; dateOfFirstActivity: Date; id: string; permissions: string[]; referralPartners?: ReferralPartner[]; + scopes: string[]; settings: UserSettings; systemMessage?: SystemMessage; subscription: { diff --git a/libs/common/src/lib/permissions.ts b/libs/common/src/lib/permissions.ts index 96533a9e7..8771f51dd 100644 --- a/libs/common/src/lib/permissions.ts +++ b/libs/common/src/lib/permissions.ts @@ -1,6 +1,6 @@ import { UserWithSettings } from '@ghostfolio/common/types'; -import { Access, Role } from '@prisma/client'; +import { Role } from '@prisma/client'; export const permissions = { accessAdminControl: 'accessAdminControl', @@ -138,7 +138,9 @@ export function getPermissions(aRole: Role): string[] { permissions.createAccount, permissions.createAccountBalance, permissions.createActivity, - permissions.createAssetProfileSplitOfOwnAssetProfile, + // TODO: Grant createAssetProfileSplitOfOwnAssetProfile and + // deleteAssetProfileSplitOfOwnAssetProfile once the stock splits + // feature is no longer experimental permissions.createMarketDataOfOwnAssetProfile, permissions.createOwnTag, permissions.createWatchlistItem, @@ -146,7 +148,6 @@ export function getPermissions(aRole: Role): string[] { permissions.deleteAccount, permissions.deleteAccountBalance, permissions.deleteActivity, - permissions.deleteAssetProfileSplitOfOwnAssetProfile, permissions.deleteAuthDevice, permissions.deleteWatchlistItem, permissions.readAiPrompt, @@ -197,24 +198,6 @@ export function hasPermission( return aPermissions.includes(aPermission); } -export function hasReadRestrictedAccessPermission({ - accesses = [], - impersonationId -}: { - accesses?: Pick[]; - impersonationId: string | null; -}) { - if (!impersonationId) { - return false; - } - - const access = accesses.find(({ id }) => { - return id === impersonationId; - }); - - return access?.permissions?.includes('READ_RESTRICTED') ?? true; -} - export function hasRole(aUser: UserWithSettings, aRole: Role) { return aUser?.role === aRole; } diff --git a/libs/common/src/lib/scopes.spec.ts b/libs/common/src/lib/scopes.spec.ts new file mode 100644 index 000000000..feac97028 --- /dev/null +++ b/libs/common/src/lib/scopes.spec.ts @@ -0,0 +1,130 @@ +import { + getScopesOfAccess, + getScopesOfOwnAccess, + getScopesOfUnrestrictedImpersonation, + hasScope, + scopes +} from '@ghostfolio/common/scopes'; + +describe('Scopes', () => { + describe('Get scopes of access', () => { + it('Scopes take precedence over the permissions', () => { + expect( + getScopesOfAccess({ + granteeUserId: 'ffb08949-2f8a-4b6e-88fd-0f1e6b6b5f5d', + permissions: ['READ'], + scopes: [scopes.portfolioRead] + }) + ).toEqual([scopes.portfolioRead]); + }); + + it('Derive from the permission to read', () => { + // An access created before the scopes have been introduced has no scopes + expect( + getScopesOfAccess({ + granteeUserId: 'ffb08949-2f8a-4b6e-88fd-0f1e6b6b5f5d', + permissions: ['READ'], + scopes: [] + }) + ).toContain(scopes.portfolioReadValues); + }); + + it('Derive from the permission to read restricted', () => { + expect( + getScopesOfAccess({ + granteeUserId: 'ffb08949-2f8a-4b6e-88fd-0f1e6b6b5f5d', + permissions: ['READ_RESTRICTED'], + scopes: [] + }) + ).not.toContain(scopes.portfolioReadValues); + }); + + it('Without permissions and scopes', () => { + expect( + getScopesOfAccess({ + granteeUserId: 'ffb08949-2f8a-4b6e-88fd-0f1e6b6b5f5d' + }) + ).not.toContain(scopes.portfolioReadValues); + }); + }); + + describe('Get scopes of public access', () => { + it('Allows reading the portfolio', () => { + expect(getScopesOfAccess({ permissions: ['READ_RESTRICTED'] })).toContain( + scopes.portfolioRead + ); + }); + + it('Excludes the accounts and the watchlist', () => { + const scopesOfAccess = getScopesOfAccess({ + permissions: ['READ_RESTRICTED'] + }); + + expect(scopesOfAccess).not.toContain(scopes.accountRead); + expect(scopesOfAccess).not.toContain(scopes.watchlistRead); + }); + + it('Cannot be widened by the scopes', () => { + expect( + getScopesOfAccess({ + scopes: [ + scopes.portfolioRead, + scopes.portfolioReadValues, + scopes.watchlistRead + ] + }) + ).toEqual([scopes.portfolioRead]); + }); + + it('Cannot be widened by the permission to read', () => { + expect(getScopesOfAccess({ permissions: ['READ'] })).not.toContain( + scopes.portfolioReadValues + ); + }); + }); + + describe('Get scopes of own access', () => { + // A new scope has to be added here deliberately to confirm that it is + // granted to the owner of the data + it('Covers every scope', () => { + expect(getScopesOfOwnAccess()).toEqual([ + scopes.accountRead, + scopes.activityRead, + scopes.portfolioRead, + scopes.portfolioReadValues, + scopes.watchlistRead + ]); + }); + }); + + describe('Get scopes of unrestricted impersonation', () => { + // A new scope has to be added here deliberately to confirm that it is + // granted to an administrator impersonating an arbitrary user + it('Covers every scope but the monetary values', () => { + expect(getScopesOfUnrestrictedImpersonation()).toEqual([ + scopes.accountRead, + scopes.activityRead, + scopes.portfolioRead, + scopes.watchlistRead + ]); + }); + }); + + describe('Has scope', () => { + it('Present scope', () => { + expect(hasScope([scopes.portfolioRead], scopes.portfolioRead)).toEqual( + true + ); + }); + + it('Absent scope', () => { + expect( + hasScope([scopes.portfolioRead], scopes.portfolioReadValues) + ).toEqual(false); + }); + + it('Without scopes', () => { + expect(hasScope(undefined, scopes.portfolioRead)).toEqual(false); + }); + }); +}); diff --git a/libs/common/src/lib/scopes.ts b/libs/common/src/lib/scopes.ts new file mode 100644 index 000000000..fda81b4a9 --- /dev/null +++ b/libs/common/src/lib/scopes.ts @@ -0,0 +1,78 @@ +import { AccessPermission } from '@prisma/client'; + +/** + * Scopes describe what a grantee may do on behalf of the granting user. They + * are a separate axis from the permissions, which describe the capabilities of + * a role. Both are evaluated, hence a delegation can only narrow the access of + * the authenticated user and never widen it. + */ +export const scopes = { + accountRead: 'account:read', + activityRead: 'activity:read', + portfolioRead: 'portfolio:read', + portfolioReadValues: 'portfolio:read:values', + watchlistRead: 'watchlist:read' +} as const; + +export type Scope = (typeof scopes)[keyof typeof scopes]; + +const SCOPES_OF_PUBLIC_ACCESS: Scope[] = [ + scopes.activityRead, + scopes.portfolioRead +]; + +const SCOPES_OF_READ_ACCESS = Object.values(scopes); + +const SCOPES_OF_READ_RESTRICTED_ACCESS = SCOPES_OF_READ_ACCESS.filter( + (scope) => { + return scope !== scopes.portfolioReadValues; + } +); + +export function getScopesOfAccess({ + granteeUserId, + permissions, + scopes: scopesOfAccess +}: { + granteeUserId?: string | null; + permissions?: AccessPermission[]; + scopes?: string[]; +}): string[] { + if (!scopesOfAccess?.length) { + // TODO: Remove the derivation from the permissions once they have been + // dropped from the access + scopesOfAccess = permissions?.includes('READ') + ? SCOPES_OF_READ_ACCESS + : SCOPES_OF_READ_RESTRICTED_ACCESS; + } + + if (granteeUserId) { + return [...scopesOfAccess]; + } + + // An access which has not been granted to a user is public, hence it is + // narrowed to the scopes exposed by the public endpoints + return SCOPES_OF_PUBLIC_ACCESS.filter((scope) => { + return scopesOfAccess.includes(scope); + }); +} + +/** + * Scopes of a user acting on their own data, which is unrestricted. The + * permissions of the role are evaluated separately. + */ +export function getScopesOfOwnAccess(): string[] { + return Object.values(scopes); +} + +/** + * Scopes of an administrator impersonating an arbitrary user, which excludes + * the monetary values + */ +export function getScopesOfUnrestrictedImpersonation(): string[] { + return [...SCOPES_OF_READ_RESTRICTED_ACCESS]; +} + +export function hasScope(aScopes: string[] = [], aScope: Scope) { + return aScopes.includes(aScope); +} diff --git a/libs/common/src/lib/types/impersonation-context.type.ts b/libs/common/src/lib/types/impersonation-context.type.ts index f5afb6bc7..8bdfb9bb3 100644 --- a/libs/common/src/lib/types/impersonation-context.type.ts +++ b/libs/common/src/lib/types/impersonation-context.type.ts @@ -8,6 +8,7 @@ import { UserSettings } from '@ghostfolio/common/interfaces'; export interface ImpersonationContext { accessId?: string; isActive: boolean; + scopes: string[]; userId: string; userSettings: UserSettings; } diff --git a/libs/common/src/lib/types/user-with-settings.type.ts b/libs/common/src/lib/types/user-with-settings.type.ts index 3c6adfec0..37b2b83e9 100644 --- a/libs/common/src/lib/types/user-with-settings.type.ts +++ b/libs/common/src/lib/types/user-with-settings.type.ts @@ -1,11 +1,10 @@ import { SubscriptionType } from '@ghostfolio/common/enums'; import { SubscriptionOffer, UserSettings } from '@ghostfolio/common/interfaces'; -import { Access, Account, Settings, User } from '@prisma/client'; +import { Account, Settings, User } from '@prisma/client'; // TODO: Compare with User interface export type UserWithSettings = User & { - accessesGet: Access[]; accounts: Account[]; activityCount: number; dataProviderGhostfolioDailyRequests: number; diff --git a/libs/ui/src/lib/benchmark/benchmark.component.html b/libs/ui/src/lib/benchmark/benchmark.component.html index 296e0ce17..556b9875f 100644 --- a/libs/ui/src/lib/benchmark/benchmark.component.html +++ b/libs/ui/src/lib/benchmark/benchmark.component.html @@ -216,7 +216,7 @@
-@if (isLoading) { +@if (isLoading()) { (); + public readonly benchmarks = input(); public readonly deviceType = input.required(); public readonly hasPermissionToDeleteItem = input(); public readonly locale = input(getLocale()); @@ -76,6 +76,7 @@ export class GfBenchmarkComponent { protected readonly sort = viewChild(MatSort); protected readonly dataSource = new MatTableDataSource([]); + protected readonly displayedColumns = computed(() => { return [ ...(this.showIcon() ? ['icon'] : []), @@ -89,7 +90,11 @@ export class GfBenchmarkComponent { 'actions' ]; }); - protected isLoading = true; + + protected readonly isLoading = computed(() => { + return !this.benchmarks(); + }); + protected readonly isNumber = isNumber; protected readonly resolveMarketCondition = resolveMarketCondition; protected readonly round = round; @@ -110,8 +115,8 @@ export class GfBenchmarkComponent { this.dataSource.sortingDataAccessor = getLowercase; this.dataSource.sort = this.sort() ?? null; - - this.isLoading = false; + } else { + this.dataSource.data = []; } }); diff --git a/libs/ui/src/lib/tags-selector/tags-selector.component.html b/libs/ui/src/lib/tags-selector/tags-selector.component.html index 9bede09a7..b8a2c372d 100644 --- a/libs/ui/src/lib/tags-selector/tags-selector.component.html +++ b/libs/ui/src/lib/tags-selector/tags-selector.component.html @@ -55,12 +55,12 @@ } - @if (hasPermissionToCreateTag && tagInputControl.value) { - + @if (hasPermissionToCreateTag && tagNameToCreate()) { + Create "{{ - tagInputControl.value.trim() + tagNameToCreate() }}" diff --git a/libs/ui/src/lib/tags-selector/tags-selector.component.ts b/libs/ui/src/lib/tags-selector/tags-selector.component.ts index 0e97e12fd..acd0d8d3b 100644 --- a/libs/ui/src/lib/tags-selector/tags-selector.component.ts +++ b/libs/ui/src/lib/tags-selector/tags-selector.component.ts @@ -72,6 +72,7 @@ export class GfTagsSelectorComponent ); public readonly separatorKeysCodes: number[] = [COMMA, ENTER]; public readonly tagInputControl = new FormControl(''); + public readonly tagNameToCreate = signal(null); public readonly tagsSelected = signal([]); private readonly tagInput = @@ -80,8 +81,8 @@ export class GfTagsSelectorComponent public constructor() { this.tagInputControl.valueChanges .pipe(takeUntilDestroyed()) - .subscribe((value) => { - this.filteredOptions.next(this.filterTags(value ?? '')); + .subscribe(() => { + this.updateFilters(); }); addIcons({ addCircleOutline, closeOutline }); @@ -161,9 +162,8 @@ export class GfTagsSelectorComponent this.updateFilters(); } - private filterTags(query: string = ''): SelectedTag[] { - const tags = this.tagsSelected() ?? []; - const tagIds = [...tags, ...(this.tagsReadOnly ?? [])].map(({ id }) => { + private filterTags(query: string): SelectedTag[] { + const tagIds = this.getTagsSelectedAndReadOnly().map(({ id }) => { return id; }); @@ -179,6 +179,27 @@ export class GfTagsSelectorComponent }); } + private getTagNameToCreate(query: string): string | null { + const name = query.trim(); + + if (!name) { + return null; + } + + const isExistingTagName = [ + ...(this.tagsAvailable ?? []), + ...this.getTagsSelectedAndReadOnly() + ].some((tag) => { + return tag.name.toLowerCase() === name.toLowerCase(); + }); + + return isExistingTagName ? null : name; + } + + private getTagsSelectedAndReadOnly(): SelectedTag[] { + return [...this.tagsSelected(), ...(this.tagsReadOnly ?? [])]; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars private onChange = (_value: SelectedTag[]): void => { // ControlValueAccessor onChange callback @@ -189,6 +210,9 @@ export class GfTagsSelectorComponent }; private updateFilters() { - this.filteredOptions.next(this.filterTags()); + const query = this.tagInputControl.value ?? ''; + + this.filteredOptions.next(this.filterTags(query)); + this.tagNameToCreate.set(this.getTagNameToCreate(query)); } } diff --git a/package-lock.json b/package-lock.json index df0ee91b4..e7e6f4968 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ghostfolio", - "version": "3.51.0", + "version": "3.52.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ghostfolio", - "version": "3.51.0", + "version": "3.52.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/package.json b/package.json index 62c1a7ce7..b1ea1ab2a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostfolio", - "version": "3.51.0", + "version": "3.52.0", "homepage": "https://ghostfol.io", "license": "AGPL-3.0", "repository": "https://github.com/ghostfolio/ghostfolio", diff --git a/prisma/migrations/20260815120000_added_symbol_profile_id_index_to_order/migration.sql b/prisma/migrations/20260815120000_added_symbol_profile_id_index_to_order/migration.sql new file mode 100644 index 000000000..9f2aaf06d --- /dev/null +++ b/prisma/migrations/20260815120000_added_symbol_profile_id_index_to_order/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX "Order_symbolProfileId_idx" ON "Order"("symbolProfileId"); diff --git a/prisma/migrations/20260815130000_added_scopes_to_access/migration.sql b/prisma/migrations/20260815130000_added_scopes_to_access/migration.sql new file mode 100644 index 000000000..11f344421 --- /dev/null +++ b/prisma/migrations/20260815130000_added_scopes_to_access/migration.sql @@ -0,0 +1,24 @@ +-- AlterTable +ALTER TABLE "Access" ADD COLUMN "scopes" TEXT[] DEFAULT ARRAY[]::TEXT[]; + +-- Derive the scopes from the permissions of the existing accesses +UPDATE "Access" +SET "scopes" = CASE + WHEN "granteeUserId" IS NULL THEN ARRAY[ + 'activity:read', + 'portfolio:read' + ] + WHEN 'READ' = ANY("permissions") THEN ARRAY[ + 'account:read', + 'activity:read', + 'portfolio:read', + 'portfolio:read:values', + 'watchlist:read' + ] + ELSE ARRAY[ + 'account:read', + 'activity:read', + 'portfolio:read', + 'watchlist:read' + ] +END; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 14cbb72fe..ca394887f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -14,7 +14,9 @@ model Access { granteeUser User? @relation("accessGet", fields: [granteeUserId], onDelete: Cascade, references: [id]) granteeUserId String? id String @id @default(uuid()) + /// @deprecated Use the scopes instead permissions AccessPermission[] @default([READ_RESTRICTED]) + scopes String[] @default([]) settings Json @default("{}") updatedAt DateTime @updatedAt userId String @@ -195,6 +197,7 @@ model Order { @@index([accountId]) @@index([date]) + @@index([symbolProfileId]) @@index([type]) @@index([userId]) }