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/), 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). 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 ## 2.198.0 - 2025-09-11
### Changed ### Changed

18
Dockerfile

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

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

@ -192,7 +192,7 @@ export class AdminService {
filters, filters,
presetId, presetId,
sortColumn, sortColumn,
sortDirection, sortDirection = 'asc',
skip, skip,
take = Number.MAX_SAFE_INTEGER take = Number.MAX_SAFE_INTEGER
}: { }: {
@ -262,11 +262,13 @@ export class AdminService {
orderBy = [{ [sortColumn]: sortDirection }]; orderBy = [{ [sortColumn]: sortDirection }];
if (sortColumn === 'activitiesCount') { if (sortColumn === 'activitiesCount') {
orderBy = { orderBy = [
activities: { {
_count: sortDirection activities: {
_count: sortDirection
}
} }
}; ];
} }
} }
@ -275,10 +277,10 @@ export class AdminService {
try { try {
const symbolProfileResult = await Promise.all([ const symbolProfileResult = await Promise.all([
extendedPrismaClient.symbolProfile.findMany({ extendedPrismaClient.symbolProfile.findMany({
orderBy,
skip, skip,
take, take,
where, where,
orderBy: [...orderBy, { id: sortDirection }],
select: { select: {
_count: { _count: {
select: { select: {
@ -817,17 +819,21 @@ export class AdminService {
skip?: number; skip?: number;
take?: number; take?: number;
}): Promise<AdminUsers['users']> { }): Promise<AdminUsers['users']> {
let orderBy: Prisma.UserOrderByWithRelationInput = { let orderBy: Prisma.Enumerable<Prisma.UserOrderByWithRelationInput> = [
createdAt: 'desc' { createdAt: 'desc' }
}; ];
let where: Prisma.UserWhereInput; let where: Prisma.UserWhereInput;
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
orderBy = { orderBy = [
analytics: { {
lastRequestAt: 'desc' analytics: {
lastRequestAt: 'desc'
}
} }
}; ];
where = { where = {
NOT: { NOT: {
analytics: null analytics: null
@ -836,10 +842,10 @@ export class AdminService {
} }
const usersWithAnalytics = await this.prismaService.user.findMany({ const usersWithAnalytics = await this.prismaService.user.findMany({
orderBy,
skip, skip,
take, take,
where, where,
orderBy: [...orderBy, { id: 'desc' }],
select: { select: {
_count: { _count: {
select: { accounts: true, activities: true } 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 { ScheduleModule } from '@nestjs/schedule';
import { ServeStaticModule } from '@nestjs/serve-static'; import { ServeStaticModule } from '@nestjs/serve-static';
import { StatusCodes } from 'http-status-codes'; import { StatusCodes } from 'http-status-codes';
import { join } from 'path'; import { join } from 'node:path';
import { AccessModule } from './access/access.module'; import { AccessModule } from './access/access.module';
import { AccountModule } from './account/account.module'; import { AccountModule } from './account/account.module';

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

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

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

@ -56,11 +56,36 @@ export class GhostfolioService {
requestTimeout, requestTimeout,
symbol 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 = {
...result, ...result,
...assetProfile, ...assetProfile,
dataSource: DataSource.GHOSTFOLIO dataSource: dataSourceOrigin
}; };
return assetProfile; 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 { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { Response } from 'express'; import { Response } from 'express';
import { readFileSync } from 'fs'; import { readFileSync } from 'node:fs';
import { join } from 'path'; import { join } from 'node:path';
import { SitemapService } from './sitemap.service'; import { SitemapService } from './sitemap.service';

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

@ -325,7 +325,7 @@ export class OrderService {
includeDrafts = false, includeDrafts = false,
skip, skip,
sortColumn, sortColumn,
sortDirection, sortDirection = 'asc',
startDate, startDate,
take = Number.MAX_SAFE_INTEGER, take = Number.MAX_SAFE_INTEGER,
types, types,
@ -347,9 +347,9 @@ export class OrderService {
withExcludedAccountsAndActivities?: boolean; withExcludedAccountsAndActivities?: boolean;
}): Promise<Activities> { }): Promise<Activities> {
let orderBy: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput> = [ let orderBy: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput> = [
{ date: 'asc' }, { date: 'asc' }
{ id: 'asc' }
]; ];
const where: Prisma.OrderWhereInput = { userId }; const where: Prisma.OrderWhereInput = { userId };
if (endDate || startDate) { if (endDate || startDate) {
@ -483,7 +483,7 @@ export class OrderService {
} }
if (sortColumn) { if (sortColumn) {
orderBy = [{ [sortColumn]: sortDirection }, { id: sortDirection }]; orderBy = [{ [sortColumn]: sortDirection }];
} }
if (types) { if (types) {
@ -506,7 +506,6 @@ export class OrderService {
const [orders, count] = await Promise.all([ const [orders, count] = await Promise.all([
this.orders({ this.orders({
orderBy,
skip, skip,
take, take,
where, where,
@ -519,7 +518,8 @@ export class OrderService {
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
SymbolProfile: true, SymbolProfile: true,
tags: true tags: true
} },
orderBy: [...orderBy, { id: sortDirection }]
}), }),
this.prismaService.order.count({ where }) 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 = { export const activityDummyData = {
accountId: undefined, 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 { Tag } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { join } from 'path'; import { join } from 'node:path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { 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 { Tag } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { join } from 'path'; import { join } from 'node:path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { 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 { Tag } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { join } from 'path'; import { join } from 'node:path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { 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 { Tag } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { join } from 'path'; import { join } from 'node:path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { 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 { Tag } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { join } from 'path'; import { join } from 'node:path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { 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 { CACHE_MANAGER, Cache } from '@nestjs/cache-manager';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { createHash } from 'crypto';
import Keyv from 'keyv'; import Keyv from 'keyv';
import ms from 'ms'; import ms from 'ms';
import { createHash } from 'node:crypto';
@Injectable() @Injectable()
export class RedisCacheService { 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 { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { Prisma, Role, User } from '@prisma/client'; import { Prisma, Role, User } from '@prisma/client';
import { createHmac } from 'crypto';
import { differenceInDays, subDays } from 'date-fns'; import { differenceInDays, subDays } from 'date-fns';
import { sortBy, without } from 'lodash'; import { sortBy, without } from 'lodash';
import { createHmac } from 'node:crypto';
@Injectable() @Injectable()
export class UserService { 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) { export function getRandomString(length: number) {
const bytes = randomBytes(length); 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', 'totalInterestInBaseCurrency',
'totalValueInBaseCurrency', 'totalValueInBaseCurrency',
'unitPrice', 'unitPrice',
'unitPriceInAssetProfileCurrency',
'value', 'value',
'valueInBaseCurrency' 'valueInBaseCurrency'
].map((attribute) => { ].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 { Injectable, Logger, NestMiddleware } from '@nestjs/common';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { NextFunction, Request, Response } from 'express'; import { NextFunction, Request, Response } from 'express';
import * as fs from 'fs'; import { readFileSync } from 'node:fs';
import { join } from 'path'; import { join } from 'node:path';
const title = 'Ghostfolio'; const title = 'Ghostfolio';
@ -87,7 +87,7 @@ export class HtmlTemplateMiddleware implements NestMiddleware {
this.indexHtmlMap = SUPPORTED_LANGUAGE_CODES.reduce( this.indexHtmlMap = SUPPORTED_LANGUAGE_CODES.reduce(
(map, languageCode) => ({ (map, languageCode) => ({
...map, ...map,
[languageCode]: fs.readFileSync( [languageCode]: readFileSync(
join(__dirname, '..', 'client', languageCode, 'index.html'), join(__dirname, '..', 'client', languageCode, 'index.html'),
'utf8' '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 { ApiKeyResponse } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { pbkdf2Sync } from 'crypto'; import { pbkdf2Sync } from 'node:crypto';
@Injectable() @Injectable()
export class ApiKeyService { 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 { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import { addDays, format, isSameDay } from 'date-fns'; import { addDays, format, isSameDay } from 'date-fns';
import { uniqBy } from 'lodash';
import YahooFinance from 'yahoo-finance2'; import YahooFinance from 'yahoo-finance2';
import { ChartResultArray } from 'yahoo-finance2/esm/src/modules/chart'; import { ChartResultArray } from 'yahoo-finance2/esm/src/modules/chart';
import { import {
@ -34,6 +35,10 @@ import {
Quote, Quote,
QuoteResponseArray QuoteResponseArray
} from 'yahoo-finance2/esm/src/modules/quote'; } 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'; import { SearchQuoteNonYahoo } from 'yahoo-finance2/esm/src/modules/search';
@Injectable() @Injectable()
@ -190,10 +195,7 @@ export class YahooFinanceService implements DataProviderInterface {
); );
try { try {
let quotes: Pick< let quotes: Price[] | Quote[] = [];
Quote,
'currency' | 'marketState' | 'regularMarketPrice' | 'symbol'
>[] = [];
try { try {
quotes = await this.yahooFinance.quote(yahooFinanceSymbols); quotes = await this.yahooFinance.quote(yahooFinanceSymbols);
@ -290,7 +292,9 @@ export class YahooFinanceService implements DataProviderInterface {
try { try {
marketData = await this.yahooFinance.quote( marketData = await this.yahooFinance.quote(
quotes.map(({ symbol }) => { uniqBy(quotes, ({ symbol }) => {
return symbol;
}).map(({ symbol }) => {
return symbol; return symbol;
}) })
); );
@ -300,35 +304,35 @@ export class YahooFinanceService implements DataProviderInterface {
} }
} }
for (const marketDataItem of marketData) { for (const {
const quote = quotes.find((currentQuote) => { currency,
return currentQuote.symbol === marketDataItem.symbol; longName,
}); quoteType,
shortName,
const symbol = symbol
this.yahooFinanceDataEnhancerService.convertFromYahooFinanceSymbol( } of marketData) {
marketDataItem.symbol
);
const { assetClass, assetSubClass } = const { assetClass, assetSubClass } =
this.yahooFinanceDataEnhancerService.parseAssetClass({ this.yahooFinanceDataEnhancerService.parseAssetClass({
quoteType: quote.quoteType, quoteType,
shortName: quote.shortname shortName
}); });
items.push({ items.push({
assetClass, assetClass,
assetSubClass, assetSubClass,
symbol, currency,
currency: marketDataItem.currency,
dataProviderInfo: this.getDataProviderInfo(), dataProviderInfo: this.getDataProviderInfo(),
dataSource: this.getName(), dataSource: this.getName(),
name: this.yahooFinanceDataEnhancerService.formatName({ name: this.yahooFinanceDataEnhancerService.formatName({
longName: quote.longname, longName,
quoteType: quote.quoteType, quoteType,
shortName: quote.shortname, shortName,
symbol: quote.symbol symbol
}) }),
symbol:
this.yahooFinanceDataEnhancerService.convertFromYahooFinanceSymbol(
symbol
)
}); });
} }
} catch (error) { } catch (error) {
@ -354,23 +358,28 @@ export class YahooFinanceService implements DataProviderInterface {
private async getQuotesWithQuoteSummary(aYahooFinanceSymbols: string[]) { private async getQuotesWithQuoteSummary(aYahooFinanceSymbols: string[]) {
const quoteSummaryPromises = aYahooFinanceSymbols.map((symbol) => { const quoteSummaryPromises = aYahooFinanceSymbols.map((symbol) => {
return this.yahooFinance.quoteSummary(symbol).catch(() => { return this.yahooFinance.quoteSummary(symbol);
Logger.error(
`Could not get quote summary for ${symbol}`,
'YahooFinanceService'
);
return null;
});
}); });
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 return false;
.filter((item) => { }
return item !== null;
}) return true;
.map(({ price }) => { }
return price; )
.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 { Injectable, Logger } from '@nestjs/common';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import { readFileSync, readdirSync } from 'fs'; import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'path'; import { join } from 'node:path';
@Injectable() @Injectable()
export class I18nService { 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 { 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 { GfLoginWithAccessTokenDialogComponent } 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 { LayoutService } from '@ghostfolio/client/core/layout.service'; import { LayoutService } from '@ghostfolio/client/core/layout.service';
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service'; import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
@ -59,7 +58,6 @@ import { catchError, takeUntil } from 'rxjs/operators';
GfLogoComponent, GfLogoComponent,
GfPremiumIndicatorComponent, GfPremiumIndicatorComponent,
IonIcon, IonIcon,
LoginWithAccessTokenDialogModule,
MatBadgeModule, MatBadgeModule,
MatButtonModule, MatButtonModule,
MatMenuModule, MatMenuModule,
@ -273,7 +271,7 @@ export class GfHeaderComponent implements OnChanges {
} }
public openLoginDialog() { public openLoginDialog() {
const dialogRef = this.dialog.open(LoginWithAccessTokenDialog, { const dialogRef = this.dialog.open(GfLoginWithAccessTokenDialogComponent, {
autoFocus: false, autoFocus: false,
data: { data: {
accessToken: '', 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 { InternetIdentityService } from '@ghostfolio/client/services/internet-identity.service';
import { import {
KEY_STAY_SIGNED_IN, KEY_STAY_SIGNED_IN,
@ -5,22 +6,44 @@ import {
} from '@ghostfolio/client/services/settings-storage.service'; } from '@ghostfolio/client/services/settings-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'; import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { FormControl, Validators } from '@angular/forms'; import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatCheckboxChange } from '@angular/material/checkbox'; import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; 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 { Router } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { eyeOffOutline, eyeOutline } from 'ionicons/icons'; import { eyeOffOutline, eyeOutline } from 'ionicons/icons';
@Component({ @Component({
selector: 'gf-login-with-access-token-dialog',
changeDetection: ChangeDetectionStrategy.OnPush, 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'], styleUrls: ['./login-with-access-token-dialog.scss'],
templateUrl: 'login-with-access-token-dialog.html', templateUrl: './login-with-access-token-dialog.html'
standalone: false
}) })
export class LoginWithAccessTokenDialog { export class GfLoginWithAccessTokenDialogComponent {
public accessTokenFormControl = new FormControl( public accessTokenFormControl = new FormControl(
this.data.accessToken, this.data.accessToken,
Validators.required Validators.required
@ -29,7 +52,7 @@ export class LoginWithAccessTokenDialog {
public constructor( public constructor(
@Inject(MAT_DIALOG_DATA) public data: any, @Inject(MAT_DIALOG_DATA) public data: any,
public dialogRef: MatDialogRef<LoginWithAccessTokenDialog>, public dialogRef: MatDialogRef<GfLoginWithAccessTokenDialogComponent>,
private internetIdentityService: InternetIdentityService, private internetIdentityService: InternetIdentityService,
private router: Router, private router: Router,
private settingsStorageService: SettingsStorageService, 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'; } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { import {
DataProviderGhostfolioAssetProfileResponse,
DataProviderGhostfolioStatusResponse, DataProviderGhostfolioStatusResponse,
DividendsResponse, DividendsResponse,
HistoricalResponse, HistoricalResponse,
@ -25,6 +26,7 @@ import { map, Observable, Subject, takeUntil } from 'rxjs';
templateUrl: './api-page.html' templateUrl: './api-page.html'
}) })
export class GfApiPageComponent implements OnInit { export class GfApiPageComponent implements OnInit {
public assetProfile$: Observable<DataProviderGhostfolioAssetProfileResponse>;
public dividends$: Observable<DividendsResponse['dividends']>; public dividends$: Observable<DividendsResponse['dividends']>;
public historicalData$: Observable<HistoricalResponse['historicalData']>; public historicalData$: Observable<HistoricalResponse['historicalData']>;
public isinLookupItems$: Observable<LookupResponse['items']>; public isinLookupItems$: Observable<LookupResponse['items']>;
@ -40,6 +42,7 @@ export class GfApiPageComponent implements OnInit {
public ngOnInit() { public ngOnInit() {
this.apiKey = prompt($localize`Please enter your Ghostfolio API key:`); this.apiKey = prompt($localize`Please enter your Ghostfolio API key:`);
this.assetProfile$ = this.fetchAssetProfile({ symbol: 'AAPL' });
this.dividends$ = this.fetchDividends({ symbol: 'KO' }); this.dividends$ = this.fetchDividends({ symbol: 'KO' });
this.historicalData$ = this.fetchHistoricalData({ symbol: 'AAPL' }); this.historicalData$ = this.fetchHistoricalData({ symbol: 'AAPL' });
this.isinLookupItems$ = this.fetchLookupItems({ query: 'US0378331005' }); this.isinLookupItems$ = this.fetchLookupItems({ query: 'US0378331005' });
@ -53,6 +56,15 @@ export class GfApiPageComponent implements OnInit {
this.unsubscribeSubject.complete(); 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 }) { private fetchDividends({ symbol }: { symbol: string }) {
const params = new HttpParams() const params = new HttpParams()
.set('from', format(startOfYear(new Date()), DATE_FORMAT)) .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> <h2 class="text-center">Status</h2>
<div>{{ status$ | async | json }}</div> <div>{{ status$ | async | json }}</div>
</div> </div>
<div class="mb-3">
<h2 class="text-center">Asset Profile</h2>
<div>{{ assetProfile$ | async | json }}</div>
</div>
<div> <div>
<h2 class="text-center">Lookup</h2> <h2 class="text-center">Lookup</h2>
@if (lookupItems$) { @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 calculation method based on the average amount of capital invested
over time. ROAI aims to provide a more insightful view of investment over time. ROAI aims to provide a more insightful view of investment
performance than simpler approaches, especially when contributions are 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>
<mat-card appearance="outlined" class="mb-3"> <mat-card appearance="outlined" class="mb-3">

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

@ -77,6 +77,14 @@
[keys]="['symbol']" [keys]="['symbol']"
[showLabels]="deviceType !== 'mobile'" [showLabels]="deviceType !== 'mobile'"
/> />
<gf-holdings-table
[deviceType]="deviceType"
[hasPermissionToOpenDetails]="false"
[hasPermissionToShowQuantities]="false"
[hasPermissionToShowValues]="false"
[holdings]="holdings"
[pageSize]="7"
/>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</div> </div>
@ -195,18 +203,6 @@
</div> </div>
</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="row my-5">
<div class="col-md-10 offset-md-1"> <div class="col-md-10 offset-md-1">
<h2 class="h4 mb-1 text-center" i18n> <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 { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
import { ShowAccessTokenDialogParams } from './show-access-token-dialog/interfaces/interfaces'; import { UserAccountRegistrationDialogParams } from './user-account-registration-dialog/interfaces/interfaces';
import { ShowAccessTokenDialog } from './show-access-token-dialog/show-access-token-dialog.component'; import { GfUserAccountRegistrationDialogComponent } from './user-account-registration-dialog/user-account-registration-dialog.component';
import { ShowAccessTokenDialogModule } from './show-access-token-dialog/show-access-token-dialog.module';
@Component({ @Component({
host: { class: 'page' }, host: { class: 'page' },
imports: [ imports: [GfLogoComponent, MatButtonModule, RouterModule],
GfLogoComponent,
MatButtonModule,
RouterModule,
ShowAccessTokenDialogModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA], schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-register-page', selector: 'gf-register-page',
styleUrls: ['./register-page.scss'], styleUrls: ['./register-page.scss'],
@ -90,15 +84,18 @@ export class GfRegisterPageComponent implements OnDestroy, OnInit {
} }
public openShowAccessTokenDialog() { public openShowAccessTokenDialog() {
const dialogRef = this.dialog.open(ShowAccessTokenDialog, { const dialogRef = this.dialog.open(
data: { GfUserAccountRegistrationDialogComponent,
deviceType: this.deviceType, {
needsToAcceptTermsOfService: this.hasPermissionForSubscription data: {
} as ShowAccessTokenDialogParams, deviceType: this.deviceType,
disableClose: true, needsToAcceptTermsOfService: this.hasPermissionForSubscription
height: this.deviceType === 'mobile' ? '98vh' : undefined, } as UserAccountRegistrationDialogParams,
width: this.deviceType === 'mobile' ? '100vw' : '30rem' disableClose: true,
}); height: this.deviceType === 'mobile' ? '98vh' : undefined,
width: this.deviceType === 'mobile' ? '100vw' : '30rem'
}
);
dialogRef dialogRef
.afterClosed() .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; deviceType: string;
needsToAcceptTermsOfService: boolean; 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 { DataService } from '@ghostfolio/client/services/data.service';
import { publicRoutes } from '@ghostfolio/common/routes/routes'; 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 { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
CUSTOM_ELEMENTS_SCHEMA,
Inject, Inject,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { MAT_DIALOG_DATA } from '@angular/material/dialog'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatStepper } from '@angular/material/stepper'; 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 { addIcons } from 'ionicons';
import { import {
arrowForwardOutline, arrowForwardOutline,
@ -19,16 +30,31 @@ import {
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
import { ShowAccessTokenDialogParams } from './interfaces/interfaces'; import { UserAccountRegistrationDialogParams } from './interfaces/interfaces';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-show-access-token-dialog', imports: [
standalone: false, ClipboardModule,
styleUrls: ['./show-access-token-dialog.scss'], CommonModule,
templateUrl: 'show-access-token-dialog.html' 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; @ViewChild(MatStepper) stepper!: MatStepper;
public accessToken: string; public accessToken: string;
@ -43,7 +69,7 @@ export class ShowAccessTokenDialog {
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: ShowAccessTokenDialogParams, @Inject(MAT_DIALOG_DATA) public data: UserAccountRegistrationDialogParams,
private dataService: DataService private dataService: DataService
) { ) {
addIcons({ arrowForwardOutline, checkmarkOutline, copyOutline }); 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.tags = [
this.product1.name, this.product1.name,
this.product1.origin,
this.product2.name, this.product2.name,
this.product2.origin,
$localize`Alternative`, $localize`Alternative`,
$localize`App`, $localize`App`,
$localize`Budgeting`, $localize`Budgeting`,
@ -96,8 +98,12 @@ export class GfProductPageComponent implements OnInit {
$localize`Wealth`, $localize`Wealth`,
$localize`Wealth Management`, $localize`Wealth Management`,
`WealthTech` `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) { 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', origin: 'Switzerland',
slogan: 'Simplicity for Complex Wealth' 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, founded: 2018,
hasFreePlan: true, hasFreePlan: true,
@ -97,6 +107,12 @@ export const personalFinanceTools: Product[] = [
pricingPerYear: '$100', pricingPerYear: '$100',
slogan: 'Stock Portfolio Tracker for Smart Investors' slogan: 'Stock Portfolio Tracker for Smart Investors'
}, },
{
key: 'budgetpulse',
name: 'BudgetPulse',
origin: 'United States',
slogan: 'Giving life to your finance!'
},
{ {
founded: 2007, founded: 2007,
hasFreePlan: false, hasFreePlan: false,
@ -180,6 +196,15 @@ export const personalFinanceTools: Product[] = [
pricingPerYear: '$95', pricingPerYear: '$95',
slogan: 'Do money better with Copilot' 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, founded: 2023,
hasFreePlan: false, hasFreePlan: false,
@ -427,6 +452,14 @@ export const personalFinanceTools: Product[] = [
slogan: 'Die All-in-One Lösung für dein Vermögen.', slogan: 'Die All-in-One Lösung für dein Vermögen.',
useAnonymously: true useAnonymously: true
}, },
{
founded: 2017,
hasSelfHostingAbility: false,
key: 'honeydue',
name: 'Honeydue',
origin: 'United States',
slogan: 'Finance App for Couples'
},
{ {
founded: 2022, founded: 2022,
key: 'income-reign', key: 'income-reign',
@ -608,6 +641,13 @@ export const personalFinanceTools: Product[] = [
origin: 'Germany', origin: 'Germany',
slogan: 'Dein smarter Finance Assistant' slogan: 'Dein smarter Finance Assistant'
}, },
{
key: 'moneywiz',
name: 'MoneyWiz',
origin: 'United States',
pricingPerYear: '$29.99',
slogan: 'Get money management superpowers'
},
{ {
hasFreePlan: false, hasFreePlan: false,
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
@ -859,6 +899,12 @@ export const personalFinanceTools: Product[] = [
pricingPerYear: '$80', pricingPerYear: '$80',
slogan: 'Simple and powerful portfolio tracker' slogan: 'Simple and powerful portfolio tracker'
}, },
{
key: 'splashmoney',
name: 'SplashMoney',
origin: 'United States',
slogan: 'Manage your money anytime, anywhere.'
},
{ {
founded: 2019, founded: 2019,
hasSelfHostingAbility: false, hasSelfHostingAbility: false,

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

@ -129,7 +129,6 @@
[symbol]="element.SymbolProfile?.symbol" [symbol]="element.SymbolProfile?.symbol"
[tooltip]="element.SymbolProfile?.name" [tooltip]="element.SymbolProfile?.name"
/> />
<div>{{ element.dataSource }}</div>
</td> </td>
</ng-container> </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 { CommonModule } from '@angular/common';
import { import {
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
@ -25,11 +27,18 @@ export class GfEntityLogoComponent implements OnChanges {
public src: string; public src: string;
public constructor(
private readonly imageSourceService: EntityLogoImageSourceService
) {}
public ngOnChanges() { public ngOnChanges() {
if (this.dataSource && this.symbol) { 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) { } 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", "name": "ghostfolio",
"version": "2.198.0", "version": "2.200.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.198.0", "version": "2.200.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
@ -44,7 +44,7 @@
"@nestjs/schedule": "6.0.0", "@nestjs/schedule": "6.0.0",
"@nestjs/serve-static": "5.0.3", "@nestjs/serve-static": "5.0.3",
"@openrouter/ai-sdk-provider": "0.7.2", "@openrouter/ai-sdk-provider": "0.7.2",
"@prisma/client": "6.15.0", "@prisma/client": "6.16.1",
"@simplewebauthn/browser": "13.1.0", "@simplewebauthn/browser": "13.1.0",
"@simplewebauthn/server": "13.1.1", "@simplewebauthn/server": "13.1.1",
"@stripe/stripe-js": "7.9.0", "@stripe/stripe-js": "7.9.0",
@ -93,7 +93,7 @@
"svgmap": "2.12.2", "svgmap": "2.12.2",
"twitter-api-v2": "1.23.0", "twitter-api-v2": "1.23.0",
"uuid": "11.1.0", "uuid": "11.1.0",
"yahoo-finance2": "3.8.0", "yahoo-finance2": "3.10.0",
"zone.js": "0.15.1" "zone.js": "0.15.1"
}, },
"devDependencies": { "devDependencies": {
@ -149,7 +149,7 @@
"nx": "21.5.1", "nx": "21.5.1",
"prettier": "3.6.2", "prettier": "3.6.2",
"prettier-plugin-organize-attributes": "1.0.0", "prettier-plugin-organize-attributes": "1.0.0",
"prisma": "6.15.0", "prisma": "6.16.1",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"replace-in-file": "8.3.0", "replace-in-file": "8.3.0",
@ -11960,9 +11960,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@prisma/client": { "node_modules/@prisma/client": {
"version": "6.15.0", "version": "6.16.1",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.15.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.16.1.tgz",
"integrity": "sha512-wR2LXUbOH4cL/WToatI/Y2c7uzni76oNFND7+23ypLllBmIS8e3ZHhO+nud9iXSXKFt1SoM3fTZvHawg63emZw==", "integrity": "sha512-QaBCOY29lLAxEFFJgBPyW3WInCW52fJeQTmWx/h6YsP5u0bwuqP51aP0uhqFvhK9DaZPwvai/M4tSDYLVE9vRg==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
@ -11982,9 +11982,9 @@
} }
}, },
"node_modules/@prisma/config": { "node_modules/@prisma/config": {
"version": "6.15.0", "version": "6.16.1",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.15.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.16.1.tgz",
"integrity": "sha512-KMEoec9b2u6zX0EbSEx/dRpx1oNLjqJEBZYyK0S3TTIbZ7GEGoVyGyFRk4C72+A38cuPLbfQGQvgOD+gBErKlA==", "integrity": "sha512-sz3uxRPNL62QrJ0EYiujCFkIGZ3hg+9hgC1Ae1HjoYuj0BxCqHua4JNijYvYCrh9LlofZDZcRBX3tHBfLvAngA==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@ -11995,53 +11995,53 @@
} }
}, },
"node_modules/@prisma/debug": { "node_modules/@prisma/debug": {
"version": "6.15.0", "version": "6.16.1",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.15.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.16.1.tgz",
"integrity": "sha512-y7cSeLuQmyt+A3hstAs6tsuAiVXSnw9T55ra77z0nbNkA8Lcq9rNcQg6PI00by/+WnE/aMRJ/W7sZWn2cgIy1g==", "integrity": "sha512-RWv/VisW5vJE4cDRTuAHeVedtGoItXTnhuLHsSlJ9202QKz60uiXWywBlVcqXVq8bFeIZoCoWH+R1duZJPwqLw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@prisma/engines": { "node_modules/@prisma/engines": {
"version": "6.15.0", "version": "6.16.1",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.15.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.16.1.tgz",
"integrity": "sha512-opITiR5ddFJ1N2iqa7mkRlohCZqVSsHhRcc29QXeldMljOf4FSellLT0J5goVb64EzRTKcIDeIsJBgmilNcKxA==", "integrity": "sha512-EOnEM5HlosPudBqbI+jipmaW/vQEaF0bKBo4gVkGabasINHR6RpC6h44fKZEqx4GD8CvH+einD2+b49DQrwrAg==",
"devOptional": true, "devOptional": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "6.15.0", "@prisma/debug": "6.16.1",
"@prisma/engines-version": "6.15.0-5.85179d7826409ee107a6ba334b5e305ae3fba9fb", "@prisma/engines-version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43",
"@prisma/fetch-engine": "6.15.0", "@prisma/fetch-engine": "6.16.1",
"@prisma/get-platform": "6.15.0" "@prisma/get-platform": "6.16.1"
} }
}, },
"node_modules/@prisma/engines-version": { "node_modules/@prisma/engines-version": {
"version": "6.15.0-5.85179d7826409ee107a6ba334b5e305ae3fba9fb", "version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.15.0-5.85179d7826409ee107a6ba334b5e305ae3fba9fb.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43.tgz",
"integrity": "sha512-a/46aK5j6L3ePwilZYEgYDPrhBQ/n4gYjLxT5YncUTJJNRnTCVjPF86QdzUOLRdYjCLfhtZp9aum90W0J+trrg==", "integrity": "sha512-ThvlDaKIVrnrv97ujNFDYiQbeMQpLa0O86HFA2mNoip4mtFqM7U5GSz2ie1i2xByZtvPztJlNRgPsXGeM/kqAA==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@prisma/fetch-engine": { "node_modules/@prisma/fetch-engine": {
"version": "6.15.0", "version": "6.16.1",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.15.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.16.1.tgz",
"integrity": "sha512-xcT5f6b+OWBq6vTUnRCc7qL+Im570CtwvgSj+0MTSGA1o9UDSKZ/WANvwtiRXdbYWECpyC3CukoG3A04VTAPHw==", "integrity": "sha512-fl/PKQ8da5YTayw86WD3O9OmKJEM43gD3vANy2hS5S1CnfW2oPXk+Q03+gUWqcKK306QqhjjIHRFuTZ31WaosQ==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "6.15.0", "@prisma/debug": "6.16.1",
"@prisma/engines-version": "6.15.0-5.85179d7826409ee107a6ba334b5e305ae3fba9fb", "@prisma/engines-version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43",
"@prisma/get-platform": "6.15.0" "@prisma/get-platform": "6.16.1"
} }
}, },
"node_modules/@prisma/get-platform": { "node_modules/@prisma/get-platform": {
"version": "6.15.0", "version": "6.16.1",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.15.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.16.1.tgz",
"integrity": "sha512-Jbb+Xbxyp05NSR1x2epabetHiXvpO8tdN2YNoWoA/ZsbYyxxu/CO/ROBauIFuMXs3Ti+W7N7SJtWsHGaWte9Rg==", "integrity": "sha512-kUfg4vagBG7dnaGRcGd1c0ytQFcDj2SUABiuveIpL3bthFdTLI6PJeLEia6Q8Dgh+WhPdo0N2q0Fzjk63XTyaA==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "6.15.0" "@prisma/debug": "6.16.1"
} }
}, },
"node_modules/@redis/client": { "node_modules/@redis/client": {
@ -35747,15 +35747,15 @@
} }
}, },
"node_modules/prisma": { "node_modules/prisma": {
"version": "6.15.0", "version": "6.16.1",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.15.0.tgz", "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.16.1.tgz",
"integrity": "sha512-E6RCgOt+kUVtjtZgLQDBJ6md2tDItLJNExwI0XJeBc1FKL+Vwb+ovxXxuok9r8oBgsOXBA33fGDuE/0qDdCWqQ==", "integrity": "sha512-MFkMU0eaDDKAT4R/By2IA9oQmwLTxokqv2wegAErr9Rf+oIe7W2sYpE/Uxq0H2DliIR7vnV63PkC1bEwUtl98w==",
"devOptional": true, "devOptional": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/config": "6.15.0", "@prisma/config": "6.16.1",
"@prisma/engines": "6.15.0" "@prisma/engines": "6.16.1"
}, },
"bin": { "bin": {
"prisma": "build/index.js" "prisma": "build/index.js"
@ -42071,9 +42071,9 @@
} }
}, },
"node_modules/yahoo-finance2": { "node_modules/yahoo-finance2": {
"version": "3.8.0", "version": "3.10.0",
"resolved": "https://registry.npmjs.org/yahoo-finance2/-/yahoo-finance2-3.8.0.tgz", "resolved": "https://registry.npmjs.org/yahoo-finance2/-/yahoo-finance2-3.10.0.tgz",
"integrity": "sha512-em11JOlfSg23wevm4kXs1+A/CoSWD9eg7/hKRU3zKWuPknCfE4NkIhGVb601Nokid+KPE8Q0eoXK4qgLsMIjKA==", "integrity": "sha512-0mnvefEAapMS6M3tnqLmQlyE2W38AQqByaTS09l2dawLaVU7NNc0hJ4qI4F3qi3C7MU+ZWAb8DFVKpW6Zsj0Nw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@deno/shim-deno": "~0.18.0", "@deno/shim-deno": "~0.18.0",

11
package.json

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

Loading…
Cancel
Save