Browse Source

Merge branch 'ghostfolio:main' into patch-1

pull/5512/head
Raj Gupta 1 month ago
committed by GitHub
parent
commit
38d713e173
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 43
      CHANGELOG.md
  2. 18
      Dockerfile
  3. 34
      apps/api/src/app/admin/admin.service.ts
  4. 2
      apps/api/src/app/app.module.ts
  5. 4
      apps/api/src/app/endpoints/assets/assets.controller.ts
  6. 29
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts
  7. 4
      apps/api/src/app/endpoints/sitemap/sitemap.controller.ts
  8. 12
      apps/api/src/app/order/order.service.ts
  9. 2
      apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts
  10. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts
  11. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts
  12. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts
  13. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  14. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts
  15. 2
      apps/api/src/app/redis-cache/redis-cache.service.ts
  16. 2
      apps/api/src/app/user/user.service.ts
  17. 324
      apps/api/src/assets/cryptocurrencies/cryptocurrencies.json
  18. 2
      apps/api/src/helper/string.helper.ts
  19. 1
      apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts
  20. 6
      apps/api/src/middlewares/html-template.middleware.ts
  21. 2
      apps/api/src/services/api-key/api-key.service.ts
  22. 85
      apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts
  23. 4
      apps/api/src/services/i18n/i18n.service.ts
  24. 6
      apps/client/src/app/components/header/header.component.ts
  25. 39
      apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.component.ts
  26. 31
      apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.module.ts
  27. 12
      apps/client/src/app/pages/api/api-page.component.ts
  28. 4
      apps/client/src/app/pages/api/api-page.html
  29. 3
      apps/client/src/app/pages/faq/overview/faq-overview-page.html
  30. 20
      apps/client/src/app/pages/public/public-page.html
  31. 33
      apps/client/src/app/pages/register/register-page.component.ts
  32. 34
      apps/client/src/app/pages/register/show-access-token-dialog/show-access-token-dialog.module.ts
  33. 2
      apps/client/src/app/pages/register/user-account-registration-dialog/interfaces/interfaces.ts
  34. 44
      apps/client/src/app/pages/register/user-account-registration-dialog/user-account-registration-dialog.component.ts
  35. 0
      apps/client/src/app/pages/register/user-account-registration-dialog/user-account-registration-dialog.html
  36. 0
      apps/client/src/app/pages/register/user-account-registration-dialog/user-account-registration-dialog.scss
  37. 12
      apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts
  38. 2
      libs/common/src/lib/permissions.ts
  39. 46
      libs/common/src/lib/personal-finance-tools.ts
  40. 1
      libs/ui/src/lib/activities-table/activities-table.component.html
  41. 20
      libs/ui/src/lib/entity-logo/entity-logo-image-source.service.ts
  42. 44
      libs/ui/src/lib/entity-logo/entity-logo.component.stories.ts
  43. 13
      libs/ui/src/lib/entity-logo/entity-logo.component.ts
  44. 24
      libs/ui/src/lib/mocks/entity-logo-image-source.service.mock.ts
  45. 84
      package-lock.json
  46. 11
      package.json
  47. 11
      prisma.config.ts
  48. 17
      prisma/migrations/20250915163323_added_asset_profile_resolution/migration.sql
  49. 14
      prisma/schema.prisma

43
CHANGELOG.md

@ -5,6 +5,49 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Changed
- Renamed the show access token dialog component to user account registration dialog component
- Refreshed the cryptocurrencies list
### Fixed
- Fixed an issue with `unitPriceInAssetProfileCurrency` in the value redaction interceptor for the impersonation mode
## 2.200.0 - 2025-09-17
### Changed
- Refactored the show access token dialog component to standalone
- Upgraded `prisma` from version `6.15.0` to `6.16.1`
### Fixed
- Removed a temporary element from the activities table component
## 2.199.0 - 2025-09-14
### Added
- Extended the content of the performance calculation method by dividends on the Frequently Asked Questions (FAQ) page
- Added a _Storybook_ story for the entity logo image component
### Changed
- Improved the search in the _Yahoo Finance_ service
- Moved the holdings table into the holdings section on the public page
- Migrated to the _Prisma Configuration File_ approach (`prisma.config.ts`)
- Refactored the login with access token dialog component to standalone
- Prefixed the `crypto`, `fs` and `path` imports with `node:`
- Upgraded `yahoo-finance2` from version `3.8.0` to `3.10.0`
### Fixed
- Fixed a pagination issue in the market data endpoint by adding `id` as a secondary sort criterion to ensure consistent ordering in the admin control panel
- Fixed a pagination issue in the user endpoint by adding `id` as a secondary sort criterion to ensure consistent ordering in the admin control panel
## 2.198.0 - 2025-09-11
### Changed

18
Dockerfile

@ -17,7 +17,8 @@ COPY ./CHANGELOG.md CHANGELOG.md
COPY ./LICENSE LICENSE
COPY ./package.json package.json
COPY ./package-lock.json package-lock.json
COPY ./prisma/schema.prisma prisma/schema.prisma
COPY ./prisma.config.ts prisma.config.ts
COPY ./prisma/schema.prisma prisma/
RUN npm install
@ -25,8 +26,8 @@ RUN npm install
COPY ./decorate-angular-cli.js decorate-angular-cli.js
RUN node decorate-angular-cli.js
COPY ./apps apps
COPY ./libs libs
COPY ./apps apps/
COPY ./libs libs/
COPY ./jest.config.ts jest.config.ts
COPY ./jest.preset.js jest.preset.js
COPY ./nx.json nx.json
@ -40,14 +41,15 @@ RUN npm run build:production
WORKDIR /ghostfolio/dist/apps/api
# package.json was generated by the build process, however the original
# package-lock.json needs to be used to ensure the same versions
COPY ./package-lock.json /ghostfolio/dist/apps/api/package-lock.json
COPY ./package-lock.json /ghostfolio/dist/apps/api/
RUN npm install
COPY prisma /ghostfolio/dist/apps/api/prisma
COPY prisma.config.ts /ghostfolio/dist/apps/api/
COPY prisma /ghostfolio/dist/apps/api/prisma/
# Overwrite the generated package.json with the original one to ensure having
# all the scripts
COPY package.json /ghostfolio/dist/apps/api
COPY package.json /ghostfolio/dist/apps/api/
RUN npm run database:generate-typings
# Image to run, copy everything needed from builder
@ -60,8 +62,8 @@ RUN apt-get update && apt-get install -y --no-install-suggests \
openssl \
&& rm -rf /var/lib/apt/lists/*
COPY --chown=node:node --from=builder /ghostfolio/dist/apps /ghostfolio/apps
COPY --chown=node:node ./docker/entrypoint.sh /ghostfolio/entrypoint.sh
COPY --chown=node:node --from=builder /ghostfolio/dist/apps /ghostfolio/apps/
COPY --chown=node:node ./docker/entrypoint.sh /ghostfolio/
WORKDIR /ghostfolio/apps/api
EXPOSE ${PORT:-3333}
USER node

34
apps/api/src/app/admin/admin.service.ts

@ -192,7 +192,7 @@ export class AdminService {
filters,
presetId,
sortColumn,
sortDirection,
sortDirection = 'asc',
skip,
take = Number.MAX_SAFE_INTEGER
}: {
@ -262,11 +262,13 @@ export class AdminService {
orderBy = [{ [sortColumn]: sortDirection }];
if (sortColumn === 'activitiesCount') {
orderBy = {
activities: {
_count: sortDirection
orderBy = [
{
activities: {
_count: sortDirection
}
}
};
];
}
}
@ -275,10 +277,10 @@ export class AdminService {
try {
const symbolProfileResult = await Promise.all([
extendedPrismaClient.symbolProfile.findMany({
orderBy,
skip,
take,
where,
orderBy: [...orderBy, { id: sortDirection }],
select: {
_count: {
select: {
@ -817,17 +819,21 @@ export class AdminService {
skip?: number;
take?: number;
}): Promise<AdminUsers['users']> {
let orderBy: Prisma.UserOrderByWithRelationInput = {
createdAt: 'desc'
};
let orderBy: Prisma.Enumerable<Prisma.UserOrderByWithRelationInput> = [
{ createdAt: 'desc' }
];
let where: Prisma.UserWhereInput;
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
orderBy = {
analytics: {
lastRequestAt: 'desc'
orderBy = [
{
analytics: {
lastRequestAt: 'desc'
}
}
};
];
where = {
NOT: {
analytics: null
@ -836,10 +842,10 @@ export class AdminService {
}
const usersWithAnalytics = await this.prismaService.user.findMany({
orderBy,
skip,
take,
where,
orderBy: [...orderBy, { id: 'desc' }],
select: {
_count: {
select: { accounts: true, activities: true }

2
apps/api/src/app/app.module.ts

@ -21,7 +21,7 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule } from '@nestjs/schedule';
import { ServeStaticModule } from '@nestjs/serve-static';
import { StatusCodes } from 'http-status-codes';
import { join } from 'path';
import { join } from 'node:path';
import { AccessModule } from './access/access.module';
import { AccountModule } from './account/account.module';

4
apps/api/src/app/endpoints/assets/assets.controller.ts

@ -10,8 +10,8 @@ import {
VERSION_NEUTRAL
} from '@nestjs/common';
import { Response } from 'express';
import { readFileSync } from 'fs';
import { join } from 'path';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
@Controller('assets')
export class AssetsController {

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

@ -56,11 +56,36 @@ export class GhostfolioService {
requestTimeout,
symbol
})
.then((assetProfile) => {
.then(async (assetProfile) => {
const dataSourceOrigin = DataSource.GHOSTFOLIO;
if (assetProfile) {
await this.prismaService.assetProfileResolution.upsert({
create: {
dataSourceOrigin,
currency: assetProfile.currency,
dataSourceTarget: assetProfile.dataSource,
symbolOrigin: symbol,
symbolTarget: assetProfile.symbol
},
update: {
requestCount: {
increment: 1
}
},
where: {
dataSourceOrigin_symbolOrigin: {
dataSourceOrigin,
symbolOrigin: symbol
}
}
});
}
result = {
...result,
...assetProfile,
dataSource: DataSource.GHOSTFOLIO
dataSource: dataSourceOrigin
};
return assetProfile;

4
apps/api/src/app/endpoints/sitemap/sitemap.controller.ts

@ -8,8 +8,8 @@ import {
import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common';
import { format } from 'date-fns';
import { Response } from 'express';
import { readFileSync } from 'fs';
import { join } from 'path';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { SitemapService } from './sitemap.service';

12
apps/api/src/app/order/order.service.ts

@ -325,7 +325,7 @@ export class OrderService {
includeDrafts = false,
skip,
sortColumn,
sortDirection,
sortDirection = 'asc',
startDate,
take = Number.MAX_SAFE_INTEGER,
types,
@ -347,9 +347,9 @@ export class OrderService {
withExcludedAccountsAndActivities?: boolean;
}): Promise<Activities> {
let orderBy: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput> = [
{ date: 'asc' },
{ id: 'asc' }
{ date: 'asc' }
];
const where: Prisma.OrderWhereInput = { userId };
if (endDate || startDate) {
@ -483,7 +483,7 @@ export class OrderService {
}
if (sortColumn) {
orderBy = [{ [sortColumn]: sortDirection }, { id: sortDirection }];
orderBy = [{ [sortColumn]: sortDirection }];
}
if (types) {
@ -506,7 +506,6 @@ export class OrderService {
const [orders, count] = await Promise.all([
this.orders({
orderBy,
skip,
take,
where,
@ -519,7 +518,8 @@ export class OrderService {
// eslint-disable-next-line @typescript-eslint/naming-convention
SymbolProfile: true,
tags: true
}
},
orderBy: [...orderBy, { id: sortDirection }]
}),
this.prismaService.order.count({ where })
]);

2
apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts

@ -1,4 +1,4 @@
import { readFileSync } from 'fs';
import { readFileSync } from 'node:fs';
export const activityDummyData = {
accountId: undefined,

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts

@ -20,7 +20,7 @@ import { PerformanceCalculationType } from '@ghostfolio/common/types/performance
import { Tag } from '@prisma/client';
import { Big } from 'big.js';
import { join } from 'path';
import { join } from 'node:path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts

@ -20,7 +20,7 @@ import { PerformanceCalculationType } from '@ghostfolio/common/types/performance
import { Tag } from '@prisma/client';
import { Big } from 'big.js';
import { join } from 'path';
import { join } from 'node:path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts

@ -20,7 +20,7 @@ import { PerformanceCalculationType } from '@ghostfolio/common/types/performance
import { Tag } from '@prisma/client';
import { Big } from 'big.js';
import { join } from 'path';
import { join } from 'node:path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts

@ -20,7 +20,7 @@ import { PerformanceCalculationType } from '@ghostfolio/common/types/performance
import { Tag } from '@prisma/client';
import { Big } from 'big.js';
import { join } from 'path';
import { join } from 'node:path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts

@ -20,7 +20,7 @@ import { PerformanceCalculationType } from '@ghostfolio/common/types/performance
import { Tag } from '@prisma/client';
import { Big } from 'big.js';
import { join } from 'path';
import { join } from 'node:path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {

2
apps/api/src/app/redis-cache/redis-cache.service.ts

@ -4,9 +4,9 @@ import { AssetProfileIdentifier, Filter } from '@ghostfolio/common/interfaces';
import { CACHE_MANAGER, Cache } from '@nestjs/cache-manager';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { createHash } from 'crypto';
import Keyv from 'keyv';
import ms from 'ms';
import { createHash } from 'node:crypto';
@Injectable()
export class RedisCacheService {

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

@ -48,9 +48,9 @@ import { PerformanceCalculationType } from '@ghostfolio/common/types/performance
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Prisma, Role, User } from '@prisma/client';
import { createHmac } from 'crypto';
import { differenceInDays, subDays } from 'date-fns';
import { sortBy, without } from 'lodash';
import { createHmac } from 'node:crypto';
@Injectable()
export class UserService {

324
apps/api/src/assets/cryptocurrencies/cryptocurrencies.json

File diff suppressed because it is too large

2
apps/api/src/helper/string.helper.ts

@ -1,4 +1,4 @@
import { randomBytes } from 'crypto';
import { randomBytes } from 'node:crypto';
export function getRandomString(length: number) {
const bytes = randomBytes(length);

1
apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts

@ -61,6 +61,7 @@ export class RedactValuesInResponseInterceptor<T>
'totalInterestInBaseCurrency',
'totalValueInBaseCurrency',
'unitPrice',
'unitPriceInAssetProfileCurrency',
'value',
'valueInBaseCurrency'
].map((attribute) => {

6
apps/api/src/middlewares/html-template.middleware.ts

@ -10,8 +10,8 @@ import { DATE_FORMAT, interpolate } from '@ghostfolio/common/helper';
import { Injectable, Logger, NestMiddleware } from '@nestjs/common';
import { format } from 'date-fns';
import { NextFunction, Request, Response } from 'express';
import * as fs from 'fs';
import { join } from 'path';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
const title = 'Ghostfolio';
@ -87,7 +87,7 @@ export class HtmlTemplateMiddleware implements NestMiddleware {
this.indexHtmlMap = SUPPORTED_LANGUAGE_CODES.reduce(
(map, languageCode) => ({
...map,
[languageCode]: fs.readFileSync(
[languageCode]: readFileSync(
join(__dirname, '..', 'client', languageCode, 'index.html'),
'utf8'
)

2
apps/api/src/services/api-key/api-key.service.ts

@ -3,7 +3,7 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { ApiKeyResponse } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { pbkdf2Sync } from 'crypto';
import { pbkdf2Sync } from 'node:crypto';
@Injectable()
export class ApiKeyService {

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

@ -24,6 +24,7 @@ import {
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
import { addDays, format, isSameDay } from 'date-fns';
import { uniqBy } from 'lodash';
import YahooFinance from 'yahoo-finance2';
import { ChartResultArray } from 'yahoo-finance2/esm/src/modules/chart';
import {
@ -34,6 +35,10 @@ import {
Quote,
QuoteResponseArray
} from 'yahoo-finance2/esm/src/modules/quote';
import {
Price,
QuoteSummaryResult
} from 'yahoo-finance2/esm/src/modules/quoteSummary';
import { SearchQuoteNonYahoo } from 'yahoo-finance2/esm/src/modules/search';
@Injectable()
@ -190,10 +195,7 @@ export class YahooFinanceService implements DataProviderInterface {
);
try {
let quotes: Pick<
Quote,
'currency' | 'marketState' | 'regularMarketPrice' | 'symbol'
>[] = [];
let quotes: Price[] | Quote[] = [];
try {
quotes = await this.yahooFinance.quote(yahooFinanceSymbols);
@ -290,7 +292,9 @@ export class YahooFinanceService implements DataProviderInterface {
try {
marketData = await this.yahooFinance.quote(
quotes.map(({ symbol }) => {
uniqBy(quotes, ({ symbol }) => {
return symbol;
}).map(({ symbol }) => {
return symbol;
})
);
@ -300,35 +304,35 @@ export class YahooFinanceService implements DataProviderInterface {
}
}
for (const marketDataItem of marketData) {
const quote = quotes.find((currentQuote) => {
return currentQuote.symbol === marketDataItem.symbol;
});
const symbol =
this.yahooFinanceDataEnhancerService.convertFromYahooFinanceSymbol(
marketDataItem.symbol
);
for (const {
currency,
longName,
quoteType,
shortName,
symbol
} of marketData) {
const { assetClass, assetSubClass } =
this.yahooFinanceDataEnhancerService.parseAssetClass({
quoteType: quote.quoteType,
shortName: quote.shortname
quoteType,
shortName
});
items.push({
assetClass,
assetSubClass,
symbol,
currency: marketDataItem.currency,
currency,
dataProviderInfo: this.getDataProviderInfo(),
dataSource: this.getName(),
name: this.yahooFinanceDataEnhancerService.formatName({
longName: quote.longname,
quoteType: quote.quoteType,
shortName: quote.shortname,
symbol: quote.symbol
})
longName,
quoteType,
shortName,
symbol
}),
symbol:
this.yahooFinanceDataEnhancerService.convertFromYahooFinanceSymbol(
symbol
)
});
}
} catch (error) {
@ -354,23 +358,28 @@ export class YahooFinanceService implements DataProviderInterface {
private async getQuotesWithQuoteSummary(aYahooFinanceSymbols: string[]) {
const quoteSummaryPromises = aYahooFinanceSymbols.map((symbol) => {
return this.yahooFinance.quoteSummary(symbol).catch(() => {
Logger.error(
`Could not get quote summary for ${symbol}`,
'YahooFinanceService'
);
return null;
});
return this.yahooFinance.quoteSummary(symbol);
});
const quoteSummaryItems = await Promise.all(quoteSummaryPromises);
const settledResults = await Promise.allSettled(quoteSummaryPromises);
return settledResults
.filter(
(result): result is PromiseFulfilledResult<QuoteSummaryResult> => {
if (result.status === 'rejected') {
Logger.error(
`Could not get quote summary: ${result.reason}`,
'YahooFinanceService'
);
return quoteSummaryItems
.filter((item) => {
return item !== null;
})
.map(({ price }) => {
return price;
return false;
}
return true;
}
)
.map(({ value }) => {
return value.price;
});
}
}

4
apps/api/src/services/i18n/i18n.service.ts

@ -2,8 +2,8 @@ import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { Injectable, Logger } from '@nestjs/common';
import * as cheerio from 'cheerio';
import { readFileSync, readdirSync } from 'fs';
import { join } from 'path';
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
@Injectable()
export class I18nService {

6
apps/client/src/app/components/header/header.component.ts

@ -1,6 +1,5 @@
import { UpdateUserSettingDto } from '@ghostfolio/api/app/user/update-user-setting.dto';
import { LoginWithAccessTokenDialog } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.component';
import { LoginWithAccessTokenDialogModule } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.module';
import { GfLoginWithAccessTokenDialogComponent } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.component';
import { LayoutService } from '@ghostfolio/client/core/layout.service';
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { DataService } from '@ghostfolio/client/services/data.service';
@ -59,7 +58,6 @@ import { catchError, takeUntil } from 'rxjs/operators';
GfLogoComponent,
GfPremiumIndicatorComponent,
IonIcon,
LoginWithAccessTokenDialogModule,
MatBadgeModule,
MatButtonModule,
MatMenuModule,
@ -273,7 +271,7 @@ export class GfHeaderComponent implements OnChanges {
}
public openLoginDialog() {
const dialogRef = this.dialog.open(LoginWithAccessTokenDialog, {
const dialogRef = this.dialog.open(GfLoginWithAccessTokenDialogComponent, {
autoFocus: false,
data: {
accessToken: '',

39
apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.component.ts

@ -1,3 +1,4 @@
import { GfDialogHeaderComponent } from '@ghostfolio/client/components/dialog-header/dialog-header.component';
import { InternetIdentityService } from '@ghostfolio/client/services/internet-identity.service';
import {
KEY_STAY_SIGNED_IN,
@ -5,22 +6,44 @@ import {
} from '@ghostfolio/client/services/settings-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { FormControl, Validators } from '@angular/forms';
import { MatCheckboxChange } from '@angular/material/checkbox';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import {
MatCheckboxChange,
MatCheckboxModule
} from '@angular/material/checkbox';
import {
MAT_DIALOG_DATA,
MatDialogModule,
MatDialogRef
} from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { Router } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons';
import { eyeOffOutline, eyeOutline } from 'ionicons/icons';
@Component({
selector: 'gf-login-with-access-token-dialog',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
GfDialogHeaderComponent,
IonIcon,
MatButtonModule,
MatCheckboxModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule
],
selector: 'gf-login-with-access-token-dialog',
styleUrls: ['./login-with-access-token-dialog.scss'],
templateUrl: 'login-with-access-token-dialog.html',
standalone: false
templateUrl: './login-with-access-token-dialog.html'
})
export class LoginWithAccessTokenDialog {
export class GfLoginWithAccessTokenDialogComponent {
public accessTokenFormControl = new FormControl(
this.data.accessToken,
Validators.required
@ -29,7 +52,7 @@ export class LoginWithAccessTokenDialog {
public constructor(
@Inject(MAT_DIALOG_DATA) public data: any,
public dialogRef: MatDialogRef<LoginWithAccessTokenDialog>,
public dialogRef: MatDialogRef<GfLoginWithAccessTokenDialogComponent>,
private internetIdentityService: InternetIdentityService,
private router: Router,
private settingsStorageService: SettingsStorageService,

31
apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.module.ts

@ -1,31 +0,0 @@
import { TextFieldModule } from '@angular/cdk/text-field';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { IonIcon } from '@ionic/angular/standalone';
import { GfDialogHeaderComponent } from '../dialog-header/dialog-header.component';
import { LoginWithAccessTokenDialog } from './login-with-access-token-dialog.component';
@NgModule({
declarations: [LoginWithAccessTokenDialog],
imports: [
CommonModule,
GfDialogHeaderComponent,
IonIcon,
MatButtonModule,
MatCheckboxModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule,
TextFieldModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class LoginWithAccessTokenDialogModule {}

12
apps/client/src/app/pages/api/api-page.component.ts

@ -4,6 +4,7 @@ import {
} from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import {
DataProviderGhostfolioAssetProfileResponse,
DataProviderGhostfolioStatusResponse,
DividendsResponse,
HistoricalResponse,
@ -25,6 +26,7 @@ import { map, Observable, Subject, takeUntil } from 'rxjs';
templateUrl: './api-page.html'
})
export class GfApiPageComponent implements OnInit {
public assetProfile$: Observable<DataProviderGhostfolioAssetProfileResponse>;
public dividends$: Observable<DividendsResponse['dividends']>;
public historicalData$: Observable<HistoricalResponse['historicalData']>;
public isinLookupItems$: Observable<LookupResponse['items']>;
@ -40,6 +42,7 @@ export class GfApiPageComponent implements OnInit {
public ngOnInit() {
this.apiKey = prompt($localize`Please enter your Ghostfolio API key:`);
this.assetProfile$ = this.fetchAssetProfile({ symbol: 'AAPL' });
this.dividends$ = this.fetchDividends({ symbol: 'KO' });
this.historicalData$ = this.fetchHistoricalData({ symbol: 'AAPL' });
this.isinLookupItems$ = this.fetchLookupItems({ query: 'US0378331005' });
@ -53,6 +56,15 @@ export class GfApiPageComponent implements OnInit {
this.unsubscribeSubject.complete();
}
private fetchAssetProfile({ symbol }: { symbol: string }) {
return this.http
.get<DataProviderGhostfolioAssetProfileResponse>(
`/api/v1/data-providers/ghostfolio/asset-profile/${symbol}`,
{ headers: this.getHeaders() }
)
.pipe(takeUntil(this.unsubscribeSubject));
}
private fetchDividends({ symbol }: { symbol: string }) {
const params = new HttpParams()
.set('from', format(startOfYear(new Date()), DATE_FORMAT))

4
apps/client/src/app/pages/api/api-page.html

@ -3,6 +3,10 @@
<h2 class="text-center">Status</h2>
<div>{{ status$ | async | json }}</div>
</div>
<div class="mb-3">
<h2 class="text-center">Asset Profile</h2>
<div>{{ assetProfile$ | async | json }}</div>
</div>
<div>
<h2 class="text-center">Lookup</h2>
@if (lookupItems$) {

3
apps/client/src/app/pages/faq/overview/faq-overview-page.html

@ -52,7 +52,8 @@
calculation method based on the average amount of capital invested
over time. ROAI aims to provide a more insightful view of investment
performance than simpler approaches, especially when contributions are
made over time.</mat-card-content
made over time. Dividends are not part of the
calculation.</mat-card-content
>
</mat-card>
<mat-card appearance="outlined" class="mb-3">

20
apps/client/src/app/pages/public/public-page.html

@ -77,6 +77,14 @@
[keys]="['symbol']"
[showLabels]="deviceType !== 'mobile'"
/>
<gf-holdings-table
[deviceType]="deviceType"
[hasPermissionToOpenDetails]="false"
[hasPermissionToShowQuantities]="false"
[hasPermissionToShowValues]="false"
[holdings]="holdings"
[pageSize]="7"
/>
</mat-card-content>
</mat-card>
</div>
@ -195,18 +203,6 @@
</div>
</div>
}
<div class="row">
<div class="col-lg">
<gf-holdings-table
[deviceType]="deviceType"
[hasPermissionToOpenDetails]="false"
[hasPermissionToShowQuantities]="false"
[hasPermissionToShowValues]="false"
[holdings]="holdings"
[pageSize]="7"
/>
</div>
</div>
<div class="row my-5">
<div class="col-md-10 offset-md-1">
<h2 class="h4 mb-1 text-center" i18n>

33
apps/client/src/app/pages/register/register-page.component.ts

@ -18,18 +18,12 @@ import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { ShowAccessTokenDialogParams } from './show-access-token-dialog/interfaces/interfaces';
import { ShowAccessTokenDialog } from './show-access-token-dialog/show-access-token-dialog.component';
import { ShowAccessTokenDialogModule } from './show-access-token-dialog/show-access-token-dialog.module';
import { UserAccountRegistrationDialogParams } from './user-account-registration-dialog/interfaces/interfaces';
import { GfUserAccountRegistrationDialogComponent } from './user-account-registration-dialog/user-account-registration-dialog.component';
@Component({
host: { class: 'page' },
imports: [
GfLogoComponent,
MatButtonModule,
RouterModule,
ShowAccessTokenDialogModule
],
imports: [GfLogoComponent, MatButtonModule, RouterModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-register-page',
styleUrls: ['./register-page.scss'],
@ -90,15 +84,18 @@ export class GfRegisterPageComponent implements OnDestroy, OnInit {
}
public openShowAccessTokenDialog() {
const dialogRef = this.dialog.open(ShowAccessTokenDialog, {
data: {
deviceType: this.deviceType,
needsToAcceptTermsOfService: this.hasPermissionForSubscription
} as ShowAccessTokenDialogParams,
disableClose: true,
height: this.deviceType === 'mobile' ? '98vh' : undefined,
width: this.deviceType === 'mobile' ? '100vw' : '30rem'
});
const dialogRef = this.dialog.open(
GfUserAccountRegistrationDialogComponent,
{
data: {
deviceType: this.deviceType,
needsToAcceptTermsOfService: this.hasPermissionForSubscription
} as UserAccountRegistrationDialogParams,
disableClose: true,
height: this.deviceType === 'mobile' ? '98vh' : undefined,
width: this.deviceType === 'mobile' ? '100vw' : '30rem'
}
);
dialogRef
.afterClosed()

34
apps/client/src/app/pages/register/show-access-token-dialog/show-access-token-dialog.module.ts

@ -1,34 +0,0 @@
import { ClipboardModule } from '@angular/cdk/clipboard';
import { TextFieldModule } from '@angular/cdk/text-field';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatStepperModule } from '@angular/material/stepper';
import { RouterModule } from '@angular/router';
import { ShowAccessTokenDialog } from './show-access-token-dialog.component';
@NgModule({
declarations: [ShowAccessTokenDialog],
imports: [
ClipboardModule,
CommonModule,
FormsModule,
MatButtonModule,
MatCheckboxModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatStepperModule,
ReactiveFormsModule,
RouterModule,
TextFieldModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class ShowAccessTokenDialogModule {}

2
apps/client/src/app/pages/register/show-access-token-dialog/interfaces/interfaces.ts → apps/client/src/app/pages/register/user-account-registration-dialog/interfaces/interfaces.ts

@ -1,4 +1,4 @@
export interface ShowAccessTokenDialogParams {
export interface UserAccountRegistrationDialogParams {
deviceType: string;
needsToAcceptTermsOfService: boolean;
}

44
apps/client/src/app/pages/register/show-access-token-dialog/show-access-token-dialog.component.ts → apps/client/src/app/pages/register/user-account-registration-dialog/user-account-registration-dialog.component.ts

@ -1,15 +1,26 @@
import { DataService } from '@ghostfolio/client/services/data.service';
import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { ClipboardModule } from '@angular/cdk/clipboard';
import { TextFieldModule } from '@angular/cdk/text-field';
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
CUSTOM_ELEMENTS_SCHEMA,
Inject,
ViewChild
} from '@angular/core';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatStepper } from '@angular/material/stepper';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatStepper, MatStepperModule } from '@angular/material/stepper';
import { RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons';
import {
arrowForwardOutline,
@ -19,16 +30,31 @@ import {
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { ShowAccessTokenDialogParams } from './interfaces/interfaces';
import { UserAccountRegistrationDialogParams } from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-show-access-token-dialog',
standalone: false,
styleUrls: ['./show-access-token-dialog.scss'],
templateUrl: 'show-access-token-dialog.html'
imports: [
ClipboardModule,
CommonModule,
FormsModule,
IonIcon,
MatButtonModule,
MatCheckboxModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatStepperModule,
ReactiveFormsModule,
RouterModule,
TextFieldModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-user-account-registration-dialog',
styleUrls: ['./user-account-registration-dialog.scss'],
templateUrl: 'user-account-registration-dialog.html'
})
export class ShowAccessTokenDialog {
export class GfUserAccountRegistrationDialogComponent {
@ViewChild(MatStepper) stepper!: MatStepper;
public accessToken: string;
@ -43,7 +69,7 @@ export class ShowAccessTokenDialog {
public constructor(
private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: ShowAccessTokenDialogParams,
@Inject(MAT_DIALOG_DATA) public data: UserAccountRegistrationDialogParams,
private dataService: DataService
) {
addIcons({ arrowForwardOutline, checkmarkOutline, copyOutline });

0
apps/client/src/app/pages/register/show-access-token-dialog/show-access-token-dialog.html → apps/client/src/app/pages/register/user-account-registration-dialog/user-account-registration-dialog.html

0
apps/client/src/app/pages/register/show-access-token-dialog/show-access-token-dialog.scss → apps/client/src/app/pages/register/user-account-registration-dialog/user-account-registration-dialog.scss

12
apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts

@ -76,7 +76,9 @@ export class GfProductPageComponent implements OnInit {
this.tags = [
this.product1.name,
this.product1.origin,
this.product2.name,
this.product2.origin,
$localize`Alternative`,
$localize`App`,
$localize`Budgeting`,
@ -96,8 +98,12 @@ export class GfProductPageComponent implements OnInit {
$localize`Wealth`,
$localize`Wealth Management`,
`WealthTech`
].sort((a, b) => {
return a.localeCompare(b, undefined, { sensitivity: 'base' });
});
]
.filter((item) => {
return !!item;
})
.sort((a, b) => {
return a.localeCompare(b, undefined, { sensitivity: 'base' });
});
}
}

2
libs/common/src/lib/permissions.ts

@ -197,5 +197,5 @@ export function hasRole(aUser: UserWithSettings, aRole: Role) {
}
export function isRestrictedView(aUser: UserWithSettings) {
return aUser.settings.settings.isRestrictedView ?? false;
return aUser?.settings?.settings?.isRestrictedView ?? false;
}

46
libs/common/src/lib/personal-finance-tools.ts

@ -33,6 +33,16 @@ export const personalFinanceTools: Product[] = [
origin: 'Switzerland',
slogan: 'Simplicity for Complex Wealth'
},
{
founded: 2023,
hasFreePlan: false,
hasSelfHostingAbility: false,
key: 'amsflow',
name: 'Amsflow Portfolio',
origin: 'Singapore',
pricingPerYear: '$228',
slogan: 'Portfolio Visualizer'
},
{
founded: 2018,
hasFreePlan: true,
@ -97,6 +107,12 @@ export const personalFinanceTools: Product[] = [
pricingPerYear: '$100',
slogan: 'Stock Portfolio Tracker for Smart Investors'
},
{
key: 'budgetpulse',
name: 'BudgetPulse',
origin: 'United States',
slogan: 'Giving life to your finance!'
},
{
founded: 2007,
hasFreePlan: false,
@ -180,6 +196,15 @@ export const personalFinanceTools: Product[] = [
pricingPerYear: '$95',
slogan: 'Do money better with Copilot'
},
{
founded: 2014,
hasFreePlan: false,
key: 'countabout',
name: 'CountAbout',
origin: 'United States',
pricingPerYear: '$9.99',
slogan: 'Customizable and Secure Personal Finance App'
},
{
founded: 2023,
hasFreePlan: false,
@ -427,6 +452,14 @@ export const personalFinanceTools: Product[] = [
slogan: 'Die All-in-One Lösung für dein Vermögen.',
useAnonymously: true
},
{
founded: 2017,
hasSelfHostingAbility: false,
key: 'honeydue',
name: 'Honeydue',
origin: 'United States',
slogan: 'Finance App for Couples'
},
{
founded: 2022,
key: 'income-reign',
@ -608,6 +641,13 @@ export const personalFinanceTools: Product[] = [
origin: 'Germany',
slogan: 'Dein smarter Finance Assistant'
},
{
key: 'moneywiz',
name: 'MoneyWiz',
origin: 'United States',
pricingPerYear: '$29.99',
slogan: 'Get money management superpowers'
},
{
hasFreePlan: false,
hasSelfHostingAbility: false,
@ -859,6 +899,12 @@ export const personalFinanceTools: Product[] = [
pricingPerYear: '$80',
slogan: 'Simple and powerful portfolio tracker'
},
{
key: 'splashmoney',
name: 'SplashMoney',
origin: 'United States',
slogan: 'Manage your money anytime, anywhere.'
},
{
founded: 2019,
hasSelfHostingAbility: false,

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

@ -129,7 +129,6 @@
[symbol]="element.SymbolProfile?.symbol"
[tooltip]="element.SymbolProfile?.name"
/>
<div>{{ element.dataSource }}</div>
</td>
</ng-container>

20
libs/ui/src/lib/entity-logo/entity-logo-image-source.service.ts

@ -0,0 +1,20 @@
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { Injectable } from '@angular/core';
@Injectable({
// Required to allow mocking in Storybook
providedIn: 'root'
})
export class EntityLogoImageSourceService {
public getLogoUrlByAssetProfileIdentifier({
dataSource,
symbol
}: AssetProfileIdentifier) {
return `../api/v1/logo/${dataSource}/${symbol}`;
}
public getLogoUrlByUrl(url: string) {
return `../api/v1/logo?url=${url}`;
}
}

44
libs/ui/src/lib/entity-logo/entity-logo.component.stories.ts

@ -0,0 +1,44 @@
import { CommonModule } from '@angular/common';
import { importProvidersFrom } from '@angular/core';
import { provideNoopAnimations } from '@angular/platform-browser/animations';
import { applicationConfig, Meta, StoryObj } from '@storybook/angular';
import { EntityLogoImageSourceServiceMock } from '../mocks/entity-logo-image-source.service.mock';
import { EntityLogoImageSourceService } from './entity-logo-image-source.service';
import { GfEntityLogoComponent } from './entity-logo.component';
export default {
title: 'Entity Logo',
component: GfEntityLogoComponent,
decorators: [
applicationConfig({
providers: [
provideNoopAnimations(),
importProvidersFrom(CommonModule),
{
provide: EntityLogoImageSourceService,
useValue: new EntityLogoImageSourceServiceMock()
}
]
})
]
} as Meta<GfEntityLogoComponent>;
type Story = StoryObj<GfEntityLogoComponent>;
export const LogoByAssetProfileIdentifier: Story = {
args: {
dataSource: 'YAHOO',
size: 'large',
symbol: 'AAPL',
tooltip: 'Apple Inc.'
}
};
export const LogoByUrl: Story = {
args: {
size: 'large',
tooltip: 'Ghostfolio',
url: 'https://ghostfol.io'
}
};

13
libs/ui/src/lib/entity-logo/entity-logo.component.ts

@ -1,3 +1,5 @@
import { EntityLogoImageSourceService } from '@ghostfolio/ui/entity-logo/entity-logo-image-source.service';
import { CommonModule } from '@angular/common';
import {
CUSTOM_ELEMENTS_SCHEMA,
@ -25,11 +27,18 @@ export class GfEntityLogoComponent implements OnChanges {
public src: string;
public constructor(
private readonly imageSourceService: EntityLogoImageSourceService
) {}
public ngOnChanges() {
if (this.dataSource && this.symbol) {
this.src = `../api/v1/logo/${this.dataSource}/${this.symbol}`;
this.src = this.imageSourceService.getLogoUrlByAssetProfileIdentifier({
dataSource: this.dataSource,
symbol: this.symbol
});
} else if (this.url) {
this.src = `../api/v1/logo?url=${this.url}`;
this.src = this.imageSourceService.getLogoUrlByUrl(this.url);
}
}
}

24
libs/ui/src/lib/mocks/entity-logo-image-source.service.mock.ts

@ -0,0 +1,24 @@
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { DataSource } from '@prisma/client';
export class EntityLogoImageSourceServiceMock {
public getLogoUrlByAssetProfileIdentifier({
dataSource,
symbol
}: AssetProfileIdentifier) {
if (dataSource === DataSource.YAHOO && symbol === 'AAPL') {
return 'data:image/png;base64,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';
}
return '';
}
public getLogoUrlByUrl(url: string) {
if (url === 'https://ghostfol.io') {
return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAMAAACdt4HsAAAAP1BMVEU2z8v////x/Pspzcn0/Px73drR8/KC3tz6/v6e5eOl5+WM4d7n+PhI08/i9/eu6edV1dJk2NVx2teV4+G87evvttLSAAABDElEQVRYhe2V3Y6EIAyFrQXlTwHd93/WpeBsdiKbtF7tJJwbCKFfDlKO0zQ0NPQJwqrn1ZO2zlk9PWPgusClRcsJmHb4pWUTItDDu4zMBJ5w0yog4HGvB0gCgOoBZrYFdL16AMsmmD5AcQ3ofj3AwbOAX38BIhMw02ZjtQ+tLnht66mCAGA2eka1G3eabUZwD/PLbeuHenKMBODVV0Dru/TTQLgKAc1BvQ/9yAEkOnlon66oehEBIHp7dbSyPoIc0NEADMDnAa4wAvVK+CADRGx/VpNSi+iF3jMTUH4rJQ0aISPmVk+JoJiZeJw1QnaqL2OmWKSFkxnrZWsbXK4TzO5tmS+8TYaGhv6xvgEEfAgHGc7HRgAAAABJRU5ErkJggg==';
}
return '';
}
}

84
package-lock.json

@ -1,12 +1,12 @@
{
"name": "ghostfolio",
"version": "2.198.0",
"version": "2.200.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ghostfolio",
"version": "2.198.0",
"version": "2.200.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
@ -44,7 +44,7 @@
"@nestjs/schedule": "6.0.0",
"@nestjs/serve-static": "5.0.3",
"@openrouter/ai-sdk-provider": "0.7.2",
"@prisma/client": "6.15.0",
"@prisma/client": "6.16.1",
"@simplewebauthn/browser": "13.1.0",
"@simplewebauthn/server": "13.1.1",
"@stripe/stripe-js": "7.9.0",
@ -93,7 +93,7 @@
"svgmap": "2.12.2",
"twitter-api-v2": "1.23.0",
"uuid": "11.1.0",
"yahoo-finance2": "3.8.0",
"yahoo-finance2": "3.10.0",
"zone.js": "0.15.1"
},
"devDependencies": {
@ -149,7 +149,7 @@
"nx": "21.5.1",
"prettier": "3.6.2",
"prettier-plugin-organize-attributes": "1.0.0",
"prisma": "6.15.0",
"prisma": "6.16.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"replace-in-file": "8.3.0",
@ -11960,9 +11960,9 @@
"license": "MIT"
},
"node_modules/@prisma/client": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.15.0.tgz",
"integrity": "sha512-wR2LXUbOH4cL/WToatI/Y2c7uzni76oNFND7+23ypLllBmIS8e3ZHhO+nud9iXSXKFt1SoM3fTZvHawg63emZw==",
"version": "6.16.1",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.16.1.tgz",
"integrity": "sha512-QaBCOY29lLAxEFFJgBPyW3WInCW52fJeQTmWx/h6YsP5u0bwuqP51aP0uhqFvhK9DaZPwvai/M4tSDYLVE9vRg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"engines": {
@ -11982,9 +11982,9 @@
}
},
"node_modules/@prisma/config": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.15.0.tgz",
"integrity": "sha512-KMEoec9b2u6zX0EbSEx/dRpx1oNLjqJEBZYyK0S3TTIbZ7GEGoVyGyFRk4C72+A38cuPLbfQGQvgOD+gBErKlA==",
"version": "6.16.1",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.16.1.tgz",
"integrity": "sha512-sz3uxRPNL62QrJ0EYiujCFkIGZ3hg+9hgC1Ae1HjoYuj0BxCqHua4JNijYvYCrh9LlofZDZcRBX3tHBfLvAngA==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
@ -11995,53 +11995,53 @@
}
},
"node_modules/@prisma/debug": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.15.0.tgz",
"integrity": "sha512-y7cSeLuQmyt+A3hstAs6tsuAiVXSnw9T55ra77z0nbNkA8Lcq9rNcQg6PI00by/+WnE/aMRJ/W7sZWn2cgIy1g==",
"version": "6.16.1",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.16.1.tgz",
"integrity": "sha512-RWv/VisW5vJE4cDRTuAHeVedtGoItXTnhuLHsSlJ9202QKz60uiXWywBlVcqXVq8bFeIZoCoWH+R1duZJPwqLw==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/engines": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.15.0.tgz",
"integrity": "sha512-opITiR5ddFJ1N2iqa7mkRlohCZqVSsHhRcc29QXeldMljOf4FSellLT0J5goVb64EzRTKcIDeIsJBgmilNcKxA==",
"version": "6.16.1",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.16.1.tgz",
"integrity": "sha512-EOnEM5HlosPudBqbI+jipmaW/vQEaF0bKBo4gVkGabasINHR6RpC6h44fKZEqx4GD8CvH+einD2+b49DQrwrAg==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.15.0",
"@prisma/engines-version": "6.15.0-5.85179d7826409ee107a6ba334b5e305ae3fba9fb",
"@prisma/fetch-engine": "6.15.0",
"@prisma/get-platform": "6.15.0"
"@prisma/debug": "6.16.1",
"@prisma/engines-version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43",
"@prisma/fetch-engine": "6.16.1",
"@prisma/get-platform": "6.16.1"
}
},
"node_modules/@prisma/engines-version": {
"version": "6.15.0-5.85179d7826409ee107a6ba334b5e305ae3fba9fb",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.15.0-5.85179d7826409ee107a6ba334b5e305ae3fba9fb.tgz",
"integrity": "sha512-a/46aK5j6L3ePwilZYEgYDPrhBQ/n4gYjLxT5YncUTJJNRnTCVjPF86QdzUOLRdYjCLfhtZp9aum90W0J+trrg==",
"version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43.tgz",
"integrity": "sha512-ThvlDaKIVrnrv97ujNFDYiQbeMQpLa0O86HFA2mNoip4mtFqM7U5GSz2ie1i2xByZtvPztJlNRgPsXGeM/kqAA==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.15.0.tgz",
"integrity": "sha512-xcT5f6b+OWBq6vTUnRCc7qL+Im570CtwvgSj+0MTSGA1o9UDSKZ/WANvwtiRXdbYWECpyC3CukoG3A04VTAPHw==",
"version": "6.16.1",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.16.1.tgz",
"integrity": "sha512-fl/PKQ8da5YTayw86WD3O9OmKJEM43gD3vANy2hS5S1CnfW2oPXk+Q03+gUWqcKK306QqhjjIHRFuTZ31WaosQ==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.15.0",
"@prisma/engines-version": "6.15.0-5.85179d7826409ee107a6ba334b5e305ae3fba9fb",
"@prisma/get-platform": "6.15.0"
"@prisma/debug": "6.16.1",
"@prisma/engines-version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43",
"@prisma/get-platform": "6.16.1"
}
},
"node_modules/@prisma/get-platform": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.15.0.tgz",
"integrity": "sha512-Jbb+Xbxyp05NSR1x2epabetHiXvpO8tdN2YNoWoA/ZsbYyxxu/CO/ROBauIFuMXs3Ti+W7N7SJtWsHGaWte9Rg==",
"version": "6.16.1",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.16.1.tgz",
"integrity": "sha512-kUfg4vagBG7dnaGRcGd1c0ytQFcDj2SUABiuveIpL3bthFdTLI6PJeLEia6Q8Dgh+WhPdo0N2q0Fzjk63XTyaA==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.15.0"
"@prisma/debug": "6.16.1"
}
},
"node_modules/@redis/client": {
@ -35747,15 +35747,15 @@
}
},
"node_modules/prisma": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.15.0.tgz",
"integrity": "sha512-E6RCgOt+kUVtjtZgLQDBJ6md2tDItLJNExwI0XJeBc1FKL+Vwb+ovxXxuok9r8oBgsOXBA33fGDuE/0qDdCWqQ==",
"version": "6.16.1",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.16.1.tgz",
"integrity": "sha512-MFkMU0eaDDKAT4R/By2IA9oQmwLTxokqv2wegAErr9Rf+oIe7W2sYpE/Uxq0H2DliIR7vnV63PkC1bEwUtl98w==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/config": "6.15.0",
"@prisma/engines": "6.15.0"
"@prisma/config": "6.16.1",
"@prisma/engines": "6.16.1"
},
"bin": {
"prisma": "build/index.js"
@ -42071,9 +42071,9 @@
}
},
"node_modules/yahoo-finance2": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/yahoo-finance2/-/yahoo-finance2-3.8.0.tgz",
"integrity": "sha512-em11JOlfSg23wevm4kXs1+A/CoSWD9eg7/hKRU3zKWuPknCfE4NkIhGVb601Nokid+KPE8Q0eoXK4qgLsMIjKA==",
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/yahoo-finance2/-/yahoo-finance2-3.10.0.tgz",
"integrity": "sha512-0mnvefEAapMS6M3tnqLmQlyE2W38AQqByaTS09l2dawLaVU7NNc0hJ4qI4F3qi3C7MU+ZWAb8DFVKpW6Zsj0Nw==",
"license": "MIT",
"dependencies": {
"@deno/shim-deno": "~0.18.0",

11
package.json

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "2.198.0",
"version": "2.200.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio",
@ -90,7 +90,7 @@
"@nestjs/schedule": "6.0.0",
"@nestjs/serve-static": "5.0.3",
"@openrouter/ai-sdk-provider": "0.7.2",
"@prisma/client": "6.15.0",
"@prisma/client": "6.16.1",
"@simplewebauthn/browser": "13.1.0",
"@simplewebauthn/server": "13.1.1",
"@stripe/stripe-js": "7.9.0",
@ -139,7 +139,7 @@
"svgmap": "2.12.2",
"twitter-api-v2": "1.23.0",
"uuid": "11.1.0",
"yahoo-finance2": "3.8.0",
"yahoo-finance2": "3.10.0",
"zone.js": "0.15.1"
},
"devDependencies": {
@ -195,7 +195,7 @@
"nx": "21.5.1",
"prettier": "3.6.2",
"prettier-plugin-organize-attributes": "1.0.0",
"prisma": "6.15.0",
"prisma": "6.16.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"replace-in-file": "8.3.0",
@ -209,8 +209,5 @@
},
"engines": {
"node": ">=22.18.0"
},
"prisma": {
"seed": "node prisma/seed.mts"
}
}

11
prisma.config.ts

@ -0,0 +1,11 @@
import 'dotenv/config';
import { join } from 'node:path';
import { defineConfig } from 'prisma/config';
export default defineConfig({
migrations: {
path: join('prisma', 'migrations'),
seed: `node ${join('prisma', 'seed.mts')}`
},
schema: join('prisma', 'schema.prisma')
});

17
prisma/migrations/20250915163323_added_asset_profile_resolution/migration.sql

@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE "public"."AssetProfileResolution" (
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"currency" TEXT NOT NULL,
"dataSourceOrigin" "public"."DataSource" NOT NULL,
"dataSourceTarget" "public"."DataSource" NOT NULL,
"id" TEXT NOT NULL,
"requestCount" INTEGER NOT NULL DEFAULT 1,
"symbolOrigin" TEXT NOT NULL,
"symbolTarget" TEXT NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AssetProfileResolution_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "AssetProfileResolution_dataSourceOrigin_symbolOrigin_key" ON "public"."AssetProfileResolution"("dataSourceOrigin", "symbolOrigin");

14
prisma/schema.prisma

@ -88,6 +88,20 @@ model ApiKey {
@@index([userId])
}
model AssetProfileResolution {
createdAt DateTime @default(now())
currency String
dataSourceOrigin DataSource
dataSourceTarget DataSource
id String @id @default(uuid())
requestCount Int @default(1)
symbolOrigin String
symbolTarget String
updatedAt DateTime @updatedAt
@@unique([dataSourceOrigin, symbolOrigin])
}
model AuthDevice {
createdAt DateTime @default(now())
credentialId Bytes

Loading…
Cancel
Save