diff --git a/CHANGELOG.md b/CHANGELOG.md index 52c1c18a..2793785d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,17 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Changed + +- Improved the static portfolio analysis rule: Emergency fund setup by supporting assets +- Restricted the historical market data gathering to active asset profiles + +## 2.148.0 - 2025-03-24 + +### Added + +- Added the `isActive` flag to the asset profile model + +### Changed + +- Improved the language localization for German (`de`) +- Upgraded `ngx-skeleton-loader` from version `9.0.0` to `10.0.0` + +## 2.147.0 - 2025-03-22 + ### Added - Added support for filtering in the _Copy AI prompt to clipboard_ actions on the analysis page (experimental) +- Added support for generating a new _Security Token_ via the users table of the admin control panel +- Added an endpoint to localize the `site.webmanifest` - Added the _Storybook_ path to the `sitemap.xml` file ### Changed - Improved the export functionality by applying filters on accounts and tags - Improved the symbol validation in the _Yahoo Finance_ service (get asset profiles) +- Eliminated `firstOrderDate` from the summary of the portfolio details endpoint in favor of using `dateOfFirstActivity` from the user endpoint - Refactored `lodash.uniq` with `Array.from(new Set(...))` - Refreshed the cryptocurrencies list +- Improved the language localization for German (`de`) +- Improved the language localization for Turkish (`tr`) ### Fixed diff --git a/apps/api/src/app/admin/admin.controller.ts b/apps/api/src/app/admin/admin.controller.ts index a761bbba..409ce716 100644 --- a/apps/api/src/app/admin/admin.controller.ts +++ b/apps/api/src/app/admin/admin.controller.ts @@ -83,7 +83,7 @@ export class AdminController { @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async gatherMax(): Promise { const assetProfileIdentifiers = - await this.dataGatheringService.getAllAssetProfileIdentifiers(); + await this.dataGatheringService.getAllActiveAssetProfileIdentifiers(); await this.dataGatheringService.addJobsToQueue( assetProfileIdentifiers.map(({ dataSource, symbol }) => { @@ -110,7 +110,7 @@ export class AdminController { @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async gatherProfileData(): Promise { const assetProfileIdentifiers = - await this.dataGatheringService.getAllAssetProfileIdentifiers(); + await this.dataGatheringService.getAllActiveAssetProfileIdentifiers(); await this.dataGatheringService.addJobsToQueue( assetProfileIdentifiers.map(({ dataSource, symbol }) => { diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 2a515bf4..99080e1e 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -32,6 +32,7 @@ import { AuthModule } from './auth/auth.module'; import { CacheModule } from './cache/cache.module'; import { AiModule } from './endpoints/ai/ai.module'; import { ApiKeysModule } from './endpoints/api-keys/api-keys.module'; +import { AssetsModule } from './endpoints/assets/assets.module'; import { BenchmarksModule } from './endpoints/benchmarks/benchmarks.module'; import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module'; import { MarketDataModule } from './endpoints/market-data/market-data.module'; @@ -61,6 +62,7 @@ import { UserModule } from './user/user.module'; AiModule, ApiKeysModule, AssetModule, + AssetsModule, AuthDeviceModule, AuthModule, BenchmarksModule, diff --git a/apps/api/src/app/auth/auth.service.ts b/apps/api/src/app/auth/auth.service.ts index edfb22b6..ceff492a 100644 --- a/apps/api/src/app/auth/auth.service.ts +++ b/apps/api/src/app/auth/auth.service.ts @@ -20,10 +20,10 @@ export class AuthService { public async validateAnonymousLogin(accessToken: string): Promise { return new Promise(async (resolve, reject) => { try { - const hashedAccessToken = this.userService.createAccessToken( - accessToken, - this.configurationService.get('ACCESS_TOKEN_SALT') - ); + const hashedAccessToken = this.userService.createAccessToken({ + password: accessToken, + salt: this.configurationService.get('ACCESS_TOKEN_SALT') + }); const [user] = await this.userService.users({ where: { accessToken: hashedAccessToken } diff --git a/apps/api/src/app/endpoints/assets/assets.controller.ts b/apps/api/src/app/endpoints/assets/assets.controller.ts new file mode 100644 index 00000000..1735cc59 --- /dev/null +++ b/apps/api/src/app/endpoints/assets/assets.controller.ts @@ -0,0 +1,46 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { interpolate } from '@ghostfolio/common/helper'; + +import { + Controller, + Get, + Param, + Res, + Version, + VERSION_NEUTRAL +} from '@nestjs/common'; +import { Response } from 'express'; +import { readFileSync } from 'fs'; +import { join } from 'path'; + +@Controller('assets') +export class AssetsController { + private webManifest = ''; + + public constructor( + public readonly configurationService: ConfigurationService + ) { + try { + this.webManifest = readFileSync( + join(__dirname, 'assets', 'site.webmanifest'), + 'utf8' + ); + } catch {} + } + + @Get('/:languageCode/site.webmanifest') + @Version(VERSION_NEUTRAL) + public getWebManifest( + @Param('languageCode') languageCode: string, + @Res() response: Response + ): void { + const rootUrl = this.configurationService.get('ROOT_URL'); + const webManifest = interpolate(this.webManifest, { + languageCode, + rootUrl + }); + + response.setHeader('Content-Type', 'application/json'); + response.send(webManifest); + } +} diff --git a/apps/api/src/app/endpoints/assets/assets.module.ts b/apps/api/src/app/endpoints/assets/assets.module.ts new file mode 100644 index 00000000..51d330e5 --- /dev/null +++ b/apps/api/src/app/endpoints/assets/assets.module.ts @@ -0,0 +1,11 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; + +import { Module } from '@nestjs/common'; + +import { AssetsController } from './assets.controller'; + +@Module({ + controllers: [AssetsController], + providers: [ConfigurationService] +}) +export class AssetsModule {} diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index 698d13e2..729049fd 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -300,6 +300,7 @@ export class ImportService { figiShareClass, holdings, id, + isActive, isin, name, scraperConfiguration, @@ -375,6 +376,7 @@ export class ImportService { figiShareClass, holdings, id, + isActive, isin, name, scraperConfiguration, @@ -586,7 +588,7 @@ export class ImportService { const assetProfiles: { [assetProfileIdentifier: string]: Partial; } = {}; - const dataSources = await this.dataProviderService.getDataSources(); + const dataSources = await this.dataProviderService.getDataSources({ user }); for (const [ index, diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index a3d9e3c4..7287c103 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -569,7 +569,7 @@ export class PortfolioService { const emergencyFundInCash = emergencyFund .minus( - this.getEmergencyFundPositionsValueInBaseCurrency({ + this.getEmergencyFundHoldingsValueInBaseCurrency({ holdings }) ) @@ -608,8 +608,8 @@ export class PortfolioService { userCurrency, userId, balanceInBaseCurrency: cashDetails.balanceInBaseCurrency, - emergencyFundPositionsValueInBaseCurrency: - this.getEmergencyFundPositionsValueInBaseCurrency({ + emergencyFundHoldingsValueInBaseCurrency: + this.getEmergencyFundHoldingsValueInBaseCurrency({ holdings }) }); @@ -1263,7 +1263,11 @@ export class PortfolioService { [ new EmergencyFundSetup( this.exchangeRateDataService, - userSettings.emergencyFund + this.getTotalEmergencyFund({ + userSettings, + emergencyFundHoldingsValueInBaseCurrency: + this.getEmergencyFundHoldingsValueInBaseCurrency({ holdings }) + }).toNumber() ) ], userSettings @@ -1585,7 +1589,7 @@ export class PortfolioService { return dividendsByGroup; } - private getEmergencyFundPositionsValueInBaseCurrency({ + private getEmergencyFundHoldingsValueInBaseCurrency({ holdings }: { holdings: PortfolioDetails['holdings']; @@ -1600,14 +1604,14 @@ export class PortfolioService { ); }); - let valueInBaseCurrencyOfEmergencyFundPositions = new Big(0); + let valueInBaseCurrencyOfEmergencyFundHoldings = new Big(0); for (const { valueInBaseCurrency } of emergencyFundHoldings) { - valueInBaseCurrencyOfEmergencyFundPositions = - valueInBaseCurrencyOfEmergencyFundPositions.plus(valueInBaseCurrency); + valueInBaseCurrencyOfEmergencyFundHoldings = + valueInBaseCurrencyOfEmergencyFundHoldings.plus(valueInBaseCurrency); } - return valueInBaseCurrencyOfEmergencyFundPositions.toNumber(); + return valueInBaseCurrencyOfEmergencyFundHoldings.toNumber(); } private getInitialCashPosition({ @@ -1774,7 +1778,7 @@ export class PortfolioService { private async getSummary({ balanceInBaseCurrency, - emergencyFundPositionsValueInBaseCurrency, + emergencyFundHoldingsValueInBaseCurrency, filteredValueInBaseCurrency, impersonationId, portfolioCalculator, @@ -1782,7 +1786,7 @@ export class PortfolioService { userId }: { balanceInBaseCurrency: number; - emergencyFundPositionsValueInBaseCurrency: number; + emergencyFundHoldingsValueInBaseCurrency: number; filteredValueInBaseCurrency: Big; impersonationId: string; portfolioCalculator: PortfolioCalculator; @@ -1827,12 +1831,10 @@ export class PortfolioService { const dividendInBaseCurrency = await portfolioCalculator.getDividendInBaseCurrency(); - const emergencyFund = new Big( - Math.max( - emergencyFundPositionsValueInBaseCurrency, - (user.Settings?.settings as UserSettings)?.emergencyFund ?? 0 - ) - ); + const totalEmergencyFund = this.getTotalEmergencyFund({ + emergencyFundHoldingsValueInBaseCurrency, + userSettings: user.Settings?.settings as UserSettings + }); const fees = await portfolioCalculator.getFeesInBaseCurrency(); @@ -1858,8 +1860,8 @@ export class PortfolioService { }).toNumber(); const cash = new Big(balanceInBaseCurrency) - .minus(emergencyFund) - .plus(emergencyFundPositionsValueInBaseCurrency) + .minus(totalEmergencyFund) + .plus(emergencyFundHoldingsValueInBaseCurrency) .toNumber(); const committedFunds = new Big(totalBuy).minus(totalSell); @@ -1918,7 +1920,6 @@ export class PortfolioService { annualizedPerformancePercentWithCurrencyEffect, cash, excludedAccountsAndActivities, - firstOrderDate, netPerformance, netPerformancePercentage, netPerformancePercentageWithCurrencyEffect, @@ -1929,11 +1930,11 @@ export class PortfolioService { currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(), dividendInBaseCurrency: dividendInBaseCurrency.toNumber(), emergencyFund: { - assets: emergencyFundPositionsValueInBaseCurrency, - cash: emergencyFund - .minus(emergencyFundPositionsValueInBaseCurrency) + assets: emergencyFundHoldingsValueInBaseCurrency, + cash: totalEmergencyFund + .minus(emergencyFundHoldingsValueInBaseCurrency) .toNumber(), - total: emergencyFund.toNumber() + total: totalEmergencyFund.toNumber() }, fees: fees.toNumber(), filteredValueInBaseCurrency: filteredValueInBaseCurrency.toNumber(), @@ -1941,7 +1942,7 @@ export class PortfolioService { ? filteredValueInBaseCurrency.div(netWorth).toNumber() : undefined, fireWealth: new Big(currentValueInBaseCurrency) - .minus(emergencyFundPositionsValueInBaseCurrency) + .minus(emergencyFundHoldingsValueInBaseCurrency) .toNumber(), grossPerformance: new Big(netPerformance).plus(fees).toNumber(), grossPerformanceWithCurrencyEffect: new Big( @@ -1986,6 +1987,21 @@ export class PortfolioService { ); } + private getTotalEmergencyFund({ + emergencyFundHoldingsValueInBaseCurrency, + userSettings + }: { + emergencyFundHoldingsValueInBaseCurrency: number; + userSettings: UserSettings; + }) { + return new Big( + Math.max( + emergencyFundHoldingsValueInBaseCurrency, + userSettings?.emergencyFund ?? 0 + ) + ); + } + private getUserCurrency(aUser?: UserWithSettings) { return ( aUser?.Settings?.settings.baseCurrency ?? diff --git a/apps/api/src/app/sitemap/sitemap.controller.ts b/apps/api/src/app/sitemap/sitemap.controller.ts index ea21906e..aad5e39a 100644 --- a/apps/api/src/app/sitemap/sitemap.controller.ts +++ b/apps/api/src/app/sitemap/sitemap.controller.ts @@ -9,8 +9,8 @@ import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools' import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common'; import { format } from 'date-fns'; import { Response } from 'express'; -import * as fs from 'fs'; -import * as path from 'path'; +import { readFileSync } from 'fs'; +import { join } from 'path'; @Controller('sitemap.xml') export class SitemapController { @@ -20,8 +20,8 @@ export class SitemapController { private readonly configurationService: ConfigurationService ) { try { - this.sitemapXml = fs.readFileSync( - path.join(__dirname, 'assets', 'sitemap.xml'), + this.sitemapXml = readFileSync( + join(__dirname, 'assets', 'sitemap.xml'), 'utf8' ); } catch {} diff --git a/apps/api/src/app/user/user.controller.ts b/apps/api/src/app/user/user.controller.ts index 149e0628..868af505 100644 --- a/apps/api/src/app/user/user.controller.ts +++ b/apps/api/src/app/user/user.controller.ts @@ -1,8 +1,13 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; -import { User, UserSettings } from '@ghostfolio/common/interfaces'; +import { + AccessTokenResponse, + User, + UserSettings +} from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import type { RequestWithUser } from '@ghostfolio/common/types'; @@ -36,6 +41,7 @@ export class UserController { public constructor( private readonly configurationService: ConfigurationService, private readonly jwtService: JwtService, + private readonly prismaService: PrismaService, private readonly propertyService: PropertyService, @Inject(REQUEST) private readonly request: RequestWithUser, private readonly userService: UserService @@ -47,10 +53,10 @@ export class UserController { public async deleteOwnUser( @Body() data: DeleteOwnUserDto ): Promise { - const hashedAccessToken = this.userService.createAccessToken( - data.accessToken, - this.configurationService.get('ACCESS_TOKEN_SALT') - ); + const hashedAccessToken = this.userService.createAccessToken({ + password: data.accessToken, + salt: this.configurationService.get('ACCESS_TOKEN_SALT') + }); const [user] = await this.userService.users({ where: { accessToken: hashedAccessToken, id: this.request.user.id } @@ -85,6 +91,25 @@ export class UserController { }); } + @HasPermission(permissions.accessAdminControl) + @Post(':id/access-token') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async generateAccessToken( + @Param('id') id: string + ): Promise { + const { accessToken, hashedAccessToken } = + this.userService.generateAccessToken({ + userId: id + }); + + await this.prismaService.user.update({ + data: { accessToken: hashedAccessToken }, + where: { id } + }); + + return { accessToken }; + } + @Get() @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async getUser( diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 40bc1b2b..e9b8078b 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -67,13 +67,33 @@ export class UserService { return this.prismaService.user.count(args); } - public createAccessToken(password: string, salt: string): string { + public createAccessToken({ + password, + salt + }: { + password: string; + salt: string; + }): string { const hash = createHmac('sha512', salt); hash.update(password); return hash.digest('hex'); } + public generateAccessToken({ userId }: { userId: string }) { + const accessToken = this.createAccessToken({ + password: userId, + salt: getRandomString(10) + }); + + const hashedAccessToken = this.createAccessToken({ + password: accessToken, + salt: this.configurationService.get('ACCESS_TOKEN_SALT') + }); + + return { accessToken, hashedAccessToken }; + } + public async getUser( { Account, id, permissions, Settings, subscription }: UserWithSettings, aLocale = locale @@ -433,7 +453,7 @@ export class UserService { data.provider = 'ANONYMOUS'; } - let user = await this.prismaService.user.create({ + const user = await this.prismaService.user.create({ data: { ...data, Account: { @@ -464,14 +484,11 @@ export class UserService { } if (data.provider === 'ANONYMOUS') { - const accessToken = this.createAccessToken(user.id, getRandomString(10)); + const { accessToken, hashedAccessToken } = this.generateAccessToken({ + userId: user.id + }); - const hashedAccessToken = this.createAccessToken( - accessToken, - this.configurationService.get('ACCESS_TOKEN_SALT') - ); - - user = await this.prismaService.user.update({ + await this.prismaService.user.update({ data: { accessToken: hashedAccessToken }, where: { id: user.id } }); diff --git a/apps/client/src/assets/site.webmanifest b/apps/api/src/assets/site.webmanifest similarity index 92% rename from apps/client/src/assets/site.webmanifest rename to apps/api/src/assets/site.webmanifest index 8f1eceef..a2871962 100644 --- a/apps/client/src/assets/site.webmanifest +++ b/apps/api/src/assets/site.webmanifest @@ -25,7 +25,7 @@ "name": "Ghostfolio", "orientation": "portrait", "short_name": "Ghostfolio", - "start_url": "/en/", + "start_url": "/${languageCode}/", "theme_color": "#FFFFFF", - "url": "https://ghostfol.io" + "url": "${rootUrl}" } diff --git a/apps/api/src/environments/environment.prod.ts b/apps/api/src/environments/environment.prod.ts index 81b32496..6d4cbb4b 100644 --- a/apps/api/src/environments/environment.prod.ts +++ b/apps/api/src/environments/environment.prod.ts @@ -1,4 +1,7 @@ +import { DEFAULT_HOST, DEFAULT_PORT } from '@ghostfolio/common/config'; + export const environment = { production: true, + rootUrl: `http://${DEFAULT_HOST}:${DEFAULT_PORT}`, version: `${require('../../../../package.json').version}` }; diff --git a/apps/api/src/environments/environment.ts b/apps/api/src/environments/environment.ts index c0ae2e7e..05476646 100644 --- a/apps/api/src/environments/environment.ts +++ b/apps/api/src/environments/environment.ts @@ -1,4 +1,7 @@ +import { DEFAULT_HOST } from '@ghostfolio/common/config'; + export const environment = { production: false, + rootUrl: `https://${DEFAULT_HOST}:4200`, version: 'dev' }; diff --git a/apps/api/src/helper/object.helper.spec.ts b/apps/api/src/helper/object.helper.spec.ts index 85fb8f4e..b0370fa3 100644 --- a/apps/api/src/helper/object.helper.spec.ts +++ b/apps/api/src/helper/object.helper.spec.ts @@ -1519,7 +1519,6 @@ describe('redactAttributes', () => { annualizedPerformancePercentWithCurrencyEffect: 0.1694019484552876, cash: null, excludedAccountsAndActivities: null, - firstOrderDate: '2017-01-02T23:00:00.000Z', netPerformance: null, netPerformancePercentage: 2.3039314216696174, netPerformancePercentageWithCurrencyEffect: 2.3589806001456606, @@ -3023,7 +3022,6 @@ describe('redactAttributes', () => { annualizedPerformancePercentWithCurrencyEffect: 0.1694019484552876, cash: null, excludedAccountsAndActivities: null, - firstOrderDate: '2017-01-02T23:00:00.000Z', netPerformance: null, netPerformancePercentage: 2.3039314216696174, netPerformancePercentageWithCurrencyEffect: 2.3589806001456606, diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 7cd5953b..73502525 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,4 +1,8 @@ -import { STORYBOOK_PATH } from '@ghostfolio/common/config'; +import { + DEFAULT_HOST, + DEFAULT_PORT, + STORYBOOK_PATH +} from '@ghostfolio/common/config'; import { Logger, @@ -75,8 +79,8 @@ async function bootstrap() { app.use(HtmlTemplateMiddleware); - const HOST = configService.get('HOST') || '0.0.0.0'; - const PORT = configService.get('PORT') || 3333; + const HOST = configService.get('HOST') || DEFAULT_HOST; + const PORT = configService.get('PORT') || DEFAULT_PORT; await app.listen(PORT, HOST, () => { logLogo(); diff --git a/apps/api/src/middlewares/html-template.middleware.ts b/apps/api/src/middlewares/html-template.middleware.ts index 25687695..403b0961 100644 --- a/apps/api/src/middlewares/html-template.middleware.ts +++ b/apps/api/src/middlewares/html-template.middleware.ts @@ -2,7 +2,6 @@ import { environment } from '@ghostfolio/api/environments/environment'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import { DEFAULT_LANGUAGE_CODE, - DEFAULT_ROOT_URL, STORYBOOK_PATH, SUPPORTED_LANGUAGE_CODES } from '@ghostfolio/common/config'; @@ -126,7 +125,7 @@ export const HtmlTemplateMiddleware = async ( } const currentDate = format(new Date(), DATE_FORMAT); - const rootUrl = process.env.ROOT_URL || DEFAULT_ROOT_URL; + const rootUrl = process.env.ROOT_URL || environment.rootUrl; if ( path.startsWith('/api/') || diff --git a/apps/api/src/services/configuration/configuration.service.ts b/apps/api/src/services/configuration/configuration.service.ts index 3dfe5d5c..602ad5e9 100644 --- a/apps/api/src/services/configuration/configuration.service.ts +++ b/apps/api/src/services/configuration/configuration.service.ts @@ -1,11 +1,13 @@ +import { environment } from '@ghostfolio/api/environments/environment'; import { Environment } from '@ghostfolio/api/services/interfaces/environment.interface'; import { CACHE_TTL_NO_CACHE, + DEFAULT_HOST, + DEFAULT_PORT, DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY, DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY, DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY, - DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT, - DEFAULT_ROOT_URL + DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT } from '@ghostfolio/common/config'; import { Injectable } from '@nestjs/common'; @@ -38,6 +40,9 @@ export class ConfigurationService { DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: json({ default: [] }), + DATA_SOURCES_LEGACY: json({ + default: [] + }), ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }), ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }), ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }), @@ -49,11 +54,11 @@ export class ConfigurationService { GOOGLE_SHEETS_ACCOUNT: str({ default: '' }), GOOGLE_SHEETS_ID: str({ default: '' }), GOOGLE_SHEETS_PRIVATE_KEY: str({ default: '' }), - HOST: host({ default: '0.0.0.0' }), + HOST: host({ default: DEFAULT_HOST }), JWT_SECRET_KEY: str({}), MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }), MAX_CHART_ITEMS: num({ default: 365 }), - PORT: port({ default: 3333 }), + PORT: port({ default: DEFAULT_PORT }), PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY: num({ default: DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY }), @@ -71,7 +76,9 @@ export class ConfigurationService { REDIS_PASSWORD: str({ default: '' }), REDIS_PORT: port({ default: 6379 }), REQUEST_TIMEOUT: num({ default: ms('3 seconds') }), - ROOT_URL: url({ default: DEFAULT_ROOT_URL }), + ROOT_URL: url({ + default: environment.rootUrl + }), STRIPE_PUBLIC_KEY: str({ default: '' }), STRIPE_SECRET_KEY: str({ default: '' }), TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }), diff --git a/apps/api/src/services/cron.service.ts b/apps/api/src/services/cron.service.ts index 7a1b30b5..088348d8 100644 --- a/apps/api/src/services/cron.service.ts +++ b/apps/api/src/services/cron.service.ts @@ -57,7 +57,7 @@ export class CronService { public async runEverySundayAtTwelvePm() { if (await this.isDataGatheringEnabled()) { const assetProfileIdentifiers = - await this.dataGatheringService.getAllAssetProfileIdentifiers(); + await this.dataGatheringService.getAllActiveAssetProfileIdentifiers(); await this.dataGatheringService.addJobsToQueue( assetProfileIdentifiers.map(({ dataSource, symbol }) => { diff --git a/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts b/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts index 78644726..d876b42c 100644 --- a/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts +++ b/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts @@ -170,7 +170,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { symbol = quotes[0].symbol; } } catch {} - } else if (symbol?.includes('-')) { + } else if (symbol?.endsWith(`-${DEFAULT_CURRENCY}`)) { throw new Error(`${symbol} is not valid`); } else { symbol = this.convertToYahooFinanceSymbol(symbol); diff --git a/apps/api/src/services/data-provider/data-provider.service.ts b/apps/api/src/services/data-provider/data-provider.service.ts index 7c0df7a9..88ae136a 100644 --- a/apps/api/src/services/data-provider/data-provider.service.ts +++ b/apps/api/src/services/data-provider/data-provider.service.ts @@ -30,7 +30,7 @@ import type { Granularity, UserWithSettings } from '@ghostfolio/common/types'; import { Inject, Injectable, Logger } from '@nestjs/common'; import { DataSource, MarketData, SymbolProfile } from '@prisma/client'; import { Big } from 'big.js'; -import { eachDayOfInterval, format, isValid } from 'date-fns'; +import { eachDayOfInterval, format, isBefore, isValid } from 'date-fns'; import { groupBy, isEmpty, isNumber, uniqWith } from 'lodash'; import ms from 'ms'; @@ -154,9 +154,22 @@ export class DataProviderService { return DataSource[this.configurationService.get('DATA_SOURCE_IMPORT')]; } - public async getDataSources(): Promise { + public async getDataSources({ + user + }: { + user: UserWithSettings; + }): Promise { + let dataSourcesKey: 'DATA_SOURCES' | 'DATA_SOURCES_LEGACY' = 'DATA_SOURCES'; + + if ( + isBefore(user.createdAt, new Date('2025-03-23')) && + this.configurationService.get('DATA_SOURCES_LEGACY')?.length > 0 + ) { + dataSourcesKey = 'DATA_SOURCES_LEGACY'; + } + const dataSources: DataSource[] = this.configurationService - .get('DATA_SOURCES') + .get(dataSourcesKey) .map((dataSource) => { return DataSource[dataSource]; }); @@ -608,7 +621,7 @@ export class DataProviderService { return { items: lookupItems }; } - const dataSources = await this.getDataSources(); + const dataSources = await this.getDataSources({ user }); const dataProviderServices = dataSources.map((dataSource) => { return this.getDataProvider(DataSource[dataSource]); diff --git a/apps/api/src/services/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts index 2f94739f..c0e22842 100644 --- a/apps/api/src/services/interfaces/environment.interface.ts +++ b/apps/api/src/services/interfaces/environment.interface.ts @@ -16,6 +16,7 @@ export interface Environment extends CleanedEnvAccessors { DATA_SOURCE_IMPORT: string; DATA_SOURCES: string[]; DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: string[]; + DATA_SOURCES_LEGACY: string[]; ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean; ENABLE_FEATURE_READ_ONLY_MODE: boolean; ENABLE_FEATURE_SOCIAL_LOGIN: boolean; diff --git a/apps/api/src/services/queues/data-gathering/data-gathering.service.ts b/apps/api/src/services/queues/data-gathering/data-gathering.service.ts index b3e7de0b..90a26931 100644 --- a/apps/api/src/services/queues/data-gathering/data-gathering.service.ts +++ b/apps/api/src/services/queues/data-gathering/data-gathering.service.ts @@ -159,7 +159,8 @@ export class DataGatheringService { ); if (!assetProfileIdentifiers) { - assetProfileIdentifiers = await this.getAllAssetProfileIdentifiers(); + assetProfileIdentifiers = + await this.getAllActiveAssetProfileIdentifiers(); } if (assetProfileIdentifiers.length <= 0) { @@ -296,11 +297,14 @@ export class DataGatheringService { ); } - public async getAllAssetProfileIdentifiers(): Promise< + public async getAllActiveAssetProfileIdentifiers(): Promise< AssetProfileIdentifier[] > { const symbolProfiles = await this.prismaService.symbolProfile.findMany({ - orderBy: [{ symbol: 'asc' }] + orderBy: [{ symbol: 'asc' }], + where: { + isActive: true + } }); return symbolProfiles @@ -370,9 +374,11 @@ export class DataGatheringService { withUserSubscription?: boolean; }): Promise { const symbolProfiles = - await this.symbolProfileService.getSymbolProfilesByUserSubscription({ - withUserSubscription - }); + await this.symbolProfileService.getActiveSymbolProfilesByUserSubscription( + { + withUserSubscription + } + ); const assetProfileIdentifiersWithCompleteMarketData = await this.getAssetProfileIdentifiersWithCompleteMarketData(); @@ -436,6 +442,9 @@ export class DataGatheringService { }, scraperConfiguration: true, symbol: true + }, + where: { + isActive: true } }) ) diff --git a/apps/api/src/services/symbol-profile/symbol-profile.service.ts b/apps/api/src/services/symbol-profile/symbol-profile.service.ts index 0dae6331..e9c568ce 100644 --- a/apps/api/src/services/symbol-profile/symbol-profile.service.ts +++ b/apps/api/src/services/symbol-profile/symbol-profile.service.ts @@ -35,6 +35,41 @@ export class SymbolProfileService { }); } + public async getActiveSymbolProfilesByUserSubscription({ + withUserSubscription = false + }: { + withUserSubscription?: boolean; + }) { + return this.prismaService.symbolProfile.findMany({ + include: { + Order: { + include: { + User: true + } + } + }, + orderBy: [{ symbol: 'asc' }], + where: { + isActive: true, + Order: withUserSubscription + ? { + some: { + User: { + Subscription: { some: { expiresAt: { gt: new Date() } } } + } + } + } + : { + every: { + User: { + Subscription: { none: { expiresAt: { gt: new Date() } } } + } + } + } + } + }); + } + public async getSymbolProfiles( aAssetProfileIdentifiers: AssetProfileIdentifier[] ): Promise { @@ -91,40 +126,6 @@ export class SymbolProfileService { }); } - public async getSymbolProfilesByUserSubscription({ - withUserSubscription = false - }: { - withUserSubscription?: boolean; - }) { - return this.prismaService.symbolProfile.findMany({ - include: { - Order: { - include: { - User: true - } - } - }, - orderBy: [{ symbol: 'asc' }], - where: { - Order: withUserSubscription - ? { - some: { - User: { - Subscription: { some: { expiresAt: { gt: new Date() } } } - } - } - } - : { - every: { - User: { - Subscription: { none: { expiresAt: { gt: new Date() } } } - } - } - } - } - }); - } - public updateSymbolProfile({ assetClass, assetSubClass, @@ -133,6 +134,7 @@ export class SymbolProfileService { currency, dataSource, holdings, + isActive, name, scraperConfiguration, sectors, @@ -149,6 +151,7 @@ export class SymbolProfileService { countries, currency, holdings, + isActive, name, scraperConfiguration, sectors, diff --git a/apps/client/ngsw-config.json b/apps/client/ngsw-config.json index c0f03a13..56e1cfd6 100644 --- a/apps/client/ngsw-config.json +++ b/apps/client/ngsw-config.json @@ -6,13 +6,7 @@ "name": "app", "installMode": "prefetch", "resources": { - "files": [ - "/favicon.ico", - "/index.html", - "/assets/site.webmanifest", - "/*.css", - "/*.js" - ] + "files": ["/favicon.ico", "/index.html", "/*.css", "/*.js"] } }, { diff --git a/apps/client/project.json b/apps/client/project.json index 160a27ea..b2144d7b 100644 --- a/apps/client/project.json +++ b/apps/client/project.json @@ -146,9 +146,6 @@ { "command": "shx cp apps/client/src/assets/robots.txt dist/apps/client" }, - { - "command": "shx cp apps/client/src/assets/site.webmanifest dist/apps/client" - }, { "command": "shx cp node_modules/ionicons/dist/index.js dist/apps/client" }, diff --git a/apps/client/src/app/components/admin-users/admin-users.component.ts b/apps/client/src/app/components/admin-users/admin-users.component.ts index 50b8cb5f..e1cd3102 100644 --- a/apps/client/src/app/components/admin-users/admin-users.component.ts +++ b/apps/client/src/app/components/admin-users/admin-users.component.ts @@ -1,9 +1,4 @@ -import { ConfirmationDialogType } from '@ghostfolio/client/core/notification/confirmation-dialog/confirmation-dialog.type'; -import { NotificationService } from '@ghostfolio/client/core/notification/notification.service'; -import { AdminService } from '@ghostfolio/client/services/admin.service'; -import { DataService } from '@ghostfolio/client/services/data.service'; -import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; -import { UserService } from '@ghostfolio/client/services/user/user.service'; +import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config'; import { getDateFormatString, getEmojiFlag } from '@ghostfolio/common/helper'; import { AdminUsers, InfoItem, User } from '@ghostfolio/common/interfaces'; @@ -26,11 +21,18 @@ import { import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; +import { ConfirmationDialogType } from '../../core/notification/confirmation-dialog/confirmation-dialog.type'; +import { NotificationService } from '../../core/notification/notification.service'; +import { AdminService } from '../../services/admin.service'; +import { DataService } from '../../services/data.service'; +import { ImpersonationStorageService } from '../../services/impersonation-storage.service'; +import { UserService } from '../../services/user/user.service'; + @Component({ selector: 'gf-admin-users', + standalone: false, styleUrls: ['./admin-users.scss'], - templateUrl: './admin-users.html', - standalone: false + templateUrl: './admin-users.html' }) export class AdminUsersComponent implements OnDestroy, OnInit { @ViewChild(MatPaginator) paginator: MatPaginator; @@ -55,6 +57,7 @@ export class AdminUsersComponent implements OnDestroy, OnInit { private dataService: DataService, private impersonationStorageService: ImpersonationStorageService, private notificationService: NotificationService, + private tokenStorageService: TokenStorageService, private userService: UserService ) { this.info = this.dataService.fetchInfo(); @@ -140,6 +143,32 @@ export class AdminUsersComponent implements OnDestroy, OnInit { }); } + public onGenerateAccessToken(aUserId: string) { + this.notificationService.confirm({ + confirmFn: () => { + this.dataService + .generateAccessToken(aUserId) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(({ accessToken }) => { + this.notificationService.alert({ + discardFn: () => { + if (aUserId === this.user.id) { + this.tokenStorageService.signOut(); + this.userService.remove(); + + document.location.href = `/${document.documentElement.lang}`; + } + }, + message: accessToken, + title: $localize`Security token` + }); + }); + }, + confirmType: ConfirmationDialogType.Warn, + title: $localize`Do you really want to generate a new security token for this user?` + }); + } + public onImpersonateUser(aId: string) { if (aId) { this.impersonationStorageService.setId(aId); diff --git a/apps/client/src/app/components/admin-users/admin-users.html b/apps/client/src/app/components/admin-users/admin-users.html index ca8ef055..e8725e70 100644 --- a/apps/client/src/app/components/admin-users/admin-users.html +++ b/apps/client/src/app/components/admin-users/admin-users.html @@ -239,8 +239,17 @@ Impersonate User -
} + +