Browse Source

Merge branch 'main' into feature/add-support-for-web-fetch-in-fetch-service

pull/6960/head
Thomas Kaul 3 months ago
committed by GitHub
parent
commit
3badceb5b5
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      CHANGELOG.md
  2. 4
      apps/api/src/app/endpoints/market-data/market-data.controller.ts
  3. 272
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  4. 3
      apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts
  5. 12
      apps/client/src/app/core/auth.guard.ts
  6. 16
      apps/client/src/app/core/auth.interceptor.ts
  7. 45
      apps/client/src/app/core/http-response.interceptor.ts
  8. 24
      apps/client/src/app/directives/file-drop/file-drop.directive.ts

4
CHANGELOG.md

@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added support for routing selected requests through the _OpenRouter_ `web_fetch` tool in the `FetchService` - Added support for routing selected requests through the _OpenRouter_ `web_fetch` tool in the `FetchService`
### Changed
- Extended the countries mapping in the data enhancer for asset profile data via _Trackinsight_
## 3.6.0 - 2026-05-28 ## 3.6.0 - 2026-05-28
### Added ### Added

4
apps/api/src/app/endpoints/market-data/market-data.controller.ts

@ -120,10 +120,10 @@ export class MarketDataController {
if (!canReadAllAssetProfiles && !canReadOwnAssetProfile) { if (!canReadAllAssetProfiles && !canReadOwnAssetProfile) {
throw new HttpException( throw new HttpException(
assetProfile.userId assetProfile?.userId
? getReasonPhrase(StatusCodes.NOT_FOUND) ? getReasonPhrase(StatusCodes.NOT_FOUND)
: getReasonPhrase(StatusCodes.FORBIDDEN), : getReasonPhrase(StatusCodes.FORBIDDEN),
assetProfile.userId ? StatusCodes.NOT_FOUND : StatusCodes.FORBIDDEN assetProfile?.userId ? StatusCodes.NOT_FOUND : StatusCodes.FORBIDDEN
); );
} }

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

@ -0,0 +1,272 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { parseDate } from '@ghostfolio/common/helper';
import { Account, DataSource } from '@prisma/client';
import { Big } from 'big.js';
import { randomUUID } from 'node:crypto';
import { PortfolioService } from './portfolio.service';
describe('PortfolioService', () => {
let accountService: AccountService;
let activitiesService: ActivitiesService;
let configurationService: ConfigurationService;
let dataProviderService: DataProviderService;
let exchangeRateDataService: ExchangeRateDataService;
let impersonationService: ImpersonationService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioService: PortfolioService;
let symbolProfileService: SymbolProfileService;
let userService: UserService;
beforeEach(() => {
configurationService = new ConfigurationService();
dataProviderService = new DataProviderService(
configurationService,
null,
null,
null,
null,
null
);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
accountService = new AccountService(
null,
null,
exchangeRateDataService,
null
);
activitiesService = new ActivitiesService(
null,
accountService,
null,
dataProviderService,
null,
exchangeRateDataService,
null,
null
);
impersonationService = new ImpersonationService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
null,
exchangeRateDataService,
null,
null
);
symbolProfileService = new SymbolProfileService(null);
userService = new UserService(
null,
null,
null,
null,
null,
null,
null,
null
);
portfolioService = new PortfolioService(
null,
accountService,
activitiesService,
null,
portfolioCalculatorFactory,
dataProviderService,
exchangeRateDataService,
null,
impersonationService,
null,
null,
symbolProfileService,
userService
);
});
describe('getCashSymbolProfiles', () => {
it('should use the exchange-rate data source so the symbol-profile join in getDetails matches the calculator positions', () => {
jest
.spyOn(dataProviderService, 'getDataSourceForExchangeRates')
.mockReturnValue(DataSource.YAHOO);
const cashDetails: CashDetails = {
accounts: [
{
balance: 2000,
comment: null,
createdAt: parseDate('2024-01-01'),
currency: 'USD',
id: randomUUID(),
isExcluded: false,
name: 'USD',
platformId: null,
updatedAt: parseDate('2024-01-01'),
userId: userDummyData.id
}
],
balanceInBaseCurrency: 1820
};
const assetProfiles = (
portfolioService as unknown as {
getCashSymbolProfiles: (
aCashDetails: CashDetails
) => { dataSource: DataSource; symbol: string }[];
}
).getCashSymbolProfiles(cashDetails);
expect(assetProfiles).toHaveLength(1);
expect(assetProfiles[0].dataSource).toBe(DataSource.YAHOO);
expect(assetProfiles[0].symbol).toBe('USD');
});
});
describe('getDetails', () => {
it('should return cash holdings when the calculator emits cash positions with the exchange-rate data source', async () => {
const accountId = randomUUID();
const cashAccount: Account = {
balance: 2000,
comment: null,
createdAt: parseDate('2024-01-01'),
currency: 'USD',
id: accountId,
isExcluded: false,
name: 'USD',
platformId: null,
updatedAt: parseDate('2024-01-01'),
userId: userDummyData.id
};
jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({
accounts: [cashAccount],
balanceInBaseCurrency: 1820
});
jest
.spyOn(activitiesService, 'getActivitiesForPortfolioCalculator')
.mockResolvedValue({ activities: [], count: 0 });
jest
.spyOn(dataProviderService, 'getDataSourceForExchangeRates')
.mockReturnValue(DataSource.YAHOO);
jest
.spyOn(impersonationService, 'validateImpersonationId')
.mockResolvedValue(null);
jest
.spyOn(symbolProfileService, 'getSymbolProfiles')
.mockResolvedValue([]);
jest.spyOn(userService, 'user').mockResolvedValue({
accessesGet: [],
accounts: [],
activityCount: 0,
dataProviderGhostfolioDailyRequests: 0,
id: userDummyData.id,
settings: {
settings: {
baseCurrency: 'CHF'
}
}
} as unknown as Awaited<ReturnType<typeof userService.user>>);
const usdPosition = {
activitiesCount: 1,
averagePrice: new Big(1),
currency: 'USD',
dataSource: DataSource.YAHOO,
dateOfFirstActivity: '2024-01-01',
dividend: new Big(0),
dividendInBaseCurrency: new Big(0),
fee: new Big(0),
feeInBaseCurrency: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(0),
grossPerformanceWithCurrencyEffect: new Big(0),
investment: new Big(1820),
investmentWithCurrencyEffect: new Big(1820),
marketPrice: 1,
marketPriceInBaseCurrency: 0.91,
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffectMap: {},
netPerformanceWithCurrencyEffectMap: {},
quantity: new Big(2000),
symbol: 'USD',
tags: [],
timeWeightedInvestment: new Big(0),
timeWeightedInvestmentWithCurrencyEffect: new Big(0),
valueInBaseCurrency: new Big(1820)
};
jest
.spyOn(portfolioCalculatorFactory, 'createCalculator')
.mockReturnValue({
getSnapshot: jest.fn().mockResolvedValue({
activitiesCount: 1,
createdAt: parseDate('2024-01-01'),
currentValueInBaseCurrency: new Big(1820),
errors: [],
hasErrors: false,
historicalData: [],
positions: [usdPosition],
totalFeesWithCurrencyEffect: new Big(0),
totalInterestWithCurrencyEffect: new Big(0),
totalInvestment: new Big(1820),
totalInvestmentWithCurrencyEffect: new Big(1820),
totalLiabilitiesWithCurrencyEffect: new Big(0)
})
} as unknown as ReturnType<
typeof portfolioCalculatorFactory.createCalculator
>);
jest
.spyOn(
portfolioService as unknown as {
getValueOfAccountsAndPlatforms: () => Promise<{
accounts: object;
platforms: object;
}>;
},
'getValueOfAccountsAndPlatforms'
)
.mockResolvedValue({ accounts: {}, platforms: {} });
const { holdings } = await portfolioService.getDetails({
filters: [],
impersonationId: userDummyData.id,
userId: userDummyData.id
});
expect(holdings['USD']).toBeDefined();
expect(holdings['USD'].assetProfile.dataSource).toBe(DataSource.YAHOO);
expect(holdings['USD'].assetProfile.symbol).toBe('USD');
});
});
});

3
apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts

@ -13,7 +13,8 @@ import { countries } from 'countries-list';
export class TrackinsightDataEnhancerService implements DataEnhancerInterface { export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
private static baseUrl = 'https://www.trackinsight.com/data-api'; private static baseUrl = 'https://www.trackinsight.com/data-api';
private static countriesMapping = { private static countriesMapping = {
'Russian Federation': 'Russia' 'Russian Federation': 'Russia',
USA: 'United States'
}; };
private static holdingsWeightTreshold = 0.85; private static holdingsWeightTreshold = 0.85;
private static sectorsMapping = { private static sectorsMapping = {

12
apps/client/src/app/core/auth.guard.ts

@ -3,7 +3,7 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
import { internalRoutes, publicRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes, publicRoutes } from '@ghostfolio/common/routes/routes';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import { import {
ActivatedRouteSnapshot, ActivatedRouteSnapshot,
Router, Router,
@ -14,12 +14,10 @@ import { catchError } from 'rxjs/operators';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AuthGuard { export class AuthGuard {
public constructor( private readonly dataService = inject(DataService);
private dataService: DataService, private readonly router = inject(Router);
private router: Router, private readonly settingsStorageService = inject(SettingsStorageService);
private settingsStorageService: SettingsStorageService, private readonly userService = inject(UserService);
private userService: UserService
) {}
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
const utmSource = route.queryParams?.utm_source; const utmSource = route.queryParams?.utm_source;

16
apps/client/src/app/core/auth.interceptor.ts

@ -13,20 +13,20 @@ import {
HttpInterceptor, HttpInterceptor,
HttpRequest HttpRequest
} from '@angular/common/http'; } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
@Injectable() @Injectable()
export class AuthInterceptor implements HttpInterceptor { export class AuthInterceptor implements HttpInterceptor {
public constructor( private readonly impersonationStorageService = inject(
private impersonationStorageService: ImpersonationStorageService, ImpersonationStorageService
private tokenStorageService: TokenStorageService );
) {} private readonly tokenStorageService = inject(TokenStorageService);
public intercept( public intercept<T>(
req: HttpRequest<any>, req: HttpRequest<T>,
next: HttpHandler next: HttpHandler
): Observable<HttpEvent<any>> { ): Observable<HttpEvent<T>> {
let request = req; let request = req;
if (request.headers.has(HEADER_KEY_SKIP_INTERCEPTOR)) { if (request.headers.has(HEADER_KEY_SKIP_INTERCEPTOR)) {

45
apps/client/src/app/core/http-response.interceptor.ts

@ -12,7 +12,7 @@ import {
HttpInterceptor, HttpInterceptor,
HttpRequest HttpRequest
} from '@angular/common/http'; } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import { import {
MatSnackBar, MatSnackBar,
MatSnackBarRef, MatSnackBarRef,
@ -22,31 +22,28 @@ import { Router } from '@angular/router';
import { StatusCodes } from 'http-status-codes'; import { StatusCodes } from 'http-status-codes';
import ms from 'ms'; import ms from 'ms';
import { Observable, throwError } from 'rxjs'; import { Observable, throwError } from 'rxjs';
import { catchError, tap } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
@Injectable() @Injectable()
export class HttpResponseInterceptor implements HttpInterceptor { export class HttpResponseInterceptor implements HttpInterceptor {
public info: InfoItem; private readonly info: InfoItem;
public snackBarRef: MatSnackBarRef<TextOnlySnackBar>; private snackBarRef: MatSnackBarRef<TextOnlySnackBar> | undefined;
public constructor( private readonly dataService = inject(DataService);
private dataService: DataService, private readonly router = inject(Router);
private router: Router, private readonly snackBar = inject(MatSnackBar);
private snackBar: MatSnackBar, private readonly userService = inject(UserService);
private userService: UserService, private readonly webAuthnService = inject(WebAuthnService);
private webAuthnService: WebAuthnService
) { public constructor() {
this.info = this.dataService.fetchInfo(); this.info = this.dataService.fetchInfo();
} }
public intercept( public intercept<T>(
request: HttpRequest<any>, request: HttpRequest<T>,
next: HttpHandler next: HttpHandler
): Observable<HttpEvent<any>> { ): Observable<HttpEvent<T>> {
return next.handle(request).pipe( return next.handle(request).pipe(
tap((event: HttpEvent<any>) => {
return event;
}),
catchError((error: HttpErrorResponse) => { catchError((error: HttpErrorResponse) => {
if (error.status === StatusCodes.FORBIDDEN) { if (error.status === StatusCodes.FORBIDDEN) {
if (!this.snackBarRef) { if (!this.snackBarRef) {
@ -61,7 +58,7 @@ export class HttpResponseInterceptor implements HttpInterceptor {
} }
); );
} else if ( } else if (
!error.url.includes(internalRoutes.auth.routerLink.join('')) !error.url?.includes(internalRoutes.auth.routerLink.join(''))
) { ) {
this.snackBarRef = this.snackBar.open( this.snackBarRef = this.snackBar.open(
$localize`This action is not allowed.`, $localize`This action is not allowed.`,
@ -72,11 +69,11 @@ export class HttpResponseInterceptor implements HttpInterceptor {
); );
} }
this.snackBarRef.afterDismissed().subscribe(() => { this.snackBarRef?.afterDismissed().subscribe(() => {
this.snackBarRef = undefined; this.snackBarRef = undefined;
}); });
this.snackBarRef.onAction().subscribe(() => { this.snackBarRef?.onAction().subscribe(() => {
this.router.navigate(publicRoutes.pricing.routerLink); this.router.navigate(publicRoutes.pricing.routerLink);
}); });
} }
@ -92,11 +89,11 @@ export class HttpResponseInterceptor implements HttpInterceptor {
} }
); );
this.snackBarRef.afterDismissed().subscribe(() => { this.snackBarRef?.afterDismissed().subscribe(() => {
this.snackBarRef = undefined; this.snackBarRef = undefined;
}); });
this.snackBarRef.onAction().subscribe(() => { this.snackBarRef?.onAction().subscribe(() => {
window.location.reload(); window.location.reload();
}); });
} }
@ -106,12 +103,12 @@ export class HttpResponseInterceptor implements HttpInterceptor {
$localize`Oops! It looks like you’re making too many requests. Please slow down a bit.` $localize`Oops! It looks like you’re making too many requests. Please slow down a bit.`
); );
this.snackBarRef.afterDismissed().subscribe(() => { this.snackBarRef?.afterDismissed().subscribe(() => {
this.snackBarRef = undefined; this.snackBarRef = undefined;
}); });
} }
} else if (error.status === StatusCodes.UNAUTHORIZED) { } else if (error.status === StatusCodes.UNAUTHORIZED) {
if (!error.url.includes('/data-providers/ghostfolio/status')) { if (!error.url?.includes('/data-providers/ghostfolio/status')) {
if (this.webAuthnService.isEnabled()) { if (this.webAuthnService.isEnabled()) {
this.router.navigate(internalRoutes.webauthn.routerLink); this.router.navigate(internalRoutes.webauthn.routerLink);
} else { } else {

24
apps/client/src/app/directives/file-drop/file-drop.directive.ts

@ -1,28 +1,34 @@
import { Directive, EventEmitter, HostListener, Output } from '@angular/core'; import { Directive, output } from '@angular/core';
@Directive({ @Directive({
host: {
'(dragenter)': 'onDragEnter($event)',
'(dragover)': 'onDragOver($event)',
'(drop)': 'onDrop($event)'
},
selector: '[gfFileDrop]' selector: '[gfFileDrop]'
}) })
export class GfFileDropDirective { export class GfFileDropDirective {
@Output() filesDropped = new EventEmitter<FileList>(); public readonly filesDropped = output<FileList>();
@HostListener('dragenter', ['$event']) onDragEnter(event: DragEvent) { public onDragEnter(event: DragEvent) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
} }
@HostListener('dragover', ['$event']) onDragOver(event: DragEvent) { public onDragOver(event: DragEvent) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
} }
@HostListener('drop', ['$event']) onDrop(event: DragEvent) { public onDrop(event: DragEvent) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
// Prevent the browser's default behavior for handling the file drop if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'copy'; // Prevent the browser's default behavior for handling the file drop
event.dataTransfer.dropEffect = 'copy';
this.filesDropped.emit(event.dataTransfer.files); this.filesDropped.emit(event.dataTransfer.files);
}
} }
} }

Loading…
Cancel
Save