diff --git a/.env.example b/.env.example index f7bd74856..8df547e37 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,5 @@ POSTGRES_USER=user POSTGRES_PASSWORD= ACCESS_TOKEN_SALT= -ALPHA_VANTAGE_API_KEY= DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer JWT_SECRET_KEY= diff --git a/CHANGELOG.md b/CHANGELOG.md index eaa15507f..07b89aea6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,15 +5,146 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## Unreleased +## 2.9.0 - 2023-10-08 + +### Added + +- Added support to search for a holding by `isin`, `name` and `symbol` (experimental) +- Added support for notes in the activities import +- Added support to search in the platform selector of the create or update account dialog +- Added support for a search query in the portfolio position endpoint +- Added the application version to the endpoint `GET api/v1/admin` +- Introduced a carousel component for the testimonial section on the landing page + +### Changed + +- Displayed the link to the markets overview on the home page without any permission + +### Fixed + +- Fixed the style of the active features page in the navigation on desktop + +## 2.8.0 - 2023-10-03 + +### Added + +- Supported enter key press to submit the form of the create or update account dialog +- Added the application version to the admin control panel +- Added pagination parameters (`skip`, `take`) to the endpoint `GET api/v1/order` + +### Changed + +- Harmonized the settings icon of the user account page +- Improved the usability to set an asset profile as a benchmark +- Reload platforms after making a change in the admin control panel +- Reload tags after making a change in the admin control panel + +### Fixed + +- Fixed the sidebar navigation on the user account page + +## 2.7.0 - 2023-09-30 + +### Added + +- Added a new static portfolio analysis rule: Emergency fund setup +- Added tabs to the user account page + +### Changed + +- Set up the _Inter_ font family +- Upgraded `yahoo-finance2` from version `2.7.0` to `2.8.0` + +### Fixed + +- Fixed a link on the features page + +## 2.6.0 - 2023-09-26 + +### Added + +- Added the management of tags in the admin control panel +- Added a blog post: _Hacktoberfest 2023_ + +### Changed + +- Upgraded `prettier` from version `3.0.2` to `3.0.3` +- Upgraded `yahoo-finance2` from version `2.5.0` to `2.7.0` + +## 2.5.0 - 2023-09-23 + +### Added + +- Added support for translated activity types in the activities table +- Added support for dates in `DD.MM.YYYY` format in the activities import +- Set up the language localization for Türkçe (`tr`) + +### Changed + +- Skipped creating queue jobs for asset profiles with `MANUAL` data source on creating a new activity + +### Fixed + +- Fixed an issue with the cash position in the holdings table + +## 2.4.0 - 2023-09-19 + +### Added + +- Added support for interest on account level (experimental) + +### Changed + +- Improved the preselected currency based on the account's currency in the create or edit activity dialog +- Unlocked the experimental features setting for all users +- Upgraded `prisma` from version `5.2.0` to `5.3.1` + +### Fixed + +- Fixed a memory leak related to the server's timezone (behind UTC) in the data gathering + +## 2.3.0 - 2023-09-17 + +### Added + +- Added support for fees on account level (experimental) + +### Fixed + +- Fixed the export functionality for liabilities + +## 2.2.0 - 2023-09-17 + +### Added + +- Introduced a sidebar navigation on desktop ### Changed -- Harmonized the logger output: () +- Improved the style of the system message +- Upgraded _Postgres_ from version `12` to `15` in the `docker-compose` files + +## 2.1.0 - 2023-09-15 + +### Added + +- Added support to drop a file in the import activities dialog +- Added a timeout to all data source requests + +### Changed + +- Harmonized the style of the user interface for granting and revoking public access to share the portfolio +- Removed the account type from the user interface as a preparation to remove it from the `Account` database schema +- Improved the logger output of the info service +- Harmonized the logger output: ` ()` +- Improved the language localization for German (`de`) +- Improved the language localization for Italian (`it`) - Improved the language localization for Dutch (`nl`) +- Improved the read-only mode ### Fixed +- Fixed the timeout in _EOD Historical Data_ requests - Fixed an issue with the portfolio summary caused by the language localization for Dutch (`nl`) ## 2.0.0 - 2023-09-09 @@ -1520,7 +1651,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Set up the language localization for Italiano (`it`) +- Set up the language localization for Italian (`it`) - Extended the landing page ## 1.195.0 - 20.09.2022 @@ -2943,7 +3074,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Supported the management of additional currencies in the admin control panel - Introduced the system message -- Introduced the read only mode +- Introduced the read-only mode ### Changed diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index b73831d98..a950e5672 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -18,6 +18,12 @@ ### Prisma +#### Access database via GUI + +Run `yarn database:gui` + +https://www.prisma.io/studio + #### Synchronize schema with database for prototyping Run `yarn database:push` diff --git a/README.md b/README.md index e514bf2b4..87e6c7d1b 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ New: [Ghostfolio 2.0](https://ghostfol.io/en/blog/2023/09/ghostfolio-2) ## Ghostfolio Premium -Our official **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs. +Our official **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. Revenue is used to cover the costs of the hosting infrastructure and to fund ongoing development. If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the [Self-hosting](#self-hosting) section. diff --git a/apps/api/src/app/account/create-account.dto.ts b/apps/api/src/app/account/create-account.dto.ts index c700e7fa9..fff982ecf 100644 --- a/apps/api/src/app/account/create-account.dto.ts +++ b/apps/api/src/app/account/create-account.dto.ts @@ -10,8 +10,9 @@ import { import { isString } from 'lodash'; export class CreateAccountDto { + @IsOptional() @IsString() - accountType: AccountType; + accountType?: AccountType; @IsNumber() balance: number; diff --git a/apps/api/src/app/account/transfer-balance.dto.ts b/apps/api/src/app/account/transfer-balance.dto.ts new file mode 100644 index 000000000..fb602033e --- /dev/null +++ b/apps/api/src/app/account/transfer-balance.dto.ts @@ -0,0 +1,12 @@ +import { IsNumber, IsString } from 'class-validator'; + +export class TransferBalanceDto { + @IsString() + accountIdFrom: string; + + @IsString() + accountIdTo: string; + + @IsNumber() + balance: number; +} diff --git a/apps/api/src/app/account/update-account.dto.ts b/apps/api/src/app/account/update-account.dto.ts index d8c62aff7..7ab829454 100644 --- a/apps/api/src/app/account/update-account.dto.ts +++ b/apps/api/src/app/account/update-account.dto.ts @@ -10,8 +10,9 @@ import { import { isString } from 'lodash'; export class UpdateAccountDto { + @IsOptional() @IsString() - accountType: AccountType; + accountType?: AccountType; @IsNumber() balance: number; diff --git a/apps/api/src/app/admin/admin.service.ts b/apps/api/src/app/admin/admin.service.ts index b1b6b9418..dd9e3f9ce 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -1,4 +1,5 @@ import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; +import { environment } from '@ghostfolio/api/environments/environment'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; @@ -8,7 +9,9 @@ import { PropertyService } from '@ghostfolio/api/services/property/property.serv import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { DEFAULT_CURRENCY, - PROPERTY_CURRENCIES + PROPERTY_CURRENCIES, + PROPERTY_IS_READ_ONLY_MODE, + PROPERTY_IS_USER_SIGNUP_ENABLED } from '@ghostfolio/common/config'; import { AdminData, @@ -95,7 +98,8 @@ export class AdminService { settings: await this.propertyService.get(), transactionCount: await this.prismaService.order.count(), userCount: await this.prismaService.user.count(), - users: await this.getUsersWithAnalytics() + users: await this.getUsersWithAnalytics(), + version: environment.version }; } @@ -305,7 +309,9 @@ export class AdminService { response = await this.propertyService.delete({ key }); } - if (key === PROPERTY_CURRENCIES) { + if (key === PROPERTY_IS_READ_ONLY_MODE && value === 'true') { + await this.putSetting(PROPERTY_IS_USER_SIGNUP_ENABLED, 'false'); + } else if (key === PROPERTY_CURRENCIES) { await this.exchangeRateDataService.initialize(); } diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index a521e7fa9..03c6a4aaa 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -39,6 +39,7 @@ import { RedisCacheModule } from './redis-cache/redis-cache.module'; import { SitemapModule } from './sitemap/sitemap.module'; import { SubscriptionModule } from './subscription/subscription.module'; import { SymbolModule } from './symbol/symbol.module'; +import { TagModule } from './tag/tag.module'; import { UserModule } from './user/user.module'; @Module({ @@ -101,6 +102,7 @@ import { UserModule } from './user/user.module'; SitemapModule, SubscriptionModule, SymbolModule, + TagModule, TwitterBotModule, UserModule ], diff --git a/apps/api/src/app/benchmark/benchmark.controller.ts b/apps/api/src/app/benchmark/benchmark.controller.ts index d59a231ff..2230ff42b 100644 --- a/apps/api/src/app/benchmark/benchmark.controller.ts +++ b/apps/api/src/app/benchmark/benchmark.controller.ts @@ -10,6 +10,7 @@ import type { RequestWithUser } from '@ghostfolio/common/types'; import { Body, Controller, + Delete, Get, HttpException, Inject, @@ -32,35 +33,49 @@ export class BenchmarkController { @Inject(REQUEST) private readonly request: RequestWithUser ) {} - @Get() - @UseInterceptors(TransformDataSourceInRequestInterceptor) - @UseInterceptors(TransformDataSourceInResponseInterceptor) - public async getBenchmark(): Promise { - return { - benchmarks: await this.benchmarkService.getBenchmarks() - }; - } - - @Get(':dataSource/:symbol/:startDateString') + @Post() @UseGuards(AuthGuard('jwt')) - @UseInterceptors(TransformDataSourceInRequestInterceptor) - public async getBenchmarkMarketDataBySymbol( - @Param('dataSource') dataSource: DataSource, - @Param('startDateString') startDateString: string, - @Param('symbol') symbol: string - ): Promise { - const startDate = new Date(startDateString); + public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) { + if ( + !hasPermission( + this.request.user.permissions, + permissions.accessAdminControl + ) + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } - return this.benchmarkService.getMarketDataBySymbol({ - dataSource, - startDate, - symbol - }); + try { + const benchmark = await this.benchmarkService.addBenchmark({ + dataSource, + symbol + }); + + if (!benchmark) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + return benchmark; + } catch { + throw new HttpException( + getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), + StatusCodes.INTERNAL_SERVER_ERROR + ); + } } - @Post() + @Delete(':dataSource/:symbol') @UseGuards(AuthGuard('jwt')) - public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) { + public async deleteBenchmark( + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string + ) { if ( !hasPermission( this.request.user.permissions, @@ -74,7 +89,7 @@ export class BenchmarkController { } try { - const benchmark = await this.benchmarkService.addBenchmark({ + const benchmark = await this.benchmarkService.deleteBenchmark({ dataSource, symbol }); @@ -94,4 +109,30 @@ export class BenchmarkController { ); } } + + @Get() + @UseInterceptors(TransformDataSourceInRequestInterceptor) + @UseInterceptors(TransformDataSourceInResponseInterceptor) + public async getBenchmark(): Promise { + return { + benchmarks: await this.benchmarkService.getBenchmarks() + }; + } + + @Get(':dataSource/:symbol/:startDateString') + @UseGuards(AuthGuard('jwt')) + @UseInterceptors(TransformDataSourceInRequestInterceptor) + public async getBenchmarkMarketDataBySymbol( + @Param('dataSource') dataSource: DataSource, + @Param('startDateString') startDateString: string, + @Param('symbol') symbol: string + ): Promise { + const startDate = new Date(startDateString); + + return this.benchmarkService.getMarketDataBySymbol({ + dataSource, + startDate, + symbol + }); + } } diff --git a/apps/api/src/app/benchmark/benchmark.service.ts b/apps/api/src/app/benchmark/benchmark.service.ts index 785c2801a..7fe1911a4 100644 --- a/apps/api/src/app/benchmark/benchmark.service.ts +++ b/apps/api/src/app/benchmark/benchmark.service.ts @@ -245,6 +245,43 @@ export class BenchmarkService { }; } + public async deleteBenchmark({ + dataSource, + symbol + }: UniqueAsset): Promise> { + const assetProfile = await this.prismaService.symbolProfile.findFirst({ + where: { + dataSource, + symbol + } + }); + + if (!assetProfile) { + return null; + } + + let benchmarks = + ((await this.propertyService.getByKey( + PROPERTY_BENCHMARKS + )) as BenchmarkProperty[]) ?? []; + + benchmarks = benchmarks.filter(({ symbolProfileId }) => { + return symbolProfileId !== assetProfile.id; + }); + + await this.propertyService.put({ + key: PROPERTY_BENCHMARKS, + value: JSON.stringify(benchmarks) + }); + + return { + dataSource, + symbol, + id: assetProfile.id, + name: assetProfile.name + }; + } + private getMarketCondition(aPerformanceInPercent: number) { return aPerformanceInPercent <= -0.2 ? 'BEAR_MARKET' : 'NEUTRAL_MARKET'; } diff --git a/apps/api/src/app/export/export.service.ts b/apps/api/src/app/export/export.service.ts index abeaf389d..2134a6520 100644 --- a/apps/api/src/app/export/export.service.ts +++ b/apps/api/src/app/export/export.service.ts @@ -26,18 +26,8 @@ export class ExportService { where: { userId } }) ).map( - ({ - accountType, - balance, - comment, - currency, - id, - isExcluded, - name, - platformId - }) => { + ({ balance, comment, currency, id, isExcluded, name, platformId }) => { return { - accountType, balance, comment, currency, @@ -87,7 +77,13 @@ export class ExportService { currency: SymbolProfile.currency, dataSource: SymbolProfile.dataSource, date: date.toISOString(), - symbol: type === 'ITEM' ? SymbolProfile.name : SymbolProfile.symbol + symbol: + type === 'FEE' || + type === 'INTEREST' || + type === 'ITEM' || + type === 'LIABILITY' + ? SymbolProfile.name + : SymbolProfile.symbol }; } ) diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index da0e4806c..83d062b83 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -410,7 +410,7 @@ export class ImportService { currency, userCurrency ), - //@ts-ignore + // @ts-ignore SymbolProfile: assetProfile, valueInBaseCurrency: this.exchangeRateDataService.toCurrency( value, diff --git a/apps/api/src/app/info/info.service.ts b/apps/api/src/app/info/info.service.ts index ec294bc25..4fc4aec4e 100644 --- a/apps/api/src/app/info/info.service.ts +++ b/apps/api/src/app/info/info.service.ts @@ -8,6 +8,7 @@ import { PropertyService } from '@ghostfolio/api/services/property/property.serv import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { DEFAULT_CURRENCY, + DEFAULT_REQUEST_TIMEOUT, PROPERTY_BETTER_UPTIME_MONITOR_ID, PROPERTY_COUNTRIES_OF_SUBSCRIBERS, PROPERTY_DEMO_USER_ID, @@ -54,12 +55,8 @@ export class InfoService { public async get(): Promise { const info: Partial = {}; let isReadOnlyMode: boolean; - const platforms = ( - await this.platformService.getPlatforms({ - orderBy: { name: 'asc' } - }) - ).map(({ id, name }) => { - return { id, name }; + const platforms = await this.platformService.getPlatforms({ + orderBy: { name: 'asc' } }); let systemMessage: string; @@ -168,16 +165,24 @@ export class InfoService { private async countDockerHubPulls(): Promise { try { + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + const { pull_count } = await got( `https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`, { - headers: { 'User-Agent': 'request' } + headers: { 'User-Agent': 'request' }, + // @ts-ignore + signal: abortController.signal } ).json(); return pull_count; } catch (error) { - Logger.error(error, 'InfoService'); + Logger.error(error, 'InfoService - DockerHub'); return undefined; } @@ -185,7 +190,16 @@ export class InfoService { private async countGitHubContributors(): Promise { try { - const { body } = await got('https://github.com/ghostfolio/ghostfolio'); + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + const { body } = await got('https://github.com/ghostfolio/ghostfolio', { + // @ts-ignore + signal: abortController.signal + }); const $ = cheerio.load(body); @@ -195,7 +209,7 @@ export class InfoService { ).text() ); } catch (error) { - Logger.error(error, 'InfoService'); + Logger.error(error, 'InfoService - GitHub'); return undefined; } @@ -203,16 +217,24 @@ export class InfoService { private async countGitHubStargazers(): Promise { try { + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + const { stargazers_count } = await got( `https://api.github.com/repos/ghostfolio/ghostfolio`, { - headers: { 'User-Agent': 'request' } + headers: { 'User-Agent': 'request' }, + // @ts-ignore + signal: abortController.signal } ).json(); return stargazers_count; } catch (error) { - Logger.error(error, 'InfoService'); + Logger.error(error, 'InfoService - GitHub'); return undefined; } @@ -323,24 +345,31 @@ export class InfoService { PROPERTY_BETTER_UPTIME_MONITOR_ID )) as string; + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + const { data } = await got( - `https://betteruptime.com/api/v2/monitors/${monitorId}/sla?from=${format( + `https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format( subDays(new Date(), 90), DATE_FORMAT )}&to${format(new Date(), DATE_FORMAT)}`, - { headers: { Authorization: `Bearer ${this.configurationService.get( 'BETTER_UPTIME_API_KEY' )}` - } + }, + // @ts-ignore + signal: abortController.signal } ).json(); return data.attributes.availability / 100; } catch (error) { - Logger.error(error, 'InfoService'); + Logger.error(error, 'InfoService - Better Stack'); return undefined; } diff --git a/apps/api/src/app/logo/logo.service.ts b/apps/api/src/app/logo/logo.service.ts index 166143a75..80ae1d6a9 100644 --- a/apps/api/src/app/logo/logo.service.ts +++ b/apps/api/src/app/logo/logo.service.ts @@ -1,4 +1,5 @@ import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config'; import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { HttpException, Injectable } from '@nestjs/common'; import { DataSource } from '@prisma/client'; @@ -41,10 +42,18 @@ export class LogoService { } private getBuffer(aUrl: string) { + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + return got( `https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`, { - headers: { 'User-Agent': 'request' } + headers: { 'User-Agent': 'request' }, + // @ts-ignore + signal: abortController.signal } ).buffer(); } diff --git a/apps/api/src/app/order/order.controller.ts b/apps/api/src/app/order/order.controller.ts index 0e617462e..8c8e3e27a 100644 --- a/apps/api/src/app/order/order.controller.ts +++ b/apps/api/src/app/order/order.controller.ts @@ -89,7 +89,9 @@ export class OrderController { @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId, @Query('accounts') filterByAccounts?: string, @Query('assetClasses') filterByAssetClasses?: string, - @Query('tags') filterByTags?: string + @Query('skip') skip?: number, + @Query('tags') filterByTags?: string, + @Query('take') take?: number ): Promise { const filters = this.apiService.buildFiltersFromQueryParams({ filterByAccounts, @@ -105,6 +107,8 @@ export class OrderController { filters, userCurrency, includeDrafts: true, + skip: isNaN(skip) ? undefined : skip, + take: isNaN(take) ? undefined : take, userId: impersonationUserId || this.request.user.id, withExcludedAccounts: true }); @@ -147,8 +151,9 @@ export class OrderController { userId: this.request.user.id }); - if (!order.isDraft) { - // Gather symbol data in the background, if not draft + if (data.dataSource && !order.isDraft) { + // Gather symbol data in the background, if data source is set + // (not MANUAL) and not draft this.dataGatheringService.gatherSymbols([ { dataSource: data.dataSource, diff --git a/apps/api/src/app/order/order.service.ts b/apps/api/src/app/order/order.service.ts index 0f7da5da3..10515018c 100644 --- a/apps/api/src/app/order/order.service.ts +++ b/apps/api/src/app/order/order.service.ts @@ -97,7 +97,12 @@ export class OrderService { const updateAccountBalance = data.updateAccountBalance ?? false; const userId = data.userId; - if (data.type === 'ITEM' || data.type === 'LIABILITY') { + if ( + data.type === 'FEE' || + data.type === 'INTEREST' || + data.type === 'ITEM' || + data.type === 'LIABILITY' + ) { const assetClass = data.assetClass; const assetSubClass = data.assetSubClass; currency = data.SymbolProfile.connectOrCreate.create.currency; @@ -118,20 +123,22 @@ export class OrderService { }; } - this.dataGatheringService.addJobToQueue({ - data: { - dataSource: data.SymbolProfile.connectOrCreate.create.dataSource, - symbol: data.SymbolProfile.connectOrCreate.create.symbol - }, - name: GATHER_ASSET_PROFILE_PROCESS, - opts: { - ...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, - jobId: getAssetProfileIdentifier({ + if (data.SymbolProfile.connectOrCreate.create.dataSource !== 'MANUAL') { + this.dataGatheringService.addJobToQueue({ + data: { dataSource: data.SymbolProfile.connectOrCreate.create.dataSource, symbol: data.SymbolProfile.connectOrCreate.create.symbol - }) - } - }); + }, + name: GATHER_ASSET_PROFILE_PROCESS, + opts: { + ...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, + jobId: getAssetProfileIdentifier({ + dataSource: data.SymbolProfile.connectOrCreate.create.dataSource, + symbol: data.SymbolProfile.connectOrCreate.create.symbol + }) + } + }); + } delete data.accountId; delete data.assetClass; @@ -151,6 +158,9 @@ export class OrderService { const orderData: Prisma.OrderCreateInput = data; const isDraft = + data.type === 'FEE' || + data.type === 'INTEREST' || + data.type === 'ITEM' || data.type === 'LIABILITY' ? false : isAfter(data.date as Date, endOfToday()); @@ -197,7 +207,12 @@ export class OrderService { where }); - if (order.type === 'ITEM' || order.type === 'LIABILITY') { + if ( + order.type === 'FEE' || + order.type === 'INTEREST' || + order.type === 'ITEM' || + order.type === 'LIABILITY' + ) { await this.symbolProfileService.deleteById(order.symbolProfileId); } @@ -215,6 +230,8 @@ export class OrderService { public async getOrders({ filters, includeDrafts = false, + skip, + take = Number.MAX_SAFE_INTEGER, types, userCurrency, userId, @@ -222,6 +239,8 @@ export class OrderService { }: { filters?: Filter[]; includeDrafts?: boolean; + skip?: number; + take?: number; types?: TypeOfOrder[]; userCurrency: string; userId: string; @@ -300,6 +319,8 @@ export class OrderService { return ( await this.orders({ + skip, + take, where, include: { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -368,7 +389,12 @@ export class OrderService { let isDraft = false; - if (data.type === 'ITEM' || data.type === 'LIABILITY') { + if ( + data.type === 'FEE' || + data.type === 'INTEREST' || + data.type === 'ITEM' || + data.type === 'LIABILITY' + ) { delete data.SymbolProfile.connect; } else { delete data.SymbolProfile.update; diff --git a/apps/api/src/app/platform/platform.controller.ts b/apps/api/src/app/platform/platform.controller.ts index 8c4d62bd6..0369da8a3 100644 --- a/apps/api/src/app/platform/platform.controller.ts +++ b/apps/api/src/app/platform/platform.controller.ts @@ -47,6 +47,7 @@ export class PlatformController { StatusCodes.FORBIDDEN ); } + return this.platformService.createPlatform(data); } diff --git a/apps/api/src/app/platform/platform.service.ts b/apps/api/src/app/platform/platform.service.ts index 12d107604..35730d041 100644 --- a/apps/api/src/app/platform/platform.service.ts +++ b/apps/api/src/app/platform/platform.service.ts @@ -6,6 +6,18 @@ import { Platform, Prisma } from '@prisma/client'; export class PlatformService { public constructor(private readonly prismaService: PrismaService) {} + public async createPlatform(data: Prisma.PlatformCreateInput) { + return this.prismaService.platform.create({ + data + }); + } + + public async deletePlatform( + where: Prisma.PlatformWhereUniqueInput + ): Promise { + return this.prismaService.platform.delete({ where }); + } + public async getPlatform( platformWhereUniqueInput: Prisma.PlatformWhereUniqueInput ): Promise { @@ -56,12 +68,6 @@ export class PlatformService { }); } - public async createPlatform(data: Prisma.PlatformCreateInput) { - return this.prismaService.platform.create({ - data - }); - } - public async updatePlatform({ data, where @@ -74,10 +80,4 @@ export class PlatformService { where }); } - - public async deletePlatform( - where: Prisma.PlatformWhereUniqueInput - ): Promise { - return this.prismaService.platform.delete({ where }); - } } diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index bf3e36431..7ec32d59c 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -208,8 +208,14 @@ export class PortfolioController { for (const [symbol, portfolioPosition] of Object.entries(holdings)) { holdings[symbol] = { ...portfolioPosition, - assetClass: hasDetails ? portfolioPosition.assetClass : undefined, - assetSubClass: hasDetails ? portfolioPosition.assetSubClass : undefined, + assetClass: + hasDetails || portfolioPosition.assetClass === 'CASH' + ? portfolioPosition.assetClass + : undefined, + assetSubClass: + hasDetails || portfolioPosition.assetSubClass === 'CASH' + ? portfolioPosition.assetSubClass + : undefined, countries: hasDetails ? portfolioPosition.countries : [], currency: hasDetails ? portfolioPosition.currency : undefined, markets: hasDetails ? portfolioPosition.markets : undefined, @@ -420,12 +426,14 @@ export class PortfolioController { @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Query('accounts') filterByAccounts?: string, @Query('assetClasses') filterByAssetClasses?: string, + @Query('query') filterBySearchQuery?: string, @Query('range') dateRange: DateRange = 'max', @Query('tags') filterByTags?: string ): Promise { const filters = this.apiService.buildFiltersFromQueryParams({ filterByAccounts, filterByAssetClasses, + filterBySearchQuery, filterByTags }); diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index ee0dbbcd0..b4f27e800 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -10,6 +10,7 @@ import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rule import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account'; import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment'; import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment'; +import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup'; import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; @@ -50,18 +51,17 @@ import { Inject, Injectable } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { Account, + Type as ActivityType, AssetClass, DataSource, Order, Platform, Prisma, - Tag, - Type as TypeOfOrder + Tag } from '@prisma/client'; import Big from 'big.js'; import { differenceInDays, - endOfToday, format, isAfter, isBefore, @@ -465,8 +465,9 @@ export class PortfolioService { transactionPoints[0]?.date ?? format(new Date(), DATE_FORMAT) ); const startDate = this.getStartDate(dateRange, portfolioStart); - const currentPositions = - await portfolioCalculator.getCurrentPositions(startDate); + const currentPositions = await portfolioCalculator.getCurrentPositions( + startDate + ); const cashDetails = await this.accountService.getCashDetails({ filters, @@ -881,8 +882,9 @@ export class PortfolioService { const transactionPoints = portfolioCalculator.getTransactionPoints(); const portfolioStart = parseDate(transactionPoints[0].date); - const currentPositions = - await portfolioCalculator.getCurrentPositions(portfolioStart); + const currentPositions = await portfolioCalculator.getCurrentPositions( + portfolioStart + ); const position = currentPositions.positions.find( (item) => item.symbol === aSymbol @@ -1103,6 +1105,9 @@ export class PortfolioService { filters?: Filter[]; impersonationId: string; }): Promise<{ hasErrors: boolean; positions: Position[] }> { + const searchQuery = filters.find(({ type }) => { + return type === 'SEARCH_QUERY'; + })?.id; const userId = await this.getUserId(impersonationId, this.request.user.id); const { portfolioOrders, transactionPoints } = @@ -1128,13 +1133,14 @@ export class PortfolioService { const portfolioStart = parseDate(transactionPoints[0].date); const startDate = this.getStartDate(dateRange, portfolioStart); - const currentPositions = - await portfolioCalculator.getCurrentPositions(startDate); - - const positions = currentPositions.positions.filter( - (item) => !item.quantity.eq(0) + const currentPositions = await portfolioCalculator.getCurrentPositions( + startDate ); + let positions = currentPositions.positions.filter(({ quantity }) => { + return !quantity.eq(0); + }); + const dataGatheringItems = positions.map(({ dataSource, symbol }) => { return { dataSource, @@ -1156,6 +1162,18 @@ export class PortfolioService { symbolProfileMap[symbolProfile.symbol] = symbolProfile; } + if (searchQuery) { + positions = positions.filter(({ symbol }) => { + const enhancedSymbolProfile = symbolProfileMap[symbol]; + + return ( + enhancedSymbolProfile.isin?.toLowerCase().startsWith(searchQuery) || + enhancedSymbolProfile.name?.toLowerCase().startsWith(searchQuery) || + enhancedSymbolProfile.symbol?.toLowerCase().startsWith(searchQuery) + ); + }); + } + return { hasErrors: currentPositions.hasErrors, positions: positions.map((position) => { @@ -1304,12 +1322,6 @@ export class PortfolioService { userId }); - if (isEmpty(orders)) { - return { - rules: {} - }; - } - const portfolioCalculator = new PortfolioCalculator({ currency: userCurrency, currentRateService: this.currentRateService, @@ -1318,9 +1330,12 @@ export class PortfolioService { portfolioCalculator.setTransactionPoints(transactionPoints); - const portfolioStart = parseDate(transactionPoints[0].date); - const currentPositions = - await portfolioCalculator.getCurrentPositions(portfolioStart); + const portfolioStart = parseDate( + transactionPoints[0]?.date ?? format(new Date(), DATE_FORMAT) + ); + const currentPositions = await portfolioCalculator.getCurrentPositions( + portfolioStart + ); const positions = currentPositions.positions.filter( (item) => !item.quantity.eq(0) @@ -1339,33 +1354,48 @@ export class PortfolioService { userId }); + const userSettings = this.request.user.Settings.settings; + return { rules: { - accountClusterRisk: await this.rulesService.evaluate( - [ - new AccountClusterRiskCurrentInvestment( - this.exchangeRateDataService, - accounts + accountClusterRisk: isEmpty(orders) + ? undefined + : await this.rulesService.evaluate( + [ + new AccountClusterRiskCurrentInvestment( + this.exchangeRateDataService, + accounts + ), + new AccountClusterRiskSingleAccount( + this.exchangeRateDataService, + accounts + ) + ], + userSettings ), - new AccountClusterRiskSingleAccount( - this.exchangeRateDataService, - accounts - ) - ], - this.request.user.Settings.settings - ), - currencyClusterRisk: await this.rulesService.evaluate( - [ - new CurrencyClusterRiskBaseCurrencyCurrentInvestment( - this.exchangeRateDataService, - positions + currencyClusterRisk: isEmpty(orders) + ? undefined + : await this.rulesService.evaluate( + [ + new CurrencyClusterRiskBaseCurrencyCurrentInvestment( + this.exchangeRateDataService, + positions + ), + new CurrencyClusterRiskCurrentInvestment( + this.exchangeRateDataService, + positions + ) + ], + userSettings ), - new CurrencyClusterRiskCurrentInvestment( + emergencyFund: await this.rulesService.evaluate( + [ + new EmergencyFundSetup( this.exchangeRateDataService, - positions + userSettings.emergencyFund ) ], - this.request.user.Settings.settings + userSettings ), fees: await this.rulesService.evaluate( [ @@ -1375,7 +1405,7 @@ export class PortfolioService { this.getFees({ userCurrency, activities: orders }).toNumber() ) ], - this.request.user.Settings.settings + userSettings ) } }; @@ -1431,36 +1461,6 @@ export class PortfolioService { return cashPositions; } - private getDividend({ - activities, - date = new Date(0), - userCurrency - }: { - activities: OrderWithAccount[]; - date?: Date; - userCurrency: string; - }) { - return activities - .filter((activity) => { - // Filter out all activities before given date (drafts) and type dividend - return ( - isBefore(date, new Date(activity.date)) && - activity.type === TypeOfOrder.DIVIDEND - ); - }) - .map(({ quantity, SymbolProfile, unitPrice }) => { - return this.exchangeRateDataService.toCurrency( - new Big(quantity).mul(unitPrice).toNumber(), - SymbolProfile.currency, - userCurrency - ); - }) - .reduce( - (previous, current) => new Big(previous).plus(current), - new Big(0) - ); - } - private getDividendsByGroup({ dividends, groupBy @@ -1605,52 +1605,6 @@ export class PortfolioService { }; } - private getItems(activities: OrderWithAccount[], date = new Date(0)) { - return activities - .filter((activity) => { - // Filter out all activities before given date (drafts) and type item - return ( - isBefore(date, new Date(activity.date)) && - activity.type === TypeOfOrder.ITEM - ); - }) - .map(({ quantity, SymbolProfile, unitPrice }) => { - return this.exchangeRateDataService.toCurrency( - new Big(quantity).mul(unitPrice).toNumber(), - SymbolProfile.currency, - this.request.user.Settings.settings.baseCurrency - ); - }) - .reduce( - (previous, current) => new Big(previous).plus(current), - new Big(0) - ); - } - - private getLiabilities({ - activities, - userCurrency - }: { - activities: OrderWithAccount[]; - userCurrency: string; - }) { - return activities - .filter(({ type }) => { - return type === TypeOfOrder.LIABILITY; - }) - .map(({ quantity, SymbolProfile, unitPrice }) => { - return this.exchangeRateDataService.toCurrency( - new Big(quantity).mul(unitPrice).toNumber(), - SymbolProfile.currency, - userCurrency - ); - }) - .reduce( - (previous, current) => new Big(previous).plus(current), - new Big(0) - ); - } - private getStartDate(aDateRange: DateRange, portfolioStart: Date) { switch (aDateRange) { case '1d': @@ -1734,6 +1688,7 @@ export class PortfolioService { let dividend = 0; let fees = 0; let items = 0; + let interest = 0; let liabilities = 0; @@ -1769,6 +1724,9 @@ export class PortfolioService { break; case 'LIABILITY': liabilities += amount; + break; + case 'INTEREST': + interest += amount; } } } @@ -1787,9 +1745,17 @@ export class PortfolioService { .plus(emergencyFundPositionsValueInBaseCurrency) .toNumber(); const committedFunds = new Big(totalBuy).minus(totalSell); - const totalOfExcludedActivities = new Big( - this.getTotalByType(excludedActivities, userCurrency, 'BUY') - ).minus(this.getTotalByType(excludedActivities, userCurrency, 'SELL')); + const totalOfExcludedActivities = this.getSumOfActivityType({ + userCurrency, + activities: excludedActivities, + activityType: 'BUY' + }).minus( + this.getSumOfActivityType({ + userCurrency, + activities: excludedActivities, + activityType: 'SELL' + }) + ); const cashDetailsWithExcludedAccounts = await this.accountService.getCashDetails({ @@ -1836,6 +1802,7 @@ export class PortfolioService { excludedAccountsAndActivities, fees, firstOrderDate, + interest, items, liabilities, netWorth, @@ -1858,6 +1825,39 @@ export class PortfolioService { }; } + private getSumOfActivityType({ + activities, + activityType, + date = new Date(0), + userCurrency + }: { + activities: OrderWithAccount[]; + activityType: ActivityType; + date?: Date; + userCurrency: string; + }) { + return activities + .filter((activity) => { + // Filter out all activities before given date (drafts) and + // activity type + return ( + isBefore(date, new Date(activity.date)) && + activity.type === activityType + ); + }) + .map(({ quantity, SymbolProfile, unitPrice }) => { + return this.exchangeRateDataService.toCurrency( + new Big(quantity).mul(unitPrice).toNumber(), + SymbolProfile.currency, + userCurrency + ); + }) + .reduce( + (previous, current) => new Big(previous).plus(current), + new Big(0) + ); + } + private async getTransactionPoints({ filters, includeDrafts = false, @@ -1929,6 +1929,21 @@ export class PortfolioService { }; } + private getUserCurrency(aUser: UserWithSettings) { + return ( + aUser.Settings?.settings.baseCurrency ?? + this.request.user?.Settings?.settings.baseCurrency ?? + DEFAULT_CURRENCY + ); + } + + private async getUserId(aImpersonationId: string, aUserId: string) { + const impersonationUserId = + await this.impersonationService.validateImpersonationId(aImpersonationId); + + return impersonationUserId || aUserId; + } + private async getValueOfAccountsAndPlatforms({ filters = [], orders, @@ -2072,38 +2087,4 @@ export class PortfolioService { return { accounts, platforms }; } - - private getTotalByType( - orders: OrderWithAccount[], - currency: string, - type: TypeOfOrder - ) { - return orders - .filter( - (order) => !isAfter(order.date, endOfToday()) && order.type === type - ) - .map((order) => { - return this.exchangeRateDataService.toCurrency( - order.quantity * order.unitPrice, - order.SymbolProfile.currency, - currency - ); - }) - .reduce((previous, current) => previous + current, 0); - } - - private getUserCurrency(aUser: UserWithSettings) { - return ( - aUser.Settings?.settings.baseCurrency ?? - this.request.user?.Settings?.settings.baseCurrency ?? - DEFAULT_CURRENCY - ); - } - - private async getUserId(aImpersonationId: string, aUserId: string) { - const impersonationUserId = - await this.impersonationService.validateImpersonationId(aImpersonationId); - - return impersonationUserId || aUserId; - } } diff --git a/apps/api/src/app/tag/create-tag.dto.ts b/apps/api/src/app/tag/create-tag.dto.ts new file mode 100644 index 000000000..650a0ce12 --- /dev/null +++ b/apps/api/src/app/tag/create-tag.dto.ts @@ -0,0 +1,6 @@ +import { IsString } from 'class-validator'; + +export class CreateTagDto { + @IsString() + name: string; +} diff --git a/apps/api/src/app/tag/tag.controller.ts b/apps/api/src/app/tag/tag.controller.ts new file mode 100644 index 000000000..950719201 --- /dev/null +++ b/apps/api/src/app/tag/tag.controller.ts @@ -0,0 +1,104 @@ +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import type { RequestWithUser } from '@ghostfolio/common/types'; +import { + Body, + Controller, + Delete, + Get, + HttpException, + Inject, + Param, + Post, + Put, + UseGuards +} from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { Tag } from '@prisma/client'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +import { CreateTagDto } from './create-tag.dto'; +import { TagService } from './tag.service'; +import { UpdateTagDto } from './update-tag.dto'; + +@Controller('tag') +export class TagController { + public constructor( + @Inject(REQUEST) private readonly request: RequestWithUser, + private readonly tagService: TagService + ) {} + + @Get() + @UseGuards(AuthGuard('jwt')) + public async getTags() { + return this.tagService.getTagsWithActivityCount(); + } + + @Post() + @UseGuards(AuthGuard('jwt')) + public async createTag(@Body() data: CreateTagDto): Promise { + if (!hasPermission(this.request.user.permissions, permissions.createTag)) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + return this.tagService.createTag(data); + } + + @Put(':id') + @UseGuards(AuthGuard('jwt')) + public async updateTag(@Param('id') id: string, @Body() data: UpdateTagDto) { + if (!hasPermission(this.request.user.permissions, permissions.updateTag)) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + const originalTag = await this.tagService.getTag({ + id + }); + + if (!originalTag) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + return this.tagService.updateTag({ + data: { + ...data + }, + where: { + id + } + }); + } + + @Delete(':id') + @UseGuards(AuthGuard('jwt')) + public async deleteTag(@Param('id') id: string) { + if (!hasPermission(this.request.user.permissions, permissions.deleteTag)) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + const originalTag = await this.tagService.getTag({ + id + }); + + if (!originalTag) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + return this.tagService.deleteTag({ id }); + } +} diff --git a/apps/api/src/app/tag/tag.module.ts b/apps/api/src/app/tag/tag.module.ts new file mode 100644 index 000000000..810105c51 --- /dev/null +++ b/apps/api/src/app/tag/tag.module.ts @@ -0,0 +1,13 @@ +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { Module } from '@nestjs/common'; + +import { TagController } from './tag.controller'; +import { TagService } from './tag.service'; + +@Module({ + controllers: [TagController], + exports: [TagService], + imports: [PrismaModule], + providers: [TagService] +}) +export class TagModule {} diff --git a/apps/api/src/app/tag/tag.service.ts b/apps/api/src/app/tag/tag.service.ts new file mode 100644 index 000000000..9da7cc475 --- /dev/null +++ b/apps/api/src/app/tag/tag.service.ts @@ -0,0 +1,79 @@ +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { Injectable } from '@nestjs/common'; +import { Prisma, Tag } from '@prisma/client'; + +@Injectable() +export class TagService { + public constructor(private readonly prismaService: PrismaService) {} + + public async createTag(data: Prisma.TagCreateInput) { + return this.prismaService.tag.create({ + data + }); + } + + public async deleteTag(where: Prisma.TagWhereUniqueInput): Promise { + return this.prismaService.tag.delete({ where }); + } + + public async getTag( + tagWhereUniqueInput: Prisma.TagWhereUniqueInput + ): Promise { + return this.prismaService.tag.findUnique({ + where: tagWhereUniqueInput + }); + } + + public async getTags({ + cursor, + orderBy, + skip, + take, + where + }: { + cursor?: Prisma.TagWhereUniqueInput; + orderBy?: Prisma.TagOrderByWithRelationInput; + skip?: number; + take?: number; + where?: Prisma.TagWhereInput; + } = {}) { + return this.prismaService.tag.findMany({ + cursor, + orderBy, + skip, + take, + where + }); + } + + public async getTagsWithActivityCount() { + const tagsWithOrderCount = await this.prismaService.tag.findMany({ + include: { + _count: { + select: { orders: true } + } + } + }); + + return tagsWithOrderCount.map(({ _count, id, name }) => { + return { + id, + name, + activityCount: _count.orders + }; + }); + } + + public async updateTag({ + data, + where + }: { + data: Prisma.TagUpdateInput; + where: Prisma.TagWhereUniqueInput; + }): Promise { + return this.prismaService.tag.update({ + data, + where + }); + } +} diff --git a/apps/api/src/app/tag/update-tag.dto.ts b/apps/api/src/app/tag/update-tag.dto.ts new file mode 100644 index 000000000..b26ffde11 --- /dev/null +++ b/apps/api/src/app/tag/update-tag.dto.ts @@ -0,0 +1,9 @@ +import { IsString } from 'class-validator'; + +export class UpdateTagDto { + @IsString() + id: string; + + @IsString() + name: string; +} diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index c5cc3c8ce..a176c43f3 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -19,7 +19,7 @@ import { UserWithSettings } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; import { Prisma, Role, User } from '@prisma/client'; import { differenceInDays } from 'date-fns'; -import { sortBy } from 'lodash'; +import { sortBy, without } from 'lodash'; const crypto = require('crypto'); @@ -163,6 +163,13 @@ export class UserService { let currentPermissions = getPermissions(user.role); + if (!(user.Settings.settings as UserSettings).isExperimentalFeatures) { + currentPermissions = without( + currentPermissions, + permissions.accessAssistant + ); + } + if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { user.subscription = this.subscriptionService.getSubscription(Subscription); @@ -188,6 +195,11 @@ export class UserService { currentPermissions.push(permissions.enableSubscriptionInterstitial); } + currentPermissions = without( + currentPermissions, + permissions.createAccess + ); + // Reset benchmark user.Settings.settings.benchmark = undefined; } diff --git a/apps/api/src/assets/sitemap.xml b/apps/api/src/assets/sitemap.xml index 9a357a777..2a3650752 100644 --- a/apps/api/src/assets/sitemap.xml +++ b/apps/api/src/assets/sitemap.xml @@ -1,9 +1,9 @@ + xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 + http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"> https://ghostfol.io/de ${currentDate}T00:00:00+00:00 @@ -58,6 +58,10 @@ https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-altoo ${currentDate}T00:00:00+00:00 + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-capmon + ${currentDate}T00:00:00+00:00 + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-copilot-money ${currentDate}T00:00:00+00:00 @@ -74,6 +78,10 @@ https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-exirio ${currentDate}T00:00:00+00:00 + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-finary + ${currentDate}T00:00:00+00:00 + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-folishare ${currentDate}T00:00:00+00:00 @@ -142,6 +150,14 @@ https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-snowball-analytics ${currentDate}T00:00:00+00:00 + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-stockle + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-stockmarketeye + ${currentDate}T00:00:00+00:00 + https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-sumio ${currentDate}T00:00:00+00:00 @@ -254,6 +270,10 @@ https://ghostfol.io/en/blog/2023/09/ghostfolio-2 ${currentDate}T00:00:00+00:00 + + https://ghostfol.io/en/blog/2023/09/hacktoberfest-2023 + ${currentDate}T00:00:00+00:00 + https://ghostfol.io/en/faq ${currentDate}T00:00:00+00:00 @@ -292,6 +312,10 @@ https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-altoo ${currentDate}T00:00:00+00:00 + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-capmon + ${currentDate}T00:00:00+00:00 + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-copilot-money ${currentDate}T00:00:00+00:00 @@ -308,6 +332,10 @@ https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-exirio ${currentDate}T00:00:00+00:00 + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-finary + ${currentDate}T00:00:00+00:00 + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-folishare ${currentDate}T00:00:00+00:00 @@ -376,6 +404,14 @@ https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-snowball-analytics ${currentDate}T00:00:00+00:00 + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-stockle + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-stockmarketeye + ${currentDate}T00:00:00+00:00 + https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-sumio ${currentDate}T00:00:00+00:00 @@ -550,6 +586,126 @@ https://ghostfol.io/it/risorse ${currentDate}T00:00:00+00:00 + + https://ghostfol.io/it/risorse/personal-finance-tools + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-altoo + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-campmon + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-copilot-money + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-delta + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-divvydiary + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-exirio + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-finary + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-folishare + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-getquin + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-gospatz + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-justetf + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-kubera + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-markets.sh + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-maybe-finance + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-monse + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-parqet + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-plannix + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-portfolio-dividend-tracker + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-portseido + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-projectionlab + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-seeking-alpha + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-sharesight + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-simple-portfolio + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-snowball-analytics + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-stockle + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-stockmarketeye + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-sumio + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-utluna + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-yeekatee + ${currentDate}T00:00:00+00:00 + https://ghostfol.io/nl ${currentDate}T00:00:00+00:00 @@ -566,6 +722,10 @@ https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-altoo ${currentDate}T00:00:00+00:00 + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-capmon + ${currentDate}T00:00:00+00:00 + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-copilot-money ${currentDate}T00:00:00+00:00 @@ -582,6 +742,10 @@ https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-exirio ${currentDate}T00:00:00+00:00 + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-finary + ${currentDate}T00:00:00+00:00 + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-folishare ${currentDate}T00:00:00+00:00 @@ -650,6 +814,14 @@ https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-snowball-analytics ${currentDate}T00:00:00+00:00 + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-stockle + ${currentDate}T00:00:00+00:00 + + + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-stockmarketeye + ${currentDate}T00:00:00+00:00 + https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-sumio ${currentDate}T00:00:00+00:00 @@ -764,4 +936,8 @@ https://ghostfol.io/pt/sobre/politica-de-privacidade ${currentDate}T00:00:00+00:00 + + https://ghostfol.io/tr + ${currentDate}T00:00:00+00:00 + diff --git a/apps/api/src/helper/object.helper.ts b/apps/api/src/helper/object.helper.ts index 50a4f2b12..2f0399fb8 100644 --- a/apps/api/src/helper/object.helper.ts +++ b/apps/api/src/helper/object.helper.ts @@ -3,7 +3,7 @@ import { cloneDeep, isArray, isObject } from 'lodash'; export function hasNotDefinedValuesInObject(aObject: Object): boolean { for (const key in aObject) { - if (aObject[key] === null || aObject[key] === null) { + if (aObject[key] === null || aObject[key] === undefined) { return true; } else if (isObject(aObject[key])) { return hasNotDefinedValuesInObject(aObject[key]); diff --git a/apps/api/src/middlewares/html-template.middleware.ts b/apps/api/src/middlewares/html-template.middleware.ts index ceb563fd6..9d44bdbe0 100644 --- a/apps/api/src/middlewares/html-template.middleware.ts +++ b/apps/api/src/middlewares/html-template.middleware.ts @@ -18,7 +18,8 @@ const descriptions = { fr: 'Ghostfolio est un dashboard de finances personnelles qui permet de suivre vos actifs comme les actions, les ETF ou les crypto-monnaies sur plusieurs plateformes.', it: 'Ghostfolio è un dashboard di finanza personale per tenere traccia delle vostre attività come azioni, ETF o criptovalute su più piattaforme.', nl: 'Ghostfolio is een persoonlijk financieel dashboard om uw activa zoals aandelen, ETF’s of cryptocurrencies over meerdere platforms bij te houden.', - pt: 'Ghostfolio é um dashboard de finanças pessoais para acompanhar os seus activos como acções, ETFs ou criptomoedas em múltiplas plataformas.' + pt: 'Ghostfolio é um dashboard de finanças pessoais para acompanhar os seus activos como acções, ETFs ou criptomoedas em múltiplas plataformas.', + tr: 'Ghostfolio, hisse senetleri, ETF’ler veya kripto para birimleri gibi varlıklarınızı birden fazla platformda takip etmenizi sağlayan bir kişisel finans panosudur.' }; const title = 'Ghostfolio – Open Source Wealth Management Software'; @@ -79,6 +80,10 @@ const locales = { '/en/blog/2023/09/ghostfolio-2': { featureGraphicPath: 'assets/images/blog/ghostfolio-2.jpg', title: `Announcing Ghostfolio 2.0 - ${titleShort}` + }, + '/en/blog/2023/09/hacktoberfest-2023': { + featureGraphicPath: 'assets/images/blog/hacktoberfest-2023.png', + title: `Hacktoberfest 2023 - ${titleShort}` } }; diff --git a/apps/api/src/models/rules/account-cluster-risk/current-investment.ts b/apps/api/src/models/rules/account-cluster-risk/current-investment.ts index d0cdbb58c..23d3307de 100644 --- a/apps/api/src/models/rules/account-cluster-risk/current-investment.ts +++ b/apps/api/src/models/rules/account-cluster-risk/current-investment.ts @@ -1,4 +1,5 @@ import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; +import { Rule } from '@ghostfolio/api/models/rule'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PortfolioDetails, @@ -6,16 +7,18 @@ import { UserSettings } from '@ghostfolio/common/interfaces'; -import { Rule } from '../../rule'; - export class AccountClusterRiskCurrentInvestment extends Rule { + private accounts: PortfolioDetails['accounts']; + public constructor( protected exchangeRateDataService: ExchangeRateDataService, - private accounts: PortfolioDetails['accounts'] + accounts: PortfolioDetails['accounts'] ) { super(exchangeRateDataService, { name: 'Investment' }); + + this.accounts = accounts; } public evaluate(ruleSettings: Settings) { diff --git a/apps/api/src/models/rules/account-cluster-risk/single-account.ts b/apps/api/src/models/rules/account-cluster-risk/single-account.ts index 3be323d7c..b5028228a 100644 --- a/apps/api/src/models/rules/account-cluster-risk/single-account.ts +++ b/apps/api/src/models/rules/account-cluster-risk/single-account.ts @@ -1,17 +1,20 @@ import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; +import { Rule } from '@ghostfolio/api/models/rule'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PortfolioDetails, UserSettings } from '@ghostfolio/common/interfaces'; -import { Rule } from '../../rule'; - export class AccountClusterRiskSingleAccount extends Rule { + private accounts: PortfolioDetails['accounts']; + public constructor( protected exchangeRateDataService: ExchangeRateDataService, - private accounts: PortfolioDetails['accounts'] + accounts: PortfolioDetails['accounts'] ) { super(exchangeRateDataService, { name: 'Single Account' }); + + this.accounts = accounts; } public evaluate() { diff --git a/apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts b/apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts index 2facb8803..a23a208c3 100644 --- a/apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts +++ b/apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts @@ -1,17 +1,20 @@ import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; +import { Rule } from '@ghostfolio/api/models/rule'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces'; -import { Rule } from '../../rule'; - export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule { + private positions: TimelinePosition[]; + public constructor( protected exchangeRateDataService: ExchangeRateDataService, - private positions: TimelinePosition[] + positions: TimelinePosition[] ) { super(exchangeRateDataService, { name: 'Investment: Base Currency' }); + + this.positions = positions; } public evaluate(ruleSettings: Settings) { diff --git a/apps/api/src/models/rules/currency-cluster-risk/current-investment.ts b/apps/api/src/models/rules/currency-cluster-risk/current-investment.ts index 2d69865f5..bd6e060ef 100644 --- a/apps/api/src/models/rules/currency-cluster-risk/current-investment.ts +++ b/apps/api/src/models/rules/currency-cluster-risk/current-investment.ts @@ -1,17 +1,20 @@ import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; +import { Rule } from '@ghostfolio/api/models/rule'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces'; -import { Rule } from '../../rule'; - export class CurrencyClusterRiskCurrentInvestment extends Rule { + private positions: TimelinePosition[]; + public constructor( protected exchangeRateDataService: ExchangeRateDataService, - private positions: TimelinePosition[] + positions: TimelinePosition[] ) { super(exchangeRateDataService, { name: 'Investment' }); + + this.positions = positions; } public evaluate(ruleSettings: Settings) { diff --git a/apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts b/apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts new file mode 100644 index 000000000..b6248ab51 --- /dev/null +++ b/apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts @@ -0,0 +1,46 @@ +import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; +import { Rule } from '@ghostfolio/api/models/rule'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { UserSettings } from '@ghostfolio/common/interfaces'; + +export class EmergencyFundSetup extends Rule { + private emergencyFund: number; + + public constructor( + protected exchangeRateDataService: ExchangeRateDataService, + emergencyFund: number + ) { + super(exchangeRateDataService, { + name: 'Emergency Fund: Set up' + }); + + this.emergencyFund = emergencyFund; + } + + public evaluate(ruleSettings: Settings) { + if (this.emergencyFund > ruleSettings.threshold) { + return { + evaluation: 'An emergency fund has been set up', + value: true + }; + } + + return { + evaluation: 'No emergency fund has been set up', + value: false + }; + } + + public getSettings(aUserSettings: UserSettings): Settings { + return { + baseCurrency: aUserSettings.baseCurrency, + isActive: true, + threshold: 0 + }; + } +} + +interface Settings extends RuleSettings { + baseCurrency: string; + threshold: number; +} diff --git a/apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts b/apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts index dfe375c43..0ba70d23c 100644 --- a/apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts +++ b/apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts @@ -1,22 +1,29 @@ import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; +import { Rule } from '@ghostfolio/api/models/rule'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { UserSettings } from '@ghostfolio/common/interfaces'; -import { Rule } from '../../rule'; - export class FeeRatioInitialInvestment extends Rule { + private fees: number; + private totalInvestment: number; + public constructor( protected exchangeRateDataService: ExchangeRateDataService, - private totalInvestment: number, - private fees: number + totalInvestment: number, + fees: number ) { super(exchangeRateDataService, { - name: 'Investment' + name: 'Fee Ratio' }); + + this.fees = fees; + this.totalInvestment = totalInvestment; } public evaluate(ruleSettings: Settings) { - const feeRatio = this.fees / this.totalInvestment; + const feeRatio = this.totalInvestment + ? this.fees / this.totalInvestment + : 0; if (feeRatio > ruleSettings.threshold) { return { diff --git a/apps/api/src/services/api/api.service.ts b/apps/api/src/services/api/api.service.ts index 2a6b1fb06..204aa030e 100644 --- a/apps/api/src/services/api/api.service.ts +++ b/apps/api/src/services/api/api.service.ts @@ -8,14 +8,17 @@ export class ApiService { public buildFiltersFromQueryParams({ filterByAccounts, filterByAssetClasses, + filterBySearchQuery, filterByTags }: { filterByAccounts?: string; filterByAssetClasses?: string; + filterBySearchQuery?: string; filterByTags?: string; }): Filter[] { const accountIds = filterByAccounts?.split(',') ?? []; const assetClasses = filterByAssetClasses?.split(',') ?? []; + const searchQuery = filterBySearchQuery?.toLowerCase(); const tagIds = filterByTags?.split(',') ?? []; return [ @@ -31,6 +34,10 @@ export class ApiService { type: 'ASSET_CLASS' }; }), + { + id: searchQuery, + type: 'SEARCH_QUERY' + }, ...tagIds.map((tagId) => { return { id: tagId, diff --git a/apps/api/src/services/data-gathering/data-gathering.processor.ts b/apps/api/src/services/data-gathering/data-gathering.processor.ts index c517e0f15..a3ab0e513 100644 --- a/apps/api/src/services/data-gathering/data-gathering.processor.ts +++ b/apps/api/src/services/data-gathering/data-gathering.processor.ts @@ -13,6 +13,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { Job } from 'bull'; import { + addDays, format, getDate, getMonth, @@ -101,15 +102,7 @@ export class DataGatheringProcessor { }); } - // Count month one up for iteration - currentDate = new Date( - Date.UTC( - getYear(currentDate), - getMonth(currentDate), - getDate(currentDate) + 1, - 0 - ) - ); + currentDate = addDays(currentDate, 1); } await this.marketDataService.updateMany({ data }); diff --git a/apps/api/src/services/data-gathering/data-gathering.service.ts b/apps/api/src/services/data-gathering/data-gathering.service.ts index 16ca505de..34645b9ea 100644 --- a/apps/api/src/services/data-gathering/data-gathering.service.ts +++ b/apps/api/src/services/data-gathering/data-gathering.service.ts @@ -127,6 +127,10 @@ export class DataGatheringService { uniqueAssets = await this.getUniqueAssets(); } + if (uniqueAssets.length <= 0) { + return; + } + const assetProfiles = await this.dataProviderService.getAssetProfiles(uniqueAssets); const symbolProfiles = diff --git a/apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts b/apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts index 4713a4c5f..973fc5df2 100644 --- a/apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts +++ b/apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts @@ -9,6 +9,7 @@ import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { Granularity } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; import { DataSource, SymbolProfile } from '@prisma/client'; +import * as Alphavantage from 'alphavantage'; import { format, isAfter, isBefore, parse } from 'date-fns'; import { IAlphaVantageHistoricalResponse } from './interfaces/interfaces'; @@ -20,7 +21,7 @@ export class AlphaVantageService implements DataProviderInterface { public constructor( private readonly configurationService: ConfigurationService ) { - this.alphaVantage = require('alphavantage')({ + this.alphaVantage = Alphavantage({ key: this.configurationService.get('ALPHA_VANTAGE_API_KEY') }); } @@ -126,6 +127,9 @@ export class AlphaVantageService implements DataProviderInterface { return { items: result?.bestMatches?.map((bestMatch) => { return { + assetClass: undefined, + assetSubClass: undefined, + currency: bestMatch['8. currency'], dataSource: this.getName(), name: bestMatch['2. name'], symbol: bestMatch['1. symbol'] diff --git a/apps/api/src/services/data-provider/coingecko/coingecko.service.ts b/apps/api/src/services/data-provider/coingecko/coingecko.service.ts index 56594ca40..4360822f0 100644 --- a/apps/api/src/services/data-provider/coingecko/coingecko.service.ts +++ b/apps/api/src/services/data-provider/coingecko/coingecko.service.ts @@ -4,7 +4,10 @@ import { IDataProviderHistoricalResponse, IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces'; -import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; +import { + DEFAULT_CURRENCY, + DEFAULT_REQUEST_TIMEOUT +} from '@ghostfolio/common/config'; import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DataProviderInfo } from '@ghostfolio/common/interfaces'; import { Granularity } from '@ghostfolio/common/types'; @@ -40,7 +43,16 @@ export class CoinGeckoService implements DataProviderInterface { }; try { - const { name } = await got(`${this.URL}/coins/${aSymbol}`).json(); + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + const { name } = await got(`${this.URL}/coins/${aSymbol}`, { + // @ts-ignore + signal: abortController.signal + }).json(); response.name = name; } catch (error) { @@ -73,12 +85,22 @@ export class CoinGeckoService implements DataProviderInterface { [symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; }> { try { + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + const { prices } = await got( `${ this.URL }/coins/${aSymbol}/market_chart/range?vs_currency=${DEFAULT_CURRENCY.toLowerCase()}&from=${getUnixTime( from - )}&to=${getUnixTime(to)}` + )}&to=${getUnixTime(to)}`, + { + // @ts-ignore + signal: abortController.signal + } ).json(); const result: { @@ -122,10 +144,20 @@ export class CoinGeckoService implements DataProviderInterface { } try { + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + const response = await got( `${this.URL}/simple/price?ids=${aSymbols.join( ',' - )}&vs_currencies=${DEFAULT_CURRENCY.toLowerCase()}` + )}&vs_currencies=${DEFAULT_CURRENCY.toLowerCase()}`, + { + // @ts-ignore + signal: abortController.signal + } ).json(); for (const symbol in response) { @@ -160,9 +192,16 @@ export class CoinGeckoService implements DataProviderInterface { let items: LookupItem[] = []; try { - const { coins } = await got( - `${this.URL}/search?query=${query}` - ).json(); + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + const { coins } = await got(`${this.URL}/search?query=${query}`, { + // @ts-ignore + signal: abortController.signal + }).json(); items = coins.map(({ id: symbol, name }) => { return { diff --git a/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts b/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts index 4af93a2cb..f17bb74c9 100644 --- a/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts +++ b/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts @@ -1,4 +1,5 @@ import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; +import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config'; import { Country } from '@ghostfolio/common/interfaces/country.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { Injectable } from '@nestjs/common'; @@ -30,15 +31,35 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { return response; } + let abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + const profile = await got( - `${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol}.json` + `${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol}.json`, + { + // @ts-ignore + signal: abortController.signal + } ) .json() .catch(() => { + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + return got( `${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol.split( '.' - )?.[0]}.json` + )?.[0]}.json`, + { + // @ts-ignore + signal: abortController.signal + } ) .json() .catch(() => { @@ -52,15 +73,35 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { response.isin = isin; } + abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + const holdings = await got( - `${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol}.json` + `${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol}.json`, + { + // @ts-ignore + signal: abortController.signal + } ) .json() .catch(() => { + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + return got( `${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol.split( '.' - )?.[0]}.json` + )?.[0]}.json`, + { + // @ts-ignore + signal: abortController.signal + } ) .json() .catch(() => { diff --git a/apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts b/apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts index fd8114ad6..307f6127a 100644 --- a/apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts +++ b/apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts @@ -78,6 +78,12 @@ export class EodHistoricalDataService implements DataProviderInterface { const symbol = this.convertToEodSymbol(aSymbol); try { + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + const response = await got( `${this.URL}/eod/${symbol}?api_token=${ this.apiKey @@ -86,9 +92,8 @@ export class EodHistoricalDataService implements DataProviderInterface { DATE_FORMAT )}&period={aGranularity}`, { - timeout: { - request: DEFAULT_REQUEST_TIMEOUT - } + // @ts-ignore + signal: abortController.signal } ).json(); @@ -138,14 +143,19 @@ export class EodHistoricalDataService implements DataProviderInterface { } try { + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + const realTimeResponse = await got( `${this.URL}/real-time/${symbols[0]}?api_token=${ this.apiKey }&fmt=json&s=${symbols.join(',')}`, { - timeout: { - request: DEFAULT_REQUEST_TIMEOUT - } + // @ts-ignore + signal: abortController.signal } ).json(); @@ -331,12 +341,17 @@ export class EodHistoricalDataService implements DataProviderInterface { let searchResult = []; try { + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + const response = await got( `${this.URL}/search/${aQuery}?api_token=${this.apiKey}`, { - timeout: { - request: DEFAULT_REQUEST_TIMEOUT - } + // @ts-ignore + signal: abortController.signal } ).json(); diff --git a/apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts b/apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts index c5d163456..4fd1d4ebd 100644 --- a/apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts +++ b/apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts @@ -5,7 +5,10 @@ import { IDataProviderHistoricalResponse, IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces'; -import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; +import { + DEFAULT_CURRENCY, + DEFAULT_REQUEST_TIMEOUT +} from '@ghostfolio/common/config'; import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; import { DataProviderInfo } from '@ghostfolio/common/interfaces'; import { Granularity } from '@ghostfolio/common/types'; @@ -63,8 +66,18 @@ export class FinancialModelingPrepService implements DataProviderInterface { [symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; }> { try { + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + const { historical } = await got( - `${this.URL}/historical-price-full/${aSymbol}?apikey=${this.apiKey}` + `${this.URL}/historical-price-full/${aSymbol}?apikey=${this.apiKey}`, + { + // @ts-ignore + signal: abortController.signal + } ).json(); const result: { @@ -110,8 +123,18 @@ export class FinancialModelingPrepService implements DataProviderInterface { } try { + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + const response = await got( - `${this.URL}/quote/${aSymbols.join(',')}?apikey=${this.apiKey}` + `${this.URL}/quote/${aSymbols.join(',')}?apikey=${this.apiKey}`, + { + // @ts-ignore + signal: abortController.signal + } ).json(); for (const { price, symbol } of response) { @@ -144,8 +167,18 @@ export class FinancialModelingPrepService implements DataProviderInterface { let items: LookupItem[] = []; try { + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + const result = await got( - `${this.URL}/search?query=${query}&apikey=${this.apiKey}` + `${this.URL}/search?query=${query}&apikey=${this.apiKey}`, + { + // @ts-ignore + signal: abortController.signal + } ).json(); items = result.map(({ currency, name, symbol }) => { diff --git a/apps/api/src/services/data-provider/manual/manual.service.ts b/apps/api/src/services/data-provider/manual/manual.service.ts index adf14e43f..5c84a9c92 100644 --- a/apps/api/src/services/data-provider/manual/manual.service.ts +++ b/apps/api/src/services/data-provider/manual/manual.service.ts @@ -6,6 +6,7 @@ import { } from '@ghostfolio/api/services/interfaces/interfaces'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config'; import { DATE_FORMAT, extractNumberFromString, @@ -95,7 +96,17 @@ export class ManualService implements DataProviderInterface { return {}; } - const { body } = await got(url, { headers }); + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + + const { body } = await got(url, { + headers, + // @ts-ignore + signal: abortController.signal + }); const $ = cheerio.load(body); diff --git a/apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts b/apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts index 307855aaf..7743d7805 100644 --- a/apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts +++ b/apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts @@ -5,7 +5,10 @@ import { IDataProviderHistoricalResponse, IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces'; -import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config'; +import { + DEFAULT_REQUEST_TIMEOUT, + ghostfolioFearAndGreedIndexSymbol +} from '@ghostfolio/common/config'; import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper'; import { Granularity } from '@ghostfolio/common/types'; import { Injectable, Logger } from '@nestjs/common'; @@ -135,6 +138,12 @@ export class RapidApiService implements DataProviderInterface { oneYearAgo: { value: number; valueText: string }; }> { try { + const abortController = new AbortController(); + + setTimeout(() => { + abortController.abort(); + }, DEFAULT_REQUEST_TIMEOUT); + const { fgi } = await got( `https://fear-and-greed-index.p.rapidapi.com/v1/fgi`, { @@ -142,7 +151,9 @@ export class RapidApiService implements DataProviderInterface { useQueryString: 'true', 'x-rapidapi-host': 'fear-and-greed-index.p.rapidapi.com', 'x-rapidapi-key': this.configurationService.get('RAPID_API_API_KEY') - } + }, + // @ts-ignore + signal: abortController.signal } ).json(); diff --git a/apps/client/project.json b/apps/client/project.json index 76da6bd1a..2e36f7144 100644 --- a/apps/client/project.json +++ b/apps/client/project.json @@ -21,6 +21,7 @@ "tsConfig": "apps/client/tsconfig.app.json", "assets": [], "styles": [ + "apps/client/src/assets/fonts/inter.css", "apps/client/src/styles/theme.scss", "apps/client/src/styles.scss" ], @@ -63,6 +64,10 @@ "baseHref": "/pt/", "localize": ["pt"] }, + "development-tr": { + "baseHref": "/tr/", + "localize": ["tr"] + }, "production": { "fileReplacements": [ { @@ -99,40 +104,40 @@ "options": { "commands": [ { - "command": "mkdir -p dist/apps/client" + "command": "shx mkdir -p dist/apps/client" }, { - "command": "cp -r apps/client/src/assets dist/apps/client" + "command": "shx cp -r apps/client/src/assets dist/apps/client" }, { - "command": "cp -r apps/client/src/assets/.well-known dist/apps/client" + "command": "shx cp -r apps/client/src/assets/.well-known dist/apps/client" }, { - "command": "cp apps/client/src/assets/favicon.ico dist/apps/client" + "command": "shx cp apps/client/src/assets/favicon.ico dist/apps/client" }, { - "command": "cp apps/client/src/assets/index.html dist/apps/client" + "command": "shx cp apps/client/src/assets/index.html dist/apps/client" }, { - "command": "cp apps/client/src/assets/robots.txt dist/apps/client" + "command": "shx cp apps/client/src/assets/robots.txt dist/apps/client" }, { - "command": "cp apps/client/src/assets/site.webmanifest dist/apps/client" + "command": "shx cp apps/client/src/assets/site.webmanifest dist/apps/client" }, { - "command": "cp node_modules/ionicons/dist/index.js dist/apps/client" + "command": "shx cp node_modules/ionicons/dist/index.js dist/apps/client" }, { - "command": "cp node_modules/ionicons/dist/ionicons.js dist/apps/client" + "command": "shx cp node_modules/ionicons/dist/ionicons.js dist/apps/client" }, { - "command": "cp -r node_modules/ionicons/dist/ionicons dist/apps/client/ionicons" + "command": "shx cp -r node_modules/ionicons/dist/ionicons dist/apps/client/ionicons" }, { - "command": "cp CHANGELOG.md dist/apps/client/assets" + "command": "shx cp CHANGELOG.md dist/apps/client/assets" }, { - "command": "cp LICENSE dist/apps/client/assets" + "command": "shx cp LICENSE dist/apps/client/assets" } ] } @@ -165,6 +170,9 @@ "development-pt": { "browserTarget": "client:build:development-pt" }, + "development-tr": { + "browserTarget": "client:build:development-tr" + }, "production": { "browserTarget": "client:build:production" } @@ -182,7 +190,8 @@ "messages.fr.xlf", "messages.it.xlf", "messages.nl.xlf", - "messages.pt.xlf" + "messages.pt.xlf", + "messages.tr.xlf" ] } }, @@ -226,6 +235,10 @@ "pt": { "baseHref": "/pt/", "translation": "apps/client/src/locales/messages.pt.xlf" + }, + "tr": { + "baseHref": "/tr/", + "translation": "apps/client/src/locales/messages.tr.xlf" } }, "sourceLocale": "en" diff --git a/apps/client/src/app/app.component.html b/apps/client/src/app/app.component.html index c9457cfc0..a52261969 100644 --- a/apps/client/src/app/app.component.html +++ b/apps/client/src/app/app.component.html @@ -1,37 +1,26 @@
- -
- -
-
-
+
+ + + + +
@@ -151,6 +153,11 @@
  • Português
  • +
    diff --git a/apps/client/src/app/app.component.scss b/apps/client/src/app/app.component.scss index 0c0828880..2cc6d9a17 100644 --- a/apps/client/src/app/app.component.scss +++ b/apps/client/src/app/app.component.scss @@ -4,31 +4,47 @@ display: block; min-height: 100vh; + &.has-info-message { + header { + height: calc(2 * var(--mat-toolbar-standard-height)); + + .info-message-container { + height: var(--mat-toolbar-standard-height); + + .info-message-inner-container { + background-color: rgba(var(--palette-primary-500), 1); + height: var(--mat-toolbar-standard-height); + z-index: 999; + + .info-message { + color: rgba(var(--palette-foreground-text), 1); + font-size: 80%; + max-width: 100%; + + .a { + font-weight: 500; + } + } + } + } + } + + main { + min-height: calc(100vh - 2 * var(--mat-toolbar-standard-height)); + } + } + footer { background-color: rgba(var(--palette-foreground-text), 0.05); font-size: 90%; } + header { + height: var(--mat-toolbar-standard-height); + } + main { - min-height: 100vh; - padding-top: 5rem; - - .info-message-container { - height: 3.5rem; - margin-top: -0.5rem; - - .info-message { - background-color: rgba(var(--palette-foreground-text), 0.05); - border-radius: 2rem; - font-size: 80%; - max-width: 100%; - - .a { - color: rgba(var(--palette-primary-500), 1); - font-weight: 500; - } - } - } + min-height: calc(100vh - var(--mat-toolbar-standard-height)); } } @@ -36,12 +52,4 @@ footer { background-color: rgba(var(--palette-foreground-text-dark), 0.05); } - - main { - .info-message-container { - .info-message { - background-color: rgba(var(--palette-foreground-text-dark), 0.05); - } - } - } } diff --git a/apps/client/src/app/app.component.ts b/apps/client/src/app/app.component.ts index dd6240d98..d1d9529ce 100644 --- a/apps/client/src/app/app.component.ts +++ b/apps/client/src/app/app.component.ts @@ -3,6 +3,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, + HostBinding, Inject, OnDestroy, OnInit @@ -28,14 +29,20 @@ import { UserService } from './services/user/user.service'; styleUrls: ['./app.component.scss'] }) export class AppComponent implements OnDestroy, OnInit { + @HostBinding('class.has-info-message') get getHasMessage() { + return this.hasInfoMessage; + } + public canCreateAccount: boolean; public currentRoute: string; public currentYear = new Date().getFullYear(); public deviceType: string; + public hasInfoMessage: boolean; public hasPermissionForBlog: boolean; public hasPermissionForStatistics: boolean; public hasPermissionForSubscription: boolean; public hasPermissionToAccessFearAndGreedIndex: boolean; + public hasTabs = false; public info: InfoItem; public pageTitle: string; public routerLinkAbout = ['/' + $localize`about`]; @@ -103,6 +110,15 @@ export class AppComponent implements OnDestroy, OnInit { const urlSegments = urlSegmentGroup.segments; this.currentRoute = urlSegments[0].path; + this.hasTabs = + (this.currentRoute === this.routerLinkAbout[0].slice(1) || + this.currentRoute === 'account' || + this.currentRoute === 'admin' || + this.currentRoute === 'home' || + this.currentRoute === 'portfolio' || + this.currentRoute === 'zen') && + this.deviceType !== 'mobile'; + this.showFooter = (this.currentRoute === 'blog' || this.currentRoute === this.routerLinkFaq[0].slice(1) || @@ -140,6 +156,12 @@ export class AppComponent implements OnDestroy, OnInit { permissions.createUserAccount ); + this.hasInfoMessage = + hasPermission( + this.user?.permissions, + permissions.createUserAccount + ) || !!this.info.systemMessage; + this.initializeTheme(this.user?.settings.colorScheme); this.changeDetectorRef.markForCheck(); diff --git a/apps/client/src/app/app.module.ts b/apps/client/src/app/app.module.ts index a5f0d755c..608ba0100 100644 --- a/apps/client/src/app/app.module.ts +++ b/apps/client/src/app/app.module.ts @@ -1,6 +1,6 @@ import { Platform } from '@angular/cdk/platform'; import { HttpClientModule } from '@angular/common/http'; -import { NgModule } from '@angular/core'; +import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatChipsModule } from '@angular/material/chips'; import { @@ -35,6 +35,7 @@ export function NgxStripeFactory(): string { } @NgModule({ + bootstrap: [AppComponent], declarations: [AppComponent], imports: [ AppRoutingModule, @@ -72,6 +73,6 @@ export function NgxStripeFactory(): string { useFactory: NgxStripeFactory } ], - bootstrap: [AppComponent] + schemas: [CUSTOM_ELEMENTS_SCHEMA] }) export class AppModule {} diff --git a/apps/client/src/app/components/access-table/access-table.component.html b/apps/client/src/app/components/access-table/access-table.component.html index 589c67e1f..498e73bf0 100644 --- a/apps/client/src/app/components/access-table/access-table.component.html +++ b/apps/client/src/app/components/access-table/access-table.component.html @@ -1,3 +1,15 @@ + + diff --git a/apps/client/src/app/components/access-table/access-table.component.ts b/apps/client/src/app/components/access-table/access-table.component.ts index c0db8fb2f..298a02069 100644 --- a/apps/client/src/app/components/access-table/access-table.component.ts +++ b/apps/client/src/app/components/access-table/access-table.component.ts @@ -19,6 +19,7 @@ import { Access } from '@ghostfolio/common/interfaces'; }) export class AccessTableComponent implements OnChanges, OnInit { @Input() accesses: Access[]; + @Input() hasPermissionToCreateAccess = false; @Input() showActions: boolean; @Output() accessDeleted = new EventEmitter(); diff --git a/apps/client/src/app/components/access-table/access-table.module.ts b/apps/client/src/app/components/access-table/access-table.module.ts index 111f0e2f4..2ace3cfc1 100644 --- a/apps/client/src/app/components/access-table/access-table.module.ts +++ b/apps/client/src/app/components/access-table/access-table.module.ts @@ -3,13 +3,20 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatMenuModule } from '@angular/material/menu'; import { MatTableModule } from '@angular/material/table'; +import { RouterModule } from '@angular/router'; import { AccessTableComponent } from './access-table.component'; @NgModule({ declarations: [AccessTableComponent], exports: [AccessTableComponent], - imports: [CommonModule, MatButtonModule, MatMenuModule, MatTableModule], + imports: [ + CommonModule, + MatButtonModule, + MatMenuModule, + MatTableModule, + RouterModule + ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }) export class GfPortfolioAccessTableModule {} diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts index 06e4cebf6..3a4746b6b 100644 --- a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts @@ -29,13 +29,13 @@ import { AccountDetailDialogParams } from './interfaces/interfaces'; styleUrls: ['./account-detail-dialog.component.scss'] }) export class AccountDetailDialog implements OnDestroy, OnInit { - public accountType: string; public balance: number; public currency: string; public equity: number; public name: string; public orders: OrderWithAccount[]; public platformName: string; + public transactionCount: number; public user: User; public valueInBaseCurrency: number; @@ -65,15 +65,14 @@ export class AccountDetailDialog implements OnDestroy, OnInit { .pipe(takeUntil(this.unsubscribeSubject)) .subscribe( ({ - accountType, balance, currency, name, Platform, + transactionCount, value, valueInBaseCurrency }) => { - this.accountType = translate(accountType); this.balance = balance; this.currency = currency; @@ -85,6 +84,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit { this.name = name; this.platformName = Platform?.name ?? '-'; + this.transactionCount = transactionCount; this.valueInBaseCurrency = valueInBaseCurrency; this.changeDetectorRef.markForCheck(); diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html index 126c33ac5..46a5ee7b0 100644 --- a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html @@ -44,8 +44,8 @@ >
    - Account TypeActivities
    diff --git a/apps/client/src/app/components/accounts-table/accounts-table.component.html b/apps/client/src/app/components/accounts-table/accounts-table.component.html index 0b859321f..664694735 100644 --- a/apps/client/src/app/components/accounts-table/accounts-table.component.html +++ b/apps/client/src/app/components/accounts-table/accounts-table.component.html @@ -1,3 +1,14 @@ +
    + +
    +
    Alias
    diff --git a/apps/client/src/app/pages/resources/personal-finance-tools/products.ts b/apps/client/src/app/pages/resources/personal-finance-tools/products.ts index 1dce94310..15850f324 100644 --- a/apps/client/src/app/pages/resources/personal-finance-tools/products.ts +++ b/apps/client/src/app/pages/resources/personal-finance-tools/products.ts @@ -1,10 +1,12 @@ import { Product } from '@ghostfolio/common/interfaces'; import { AltooPageComponent } from './products/altoo-page.component'; +import { CapMonPageComponent } from './products/capmon-page.component'; import { CopilotMoneyPageComponent } from './products/copilot-money-page.component'; import { DeltaPageComponent } from './products/delta-page.component'; import { DivvyDiaryPageComponent } from './products/divvydiary-page.component'; import { ExirioPageComponent } from './products/exirio-page.component'; +import { FinaryPageComponent } from './products/finary-page.component'; import { FolisharePageComponent } from './products/folishare-page.component'; import { GetquinPageComponent } from './products/getquin-page.component'; import { GoSpatzPageComponent } from './products/gospatz-page.component'; @@ -22,6 +24,8 @@ import { SeekingAlphaPageComponent } from './products/seeking-alpha-page.compone import { SharesightPageComponent } from './products/sharesight-page.component'; import { SimplePortfolioPageComponent } from './products/simple-portfolio-page.component'; import { SnowballAnalyticsPageComponent } from './products/snowball-analytics-page.component'; +import { StocklePageComponent } from './products/stockle-page.component'; +import { StockMarketEyePageComponent } from './products/stockmarketeye-page.component'; import { SumioPageComponent } from './products/sumio-page.component'; import { UtlunaPageComponent } from './products/utluna-page.component'; import { YeekateePageComponent } from './products/yeekatee-page.component'; @@ -45,7 +49,7 @@ export const products: Product[] = [ ], name: 'Ghostfolio', origin: $localize`Switzerland`, - pricingPerYear: '$19', + pricingPerYear: '$24', region: $localize`Global`, slogan: 'Open Source Wealth Management', useAnonymously: true @@ -54,18 +58,25 @@ export const products: Product[] = [ component: AltooPageComponent, founded: 2017, hasSelfHostingAbility: false, - isOpenSource: false, key: 'altoo', name: 'Altoo Wealth Platform', origin: $localize`Switzerland`, slogan: 'Simplicity for Complex Wealth' }, + { + component: CapMonPageComponent, + founded: 2022, + key: 'capmon', + name: 'CapMon.org', + origin: $localize`Germany`, + note: 'Sunset in 2023', + slogan: 'Next Generation Assets Tracking' + }, { component: CopilotMoneyPageComponent, founded: 2019, hasFreePlan: false, hasSelfHostingAbility: false, - isOpenSource: false, key: 'copilot-money', name: 'Copilot Money', origin: $localize`United States`, @@ -77,7 +88,6 @@ export const products: Product[] = [ founded: 2017, hasFreePlan: true, hasSelfHostingAbility: false, - isOpenSource: false, key: 'delta', name: 'Delta Investment Tracker', note: 'Acquired by eToro', @@ -89,7 +99,6 @@ export const products: Product[] = [ founded: 2019, hasFreePlan: true, hasSelfHostingAbility: false, - isOpenSource: false, key: 'divvydiary', languages: ['Deutsch', 'English'], name: 'DivvyDiary', @@ -102,18 +111,25 @@ export const products: Product[] = [ founded: 2020, hasFreePlan: true, hasSelfHostingAbility: false, - isOpenSource: false, key: 'exirio', name: 'Exirio', origin: $localize`United States`, pricingPerYear: '$100', slogan: 'All your wealth, in one place.' }, + { + component: FinaryPageComponent, + founded: 2020, + key: 'finary', + languages: ['Deutsch', 'English', 'Français'], + name: 'Finary', + origin: $localize`United States`, + slogan: 'Real-Time Portfolio Tracker & Stock Tracker' + }, { component: FolisharePageComponent, hasFreePlan: true, hasSelfHostingAbility: false, - isOpenSource: false, key: 'folishare', languages: ['Deutsch', 'English'], name: 'folishare', @@ -126,7 +142,6 @@ export const products: Product[] = [ founded: 2020, hasFreePlan: true, hasSelfHostingAbility: false, - isOpenSource: false, key: 'getquin', languages: ['Deutsch', 'English'], name: 'getquin', @@ -138,7 +153,6 @@ export const products: Product[] = [ component: GoSpatzPageComponent, hasFreePlan: true, hasSelfHostingAbility: false, - isOpenSource: false, key: 'gospatz', name: 'goSPATZ', origin: $localize`Germany`, @@ -149,7 +163,6 @@ export const products: Product[] = [ founded: 2011, hasFreePlan: true, hasSelfHostingAbility: false, - isOpenSource: false, key: 'justetf', name: 'justETF', origin: $localize`Germany`, @@ -161,7 +174,6 @@ export const products: Product[] = [ founded: 2019, hasFreePlan: false, hasSelfHostingAbility: false, - isOpenSource: false, key: 'kubera', name: 'Kubera®', origin: $localize`United States`, @@ -173,7 +185,6 @@ export const products: Product[] = [ founded: 2022, hasFreePlan: true, hasSelfHostingAbility: false, - isOpenSource: false, key: 'markets.sh', languages: ['English'], name: 'markets.sh', @@ -186,7 +197,6 @@ export const products: Product[] = [ component: MaybeFinancePageComponent, founded: 2021, hasSelfHostingAbility: false, - isOpenSource: false, key: 'maybe-finance', languages: ['English'], name: 'Maybe Finance', @@ -200,7 +210,6 @@ export const products: Product[] = [ component: MonsePageComponent, hasFreePlan: false, hasSelfHostingAbility: false, - isOpenSource: false, key: 'monse', name: 'Monse', pricingPerYear: '$60', @@ -211,7 +220,6 @@ export const products: Product[] = [ founded: 2020, hasSelfHostingAbility: false, hasFreePlan: true, - isOpenSource: false, key: 'parqet', name: 'Parqet', note: 'Originally named as Tresor One', @@ -224,7 +232,6 @@ export const products: Product[] = [ component: PlannixPageComponent, founded: 2023, hasSelfHostingAbility: false, - isOpenSource: false, key: 'plannix', name: 'Plannix', origin: $localize`Italy`, @@ -234,7 +241,6 @@ export const products: Product[] = [ component: PortfolioDividendTrackerPageComponent, hasFreePlan: false, hasSelfHostingAbility: false, - isOpenSource: false, key: 'portfolio-dividend-tracker', languages: ['English', 'Nederlands'], name: 'Portfolio Dividend Tracker', @@ -247,7 +253,6 @@ export const products: Product[] = [ founded: 2021, hasFreePlan: true, hasSelfHostingAbility: false, - isOpenSource: false, key: 'portseido', languages: ['Deutsch', 'English', 'Français', 'Nederlands'], name: 'Portseido', @@ -260,7 +265,6 @@ export const products: Product[] = [ founded: 2021, hasFreePlan: true, hasSelfHostingAbility: true, - isOpenSource: false, key: 'projectionlab', name: 'ProjectionLab', origin: $localize`United States`, @@ -272,7 +276,6 @@ export const products: Product[] = [ founded: 2004, hasFreePlan: false, hasSelfHostingAbility: false, - isOpenSource: false, key: 'seeking-alpha', name: 'Seeking Alpha', origin: $localize`United States`, @@ -284,7 +287,6 @@ export const products: Product[] = [ founded: 2007, hasFreePlan: true, hasSelfHostingAbility: false, - isOpenSource: false, key: 'sharesight', name: 'Sharesight', origin: $localize`New Zealand`, @@ -296,7 +298,6 @@ export const products: Product[] = [ component: SimplePortfolioPageComponent, hasFreePlan: true, hasSelfHostingAbility: false, - isOpenSource: false, key: 'simple-portfolio', name: 'Simple Portfolio', origin: $localize`Czech Republic`, @@ -308,18 +309,32 @@ export const products: Product[] = [ founded: 2021, hasFreePlan: true, hasSelfHostingAbility: false, - isOpenSource: false, key: 'snowball-analytics', name: 'Snowball Analytics', - origin: 'France', + origin: $localize`France`, pricingPerYear: '$80', slogan: 'Simple and powerful portfolio tracker' }, + { + component: StocklePageComponent, + key: 'stockle', + name: 'Stockle', + origin: $localize`Finland`, + slogan: 'Supercharge your investments tracking experience' + }, + { + component: StockMarketEyePageComponent, + founded: 2008, + key: 'stockmarketeye', + name: 'StockMarketEye', + origin: $localize`France`, + note: 'Sunset in 2023', + slogan: 'A Powerful Portfolio & Investment Tracking App' + }, { component: SumioPageComponent, hasFreePlan: true, hasSelfHostingAbility: false, - isOpenSource: false, key: 'sumio', name: 'Sumio', origin: $localize`Czech Republic`, @@ -330,7 +345,6 @@ export const products: Product[] = [ component: UtlunaPageComponent, hasFreePlan: true, hasSelfHostingAbility: false, - isOpenSource: false, key: 'utluna', languages: ['Deutsch', 'English', 'Français'], name: 'Utluna', @@ -343,7 +357,6 @@ export const products: Product[] = [ component: YeekateePageComponent, founded: 2021, hasSelfHostingAbility: false, - isOpenSource: false, key: 'yeekatee', name: 'yeekatee', origin: $localize`Switzerland`, diff --git a/apps/client/src/app/pages/resources/personal-finance-tools/products/capmon-page.component.ts b/apps/client/src/app/pages/resources/personal-finance-tools/products/capmon-page.component.ts new file mode 100644 index 000000000..7df8872a3 --- /dev/null +++ b/apps/client/src/app/pages/resources/personal-finance-tools/products/capmon-page.component.ts @@ -0,0 +1,31 @@ +import { CommonModule } from '@angular/common'; +import { Component } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { RouterModule } from '@angular/router'; + +import { products } from '../products'; + +@Component({ + host: { class: 'page' }, + imports: [CommonModule, MatButtonModule, RouterModule], + selector: 'gf-capmon-page', + standalone: true, + styleUrls: ['../product-page-template.scss'], + templateUrl: '../product-page-template.html' +}) +export class CapMonPageComponent { + public product1 = products.find(({ key }) => { + return key === 'ghostfolio'; + }); + + public product2 = products.find(({ key }) => { + return key === 'capmon'; + }); + + public routerLinkAbout = ['/' + $localize`about`]; + public routerLinkFeatures = ['/' + $localize`features`]; + public routerLinkResourcesPersonalFinanceTools = [ + '/' + $localize`resources`, + 'personal-finance-tools' + ]; +} diff --git a/apps/client/src/app/pages/resources/personal-finance-tools/products/finary-page.component.ts b/apps/client/src/app/pages/resources/personal-finance-tools/products/finary-page.component.ts new file mode 100644 index 000000000..6b2d25827 --- /dev/null +++ b/apps/client/src/app/pages/resources/personal-finance-tools/products/finary-page.component.ts @@ -0,0 +1,31 @@ +import { CommonModule } from '@angular/common'; +import { Component } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { RouterModule } from '@angular/router'; + +import { products } from '../products'; + +@Component({ + host: { class: 'page' }, + imports: [CommonModule, MatButtonModule, RouterModule], + selector: 'gf-finary-page', + standalone: true, + styleUrls: ['../product-page-template.scss'], + templateUrl: '../product-page-template.html' +}) +export class FinaryPageComponent { + public product1 = products.find(({ key }) => { + return key === 'ghostfolio'; + }); + + public product2 = products.find(({ key }) => { + return key === 'finary'; + }); + + public routerLinkAbout = ['/' + $localize`about`]; + public routerLinkFeatures = ['/' + $localize`features`]; + public routerLinkResourcesPersonalFinanceTools = [ + '/' + $localize`resources`, + 'personal-finance-tools' + ]; +} diff --git a/apps/client/src/app/pages/resources/personal-finance-tools/products/stockle-page.component.ts b/apps/client/src/app/pages/resources/personal-finance-tools/products/stockle-page.component.ts new file mode 100644 index 000000000..eb1b41a9c --- /dev/null +++ b/apps/client/src/app/pages/resources/personal-finance-tools/products/stockle-page.component.ts @@ -0,0 +1,31 @@ +import { CommonModule } from '@angular/common'; +import { Component } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { RouterModule } from '@angular/router'; + +import { products } from '../products'; + +@Component({ + host: { class: 'page' }, + imports: [CommonModule, MatButtonModule, RouterModule], + selector: 'gf-stockle-page', + standalone: true, + styleUrls: ['../product-page-template.scss'], + templateUrl: '../product-page-template.html' +}) +export class StocklePageComponent { + public product1 = products.find(({ key }) => { + return key === 'ghostfolio'; + }); + + public product2 = products.find(({ key }) => { + return key === 'stockle'; + }); + + public routerLinkAbout = ['/' + $localize`about`]; + public routerLinkFeatures = ['/' + $localize`features`]; + public routerLinkResourcesPersonalFinanceTools = [ + '/' + $localize`resources`, + 'personal-finance-tools' + ]; +} diff --git a/apps/client/src/app/pages/resources/personal-finance-tools/products/stockmarketeye-page.component.ts b/apps/client/src/app/pages/resources/personal-finance-tools/products/stockmarketeye-page.component.ts new file mode 100644 index 000000000..8fda86382 --- /dev/null +++ b/apps/client/src/app/pages/resources/personal-finance-tools/products/stockmarketeye-page.component.ts @@ -0,0 +1,31 @@ +import { CommonModule } from '@angular/common'; +import { Component } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { RouterModule } from '@angular/router'; + +import { products } from '../products'; + +@Component({ + host: { class: 'page' }, + imports: [CommonModule, MatButtonModule, RouterModule], + selector: 'gf-stockmarketeye-page', + standalone: true, + styleUrls: ['../product-page-template.scss'], + templateUrl: '../product-page-template.html' +}) +export class StockMarketEyePageComponent { + public product1 = products.find(({ key }) => { + return key === 'ghostfolio'; + }); + + public product2 = products.find(({ key }) => { + return key === 'stockmarketeye'; + }); + + public routerLinkAbout = ['/' + $localize`about`]; + public routerLinkFeatures = ['/' + $localize`features`]; + public routerLinkResourcesPersonalFinanceTools = [ + '/' + $localize`resources`, + 'personal-finance-tools' + ]; +} diff --git a/apps/client/src/app/pages/user-account/user-account-page-routing.module.ts b/apps/client/src/app/pages/user-account/user-account-page-routing.module.ts index f52591d21..568095009 100644 --- a/apps/client/src/app/pages/user-account/user-account-page-routing.module.ts +++ b/apps/client/src/app/pages/user-account/user-account-page-routing.module.ts @@ -1,5 +1,8 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; +import { UserAccountAccessComponent } from '@ghostfolio/client/components/user-account-access/user-account-access.component'; +import { UserAccountMembershipComponent } from '@ghostfolio/client/components/user-account-membership/user-account-membership.component'; +import { UserAccountSettingsComponent } from '@ghostfolio/client/components/user-account-settings/user-account-settings.component'; import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; import { UserAccountPageComponent } from './user-account-page.component'; @@ -7,6 +10,23 @@ import { UserAccountPageComponent } from './user-account-page.component'; const routes: Routes = [ { canActivate: [AuthGuard], + children: [ + { + path: '', + component: UserAccountSettingsComponent, + title: $localize`Settings` + }, + { + path: 'membership', + component: UserAccountMembershipComponent, + title: $localize`Membership` + }, + { + path: 'access', + component: UserAccountAccessComponent, + title: $localize`Access` + } + ], component: UserAccountPageComponent, path: '', title: $localize`My Ghostfolio` diff --git a/apps/client/src/app/pages/user-account/user-account-page.component.ts b/apps/client/src/app/pages/user-account/user-account-page.component.ts index f59d1ed40..80c4a8f72 100644 --- a/apps/client/src/app/pages/user-account/user-account-page.component.ts +++ b/apps/client/src/app/pages/user-account/user-account-page.component.ts @@ -1,447 +1,63 @@ -import { - ChangeDetectorRef, - Component, - OnDestroy, - OnInit, - ViewChild -} from '@angular/core'; -import { MatCheckbox, MatCheckboxChange } from '@angular/material/checkbox'; -import { MatDialog } from '@angular/material/dialog'; -import { - MatSnackBar, - MatSnackBarRef, - TextOnlySnackBar -} from '@angular/material/snack-bar'; -import { ActivatedRoute, Router } from '@angular/router'; -import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto'; -import { DataService } from '@ghostfolio/client/services/data.service'; -import { - STAY_SIGNED_IN, - SettingsStorageService -} from '@ghostfolio/client/services/settings-storage.service'; +import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { UserService } from '@ghostfolio/client/services/user/user.service'; -import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service'; -import { downloadAsFile, getDateFormatString } from '@ghostfolio/common/helper'; -import { Access, User } from '@ghostfolio/common/interfaces'; -import { hasPermission, permissions } from '@ghostfolio/common/permissions'; -import { format, parseISO } from 'date-fns'; -import { uniq } from 'lodash'; +import { TabConfiguration, User } from '@ghostfolio/common/interfaces'; import { DeviceDetectorService } from 'ngx-device-detector'; -import { StripeService } from 'ngx-stripe'; -import { EMPTY, Subject } from 'rxjs'; -import { catchError, switchMap, takeUntil } from 'rxjs/operators'; - -import { CreateOrUpdateAccessDialog } from './create-or-update-access-dialog/create-or-update-access-dialog.component'; +import { Subject, takeUntil } from 'rxjs'; @Component({ - host: { class: 'page' }, + host: { class: 'page has-tabs' }, selector: 'gf-user-account-page', styleUrls: ['./user-account-page.scss'], templateUrl: './user-account-page.html' }) export class UserAccountPageComponent implements OnDestroy, OnInit { - @ViewChild('toggleSignInWithFingerprintEnabledElement') - signInWithFingerprintElement: MatCheckbox; - - public accesses: Access[]; - public appearancePlaceholder = $localize`Auto`; - public baseCurrency: string; - public coupon: number; - public couponId: string; - public currencies: string[] = []; - public defaultDateFormat: string; public deviceType: string; - public hasPermissionForSubscription: boolean; - public hasPermissionToCreateAccess: boolean; - public hasPermissionToDeleteAccess: boolean; - public hasPermissionToUpdateViewMode: boolean; - public hasPermissionToUpdateUserSettings: boolean; - public language = document.documentElement.lang; - public locales = [ - 'de', - 'de-CH', - 'en-GB', - 'en-US', - 'es', - 'fr', - 'it', - 'nl', - 'pt' - ]; - public price: number; - public priceId: string; - public snackBarRef: MatSnackBarRef; - public trySubscriptionMail = - 'mailto:hi@ghostfol.io?Subject=Ghostfolio Premium Trial&body=Hello%0D%0DI am interested in Ghostfolio Premium. Can you please send me a coupon code to try it for some time?%0D%0DKind regards'; + public tabs: TabConfiguration[] = []; public user: User; private unsubscribeSubject = new Subject(); public constructor( private changeDetectorRef: ChangeDetectorRef, - private dataService: DataService, private deviceService: DeviceDetectorService, - private dialog: MatDialog, - private snackBar: MatSnackBar, - private route: ActivatedRoute, - private router: Router, - private settingsStorageService: SettingsStorageService, - private stripeService: StripeService, - private userService: UserService, - public webAuthnService: WebAuthnService + private userService: UserService ) { - const { baseCurrency, currencies, globalPermissions, subscriptions } = - this.dataService.fetchInfo(); - - this.baseCurrency = baseCurrency; - this.currencies = currencies; - - this.hasPermissionForSubscription = hasPermission( - globalPermissions, - permissions.enableSubscription - ); - - this.hasPermissionToDeleteAccess = hasPermission( - globalPermissions, - permissions.deleteAccess - ); - this.userService.stateChanged .pipe(takeUntil(this.unsubscribeSubject)) .subscribe((state) => { if (state?.user) { this.user = state.user; - this.defaultDateFormat = getDateFormatString( - this.user.settings.locale - ); - - this.hasPermissionToCreateAccess = hasPermission( - this.user.permissions, - permissions.createAccess - ); - - this.hasPermissionToDeleteAccess = hasPermission( - this.user.permissions, - permissions.deleteAccess - ); - - this.hasPermissionToUpdateUserSettings = hasPermission( - this.user.permissions, - permissions.updateUserSettings - ); - - this.hasPermissionToUpdateViewMode = hasPermission( - this.user.permissions, - permissions.updateViewMode - ); - - this.locales.push(this.user.settings.locale); - this.locales = uniq(this.locales.sort()); - - this.coupon = subscriptions?.[this.user.subscription.offer]?.coupon; - this.couponId = - subscriptions?.[this.user.subscription.offer]?.couponId; - this.price = subscriptions?.[this.user.subscription.offer]?.price; - this.priceId = subscriptions?.[this.user.subscription.offer]?.priceId; + this.tabs = [ + { + iconName: 'settings-outline', + label: $localize`Settings`, + path: ['/account'] + }, + { + iconName: 'diamond-outline', + label: $localize`Membership`, + path: ['/account/membership'], + showCondition: !!this.user?.subscription + }, + { + iconName: 'share-social-outline', + label: $localize`Access`, + path: ['/account', 'access'] + } + ]; this.changeDetectorRef.markForCheck(); } }); - - this.route.queryParams - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe((params) => { - if (params['createDialog']) { - this.openCreateAccessDialog(); - } - }); } public ngOnInit() { this.deviceType = this.deviceService.getDeviceInfo().deviceType; - - this.update(); - } - - public onChangeUserSetting(aKey: string, aValue: string) { - this.dataService - .putUserSetting({ [aKey]: aValue }) - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(() => { - this.userService.remove(); - - this.userService - .get() - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe((user) => { - this.user = user; - - this.changeDetectorRef.markForCheck(); - - if (aKey === 'language') { - if (aValue) { - window.location.href = `../${aValue}/account`; - } else { - window.location.href = `../`; - } - } - }); - }); - } - - public onCheckout() { - this.dataService - .createCheckoutSession({ couponId: this.couponId, priceId: this.priceId }) - .pipe( - switchMap(({ sessionId }: { sessionId: string }) => { - return this.stripeService.redirectToCheckout({ sessionId }); - }), - catchError((error) => { - alert(error.message); - throw error; - }) - ) - .subscribe((result) => { - if (result.error) { - alert(result.error.message); - } - }); - } - - public onDeleteAccess(aId: string) { - this.dataService - .deleteAccess(aId) - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe({ - next: () => { - this.update(); - } - }); - } - - public onExperimentalFeaturesChange(aEvent: MatCheckboxChange) { - this.dataService - .putUserSetting({ isExperimentalFeatures: aEvent.checked }) - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(() => { - this.userService.remove(); - - this.userService - .get() - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe((user) => { - this.user = user; - - this.changeDetectorRef.markForCheck(); - }); - }); - } - - public onExport() { - this.dataService - .fetchExport() - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe((data) => { - for (const activity of data.activities) { - delete activity.id; - } - - downloadAsFile({ - content: data, - fileName: `ghostfolio-export-${format( - parseISO(data.meta.date), - 'yyyyMMddHHmm' - )}.json`, - format: 'json' - }); - }); - } - - public onRedeemCoupon() { - let couponCode = prompt($localize`Please enter your coupon code:`); - couponCode = couponCode?.trim(); - - if (couponCode) { - this.dataService - .redeemCoupon(couponCode) - .pipe( - takeUntil(this.unsubscribeSubject), - catchError(() => { - this.snackBar.open( - '😞 ' + $localize`Could not redeem coupon code`, - undefined, - { - duration: 3000 - } - ); - - return EMPTY; - }) - ) - .subscribe(() => { - this.snackBarRef = this.snackBar.open( - '✅ ' + $localize`Coupon code has been redeemed`, - $localize`Reload`, - { - duration: 3000 - } - ); - - this.snackBarRef - .afterDismissed() - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(() => { - window.location.reload(); - }); - - this.snackBarRef - .onAction() - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(() => { - window.location.reload(); - }); - }); - } - } - - public onRestrictedViewChange(aEvent: MatCheckboxChange) { - this.dataService - .putUserSetting({ isRestrictedView: aEvent.checked }) - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(() => { - this.userService.remove(); - - this.userService - .get() - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe((user) => { - this.user = user; - - this.changeDetectorRef.markForCheck(); - }); - }); - } - - public onSignInWithFingerprintChange(aEvent: MatCheckboxChange) { - if (aEvent.checked) { - this.registerDevice(); - } else { - const confirmation = confirm( - $localize`Do you really want to remove this sign in method?` - ); - - if (confirmation) { - this.deregisterDevice(); - } else { - this.update(); - } - } - } - - public onViewModeChange(aEvent: MatCheckboxChange) { - this.dataService - .putUserSetting({ viewMode: aEvent.checked === true ? 'ZEN' : 'DEFAULT' }) - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(() => { - this.userService.remove(); - - this.userService - .get() - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe((user) => { - this.user = user; - - this.changeDetectorRef.markForCheck(); - }); - }); } public ngOnDestroy() { this.unsubscribeSubject.next(); this.unsubscribeSubject.complete(); } - - private openCreateAccessDialog(): void { - const dialogRef = this.dialog.open(CreateOrUpdateAccessDialog, { - data: { - access: { - alias: '', - type: 'PUBLIC' - } - }, - height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', - width: this.deviceType === 'mobile' ? '100vw' : '50rem' - }); - - dialogRef - .afterClosed() - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe((data: any) => { - const access: CreateAccessDto = data?.access; - - if (access) { - this.dataService - .postAccess({ alias: access.alias }) - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe({ - next: () => { - this.update(); - } - }); - } - - this.router.navigate(['.'], { relativeTo: this.route }); - }); - } - - private deregisterDevice() { - this.webAuthnService - .deregister() - .pipe( - takeUntil(this.unsubscribeSubject), - catchError(() => { - this.update(); - - return EMPTY; - }) - ) - .subscribe(() => { - this.update(); - }); - } - - private registerDevice() { - this.webAuthnService - .register() - .pipe( - takeUntil(this.unsubscribeSubject), - catchError(() => { - this.update(); - - return EMPTY; - }) - ) - .subscribe(() => { - this.settingsStorageService.removeSetting(STAY_SIGNED_IN); - - this.update(); - }); - } - - private update() { - this.dataService - .fetchAccesses() - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe((response) => { - this.accesses = response; - - if (this.signInWithFingerprintElement) { - this.signInWithFingerprintElement.checked = - this.webAuthnService.isEnabled() ?? false; - } - - this.changeDetectorRef.markForCheck(); - }); - } } diff --git a/apps/client/src/app/pages/user-account/user-account-page.html b/apps/client/src/app/pages/user-account/user-account-page.html index 24cfce6e1..d3fbca534 100644 --- a/apps/client/src/app/pages/user-account/user-account-page.html +++ b/apps/client/src/app/pages/user-account/user-account-page.html @@ -1,310 +1,29 @@ -
    -
    -
    -

    Account

    -
    -
    -
    -
    - - -
    -
    Membership
    -
    - -
    - Valid until {{ - user?.subscription?.expiresAt | date: defaultDateFormat }} -
    -
    - - -
    - {{ baseCurrency }} {{ price }} {{ baseCurrency }} {{ price - coupon - }} - {{ baseCurrency }} {{ price }} per year -
    -
    - Try Premium - - Redeem Coupon -
    -
    -
    -
    -
    -
    Presenter View
    -
    - Protection for sensitive information like absolute performances - and quantity values -
    -
    -
    - -
    -
    -
    -
    -
    -
    - Base Currency -
    -
    - - - {{ currency }} - - -
    -
    -
    -
    -
    Language
    -
    -
    - - - - Deutsch - English - Español (Community) - Français (Community) - Italiano (Community) - Nederlands (Community) - Português (Community) - - -
    -
    -
    -
    -
    Locale
    -
    - Date and number format -
    -
    -
    - - - - {{ locale }} - - -
    -
    -
    -
    - Appearance -
    -
    - - - Auto - Light - Dark - - -
    -
    - -
    -
    -
    -
    Zen Mode
    -
    - Distraction-free experience for turbulent times -
    -
    -
    - -
    -
    -
    -
    -
    Biometric Authentication
    -
    - Sign in with fingerprint -
    -
    -
    - -
    -
    -
    -
    -
    Experimental Features
    -
    - Sneak peek at upcoming functionality -
    -
    -
    - -
    -
    -
    -
    User ID
    -
    {{ user?.id }}
    -
    -
    -
    -
    - -
    -
    -
    -
    -
    -
    -
    -
    -

    Granted Access

    - -
    -
    + + + - -
    + + diff --git a/apps/client/src/app/pages/user-account/user-account-page.module.ts b/apps/client/src/app/pages/user-account/user-account-page.module.ts index 240441ada..5ec767170 100644 --- a/apps/client/src/app/pages/user-account/user-account-page.module.ts +++ b/apps/client/src/app/pages/user-account/user-account-page.module.ts @@ -1,18 +1,10 @@ import { CommonModule } from '@angular/common'; -import { NgModule } from '@angular/core'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatCardModule } from '@angular/material/card'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MatDialogModule } from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatSelectModule } from '@angular/material/select'; -import { RouterModule } from '@angular/router'; -import { GfPortfolioAccessTableModule } from '@ghostfolio/client/components/access-table/access-table.module'; -import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator'; -import { GfValueModule } from '@ghostfolio/ui/value'; +import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; +import { MatTabsModule } from '@angular/material/tabs'; +import { GfUserAccountAccessModule } from '@ghostfolio/client/components/user-account-access/user-account-access.module'; +import { GfUserAccountMembershipModule } from '@ghostfolio/client/components/user-account-membership/user-account-membership.module'; +import { GfUserAccountSettingsModule } from '@ghostfolio/client/components/user-account-settings/user-account-settings.module'; -import { GfCreateOrUpdateAccessDialogModule } from './create-or-update-access-dialog/create-or-update-access-dialog.module'; import { UserAccountPageRoutingModule } from './user-account-page-routing.module'; import { UserAccountPageComponent } from './user-account-page.component'; @@ -20,20 +12,12 @@ import { UserAccountPageComponent } from './user-account-page.component'; declarations: [UserAccountPageComponent], imports: [ CommonModule, - FormsModule, - GfCreateOrUpdateAccessDialogModule, - GfPortfolioAccessTableModule, - GfPremiumIndicatorModule, - GfValueModule, - MatButtonModule, - MatCardModule, - MatCheckboxModule, - MatDialogModule, - MatFormFieldModule, - MatSelectModule, - ReactiveFormsModule, - RouterModule, + GfUserAccountAccessModule, + GfUserAccountMembershipModule, + GfUserAccountSettingsModule, + MatTabsModule, UserAccountPageRoutingModule - ] + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] }) export class UserAccountPageModule {} diff --git a/apps/client/src/app/pages/user-account/user-account-page.scss b/apps/client/src/app/pages/user-account/user-account-page.scss index 6a010a85d..6a0b74854 100644 --- a/apps/client/src/app/pages/user-account/user-account-page.scss +++ b/apps/client/src/app/pages/user-account/user-account-page.scss @@ -1,22 +1,7 @@ +@import 'apps/client/src/styles/ghostfolio-style'; + :host { color: rgb(var(--dark-primary-text)); - display: block; - - gf-access-table { - overflow-x: auto; - } - - .fab-container { - position: fixed; - right: 2rem; - bottom: 2rem; - z-index: 999; - } - - .hint-text { - font-size: 90%; - line-height: 1.2; - } } :host-context(.is-dark-theme) { diff --git a/apps/client/src/app/pages/zen/zen-page.component.ts b/apps/client/src/app/pages/zen/zen-page.component.ts index 9f769e281..b0906ea6c 100644 --- a/apps/client/src/app/pages/zen/zen-page.component.ts +++ b/apps/client/src/app/pages/zen/zen-page.component.ts @@ -1,33 +1,27 @@ -import { ViewportScroller } from '@angular/common'; -import { - AfterViewInit, - ChangeDetectorRef, - Component, - OnDestroy, - OnInit -} from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; +import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { TabConfiguration, User } from '@ghostfolio/common/interfaces'; +import { DeviceDetectorService } from 'ngx-device-detector'; import { Subject } from 'rxjs'; -import { first, takeUntil } from 'rxjs/operators'; +import { takeUntil } from 'rxjs/operators'; @Component({ + host: { class: 'page has-tabs' }, selector: 'gf-zen-page', - templateUrl: './zen-page.html', - styleUrls: ['./zen-page.scss'] + styleUrls: ['./zen-page.scss'], + templateUrl: './zen-page.html' }) -export class ZenPageComponent implements AfterViewInit, OnDestroy, OnInit { +export class ZenPageComponent implements OnDestroy, OnInit { + public deviceType: string; public tabs: TabConfiguration[] = []; public user: User; private unsubscribeSubject = new Subject(); public constructor( - private route: ActivatedRoute, private changeDetectorRef: ChangeDetectorRef, - private userService: UserService, - private viewportScroller: ViewportScroller + private deviceService: DeviceDetectorService, + private userService: UserService ) { this.userService.stateChanged .pipe(takeUntil(this.unsubscribeSubject)) @@ -52,12 +46,8 @@ export class ZenPageComponent implements AfterViewInit, OnDestroy, OnInit { }); } - public ngOnInit() {} - - public ngAfterViewInit(): void { - this.route.fragment - .pipe(first()) - .subscribe((fragment) => this.viewportScroller.scrollToAnchor(fragment)); + public ngOnInit() { + this.deviceType = this.deviceService.getDeviceInfo().deviceType; } public ngOnDestroy() { diff --git a/apps/client/src/app/pages/zen/zen-page.html b/apps/client/src/app/pages/zen/zen-page.html index ce1d84851..d3fbca534 100644 --- a/apps/client/src/app/pages/zen/zen-page.html +++ b/apps/client/src/app/pages/zen/zen-page.html @@ -2,7 +2,12 @@ -
    @@ -508,7 +476,7 @@
    @@ -93,9 +104,7 @@ Activities - {{ - element.transactionCount - }} + {{ element.transactionCount }} {{ transactionCount }} diff --git a/apps/client/src/app/components/accounts-table/accounts-table.component.ts b/apps/client/src/app/components/accounts-table/accounts-table.component.ts index 77b7b4d0f..81333ef2d 100644 --- a/apps/client/src/app/components/accounts-table/accounts-table.component.ts +++ b/apps/client/src/app/components/accounts-table/accounts-table.component.ts @@ -34,6 +34,7 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit { @Output() accountDeleted = new EventEmitter(); @Output() accountToUpdate = new EventEmitter(); + @Output() transferBalance = new EventEmitter(); @ViewChild(MatSort) sort: MatSort; @@ -97,6 +98,10 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit { alert(aComment); } + public onTransferBalance() { + this.transferBalance.emit(); + } + public onUpdateAccount(aAccount: AccountModel) { this.accountToUpdate.emit(aAccount); } diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts index be1892e91..792025e9b 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts @@ -13,7 +13,6 @@ import { AdminService } from '@ghostfolio/client/services/admin.service'; import { DataService } from '@ghostfolio/client/services/data.service'; import { AdminMarketDataDetails, - ScraperConfiguration, UniqueAsset } from '@ghostfolio/common/interfaces'; import { translate } from '@ghostfolio/ui/i18n'; @@ -146,9 +145,11 @@ export class AssetProfileDialog implements OnDestroy, OnInit { .postBenchmark({ dataSource, symbol }) .pipe(takeUntil(this.unsubscribeSubject)) .subscribe(() => { - setTimeout(() => { - window.location.reload(); - }, 300); + this.dataService.updateInfo(); + + this.isBenchmark = true; + + this.changeDetectorRef.markForCheck(); }); } @@ -185,6 +186,19 @@ export class AssetProfileDialog implements OnDestroy, OnInit { }); } + public onUnsetBenchmark({ dataSource, symbol }: UniqueAsset) { + this.dataService + .deleteBenchmark({ dataSource, symbol }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(() => { + this.dataService.updateInfo(); + + this.isBenchmark = false; + + this.changeDetectorRef.markForCheck(); + }); + } + public ngOnDestroy() { this.unsubscribeSubject.next(); this.unsubscribeSubject.complete(); diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html index be99df7cb..6682d004d 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html @@ -37,13 +37,6 @@ > Gather Profile Data - @@ -151,6 +144,17 @@ +
    +
    + Benchmark +
    +
    Symbol Mapping diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.module.ts b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.module.ts index 8672342b0..1911f5a47 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.module.ts +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.module.ts @@ -3,6 +3,7 @@ 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 { MatInputModule } from '@angular/material/input'; import { MatMenuModule } from '@angular/material/menu'; @@ -21,6 +22,7 @@ import { AssetProfileDialog } from './asset-profile-dialog.component'; GfPortfolioProportionChartModule, GfValueModule, MatButtonModule, + MatCheckboxModule, MatDialogModule, MatInputModule, MatMenuModule, diff --git a/apps/client/src/app/components/admin-overview/admin-overview.component.ts b/apps/client/src/app/components/admin-overview/admin-overview.component.ts index 2053c4298..b1e91dfc9 100644 --- a/apps/client/src/app/components/admin-overview/admin-overview.component.ts +++ b/apps/client/src/app/components/admin-overview/admin-overview.component.ts @@ -1,5 +1,6 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { MatCheckboxChange } from '@angular/material/checkbox'; +import { environment } from '@ghostfolio/client/../environments/environment'; import { AdminService } from '@ghostfolio/client/services/admin.service'; import { CacheService } from '@ghostfolio/client/services/cache.service'; import { DataService } from '@ghostfolio/client/services/data.service'; @@ -42,6 +43,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit { public transactionCount: number; public userCount: number; public user: User; + public version: string; private unsubscribeSubject = new Subject(); @@ -202,15 +204,18 @@ export class AdminOverviewComponent implements OnDestroy, OnInit { this.adminService .fetchAdminData() .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(({ exchangeRates, settings, transactionCount, userCount }) => { - this.coupons = (settings[PROPERTY_COUPONS] as Coupon[]) ?? []; - this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[]; - this.exchangeRates = exchangeRates; - this.transactionCount = transactionCount; - this.userCount = userCount; - - this.changeDetectorRef.markForCheck(); - }); + .subscribe( + ({ exchangeRates, settings, transactionCount, userCount, version }) => { + this.coupons = (settings[PROPERTY_COUPONS] as Coupon[]) ?? []; + this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[]; + this.exchangeRates = exchangeRates; + this.transactionCount = transactionCount; + this.userCount = userCount; + this.version = version; + + this.changeDetectorRef.markForCheck(); + } + ); } private generateCouponCode(aLength: number) { diff --git a/apps/client/src/app/components/admin-overview/admin-overview.html b/apps/client/src/app/components/admin-overview/admin-overview.html index f72c88050..47f27dd40 100644 --- a/apps/client/src/app/components/admin-overview/admin-overview.html +++ b/apps/client/src/app/components/admin-overview/admin-overview.html @@ -3,12 +3,18 @@
    +
    +
    Version
    +
    + +
    +
    User Count
    @@ -17,8 +23,8 @@
    Activity Count
    @@ -72,19 +78,6 @@
    -
    -
    Tags
    -
    - - - - -
    {{ tag.name }}
    -
    -
    User Signup
    diff --git a/apps/client/src/app/components/admin-platform/admin-platform.component.ts b/apps/client/src/app/components/admin-platform/admin-platform.component.ts index f3fbb348d..ffc5810b3 100644 --- a/apps/client/src/app/components/admin-platform/admin-platform.component.ts +++ b/apps/client/src/app/components/admin-platform/admin-platform.component.ts @@ -13,13 +13,14 @@ import { ActivatedRoute, Router } from '@angular/router'; import { CreatePlatformDto } from '@ghostfolio/api/app/platform/create-platform.dto'; import { UpdatePlatformDto } from '@ghostfolio/api/app/platform/update-platform.dto'; import { AdminService } from '@ghostfolio/client/services/admin.service'; +import { DataService } from '@ghostfolio/client/services/data.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { Platform } from '@prisma/client'; import { get } from 'lodash'; import { DeviceDetectorService } from 'ngx-device-detector'; import { Subject, takeUntil } from 'rxjs'; -import { CreateOrUpdatePlatformDialog } from './create-or-update-platform-dialog/create-or-update-account-platform.component'; +import { CreateOrUpdatePlatformDialog } from './create-or-update-platform-dialog/create-or-update-platform-dialog.component'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -40,6 +41,7 @@ export class AdminPlatformComponent implements OnInit, OnDestroy { public constructor( private adminService: AdminService, private changeDetectorRef: ChangeDetectorRef, + private dataService: DataService, private deviceService: DeviceDetectorService, private dialog: MatDialog, private route: ActivatedRoute, @@ -114,10 +116,13 @@ export class AdminPlatformComponent implements OnInit, OnDestroy { .pipe(takeUntil(this.unsubscribeSubject)) .subscribe((platforms) => { this.platforms = platforms; + this.dataSource = new MatTableDataSource(platforms); this.dataSource.sort = this.sort; this.dataSource.sortingDataAccessor = get; + this.dataService.updateInfo(); + this.changeDetectorRef.markForCheck(); }); } @@ -130,7 +135,6 @@ export class AdminPlatformComponent implements OnInit, OnDestroy { url: null } }, - height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', width: this.deviceType === 'mobile' ? '100vw' : '50rem' }); @@ -170,7 +174,6 @@ export class AdminPlatformComponent implements OnInit, OnDestroy { url } }, - height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', width: this.deviceType === 'mobile' ? '100vw' : '50rem' }); diff --git a/apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-account-platform.component.ts b/apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-platform-dialog.component.ts similarity index 98% rename from apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-account-platform.component.ts rename to apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-platform-dialog.component.ts index 55d466a9a..14d893900 100644 --- a/apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-account-platform.component.ts +++ b/apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-platform-dialog.component.ts @@ -15,8 +15,8 @@ export class CreateOrUpdatePlatformDialog { private unsubscribeSubject = new Subject(); public constructor( - public dialogRef: MatDialogRef, - @Inject(MAT_DIALOG_DATA) public data: CreateOrUpdatePlatformDialogParams + @Inject(MAT_DIALOG_DATA) public data: CreateOrUpdatePlatformDialogParams, + public dialogRef: MatDialogRef ) {} public onCancel() { diff --git a/apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-platform-dialog.module.ts b/apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-platform-dialog.module.ts index cbc74b9cf..bf576480a 100644 --- a/apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-platform-dialog.module.ts +++ b/apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-platform-dialog.module.ts @@ -6,7 +6,7 @@ import { MatDialogModule } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; -import { CreateOrUpdatePlatformDialog } from './create-or-update-account-platform.component'; +import { CreateOrUpdatePlatformDialog } from './create-or-update-platform-dialog.component'; @NgModule({ declarations: [CreateOrUpdatePlatformDialog], diff --git a/apps/client/src/app/components/admin-settings/admin-settings.component.html b/apps/client/src/app/components/admin-settings/admin-settings.component.html index 6f23a4056..4c4a6df1e 100644 --- a/apps/client/src/app/components/admin-settings/admin-settings.component.html +++ b/apps/client/src/app/components/admin-settings/admin-settings.component.html @@ -2,14 +2,13 @@

    Platforms

    - +
    -
    diff --git a/apps/client/src/app/components/admin-settings/admin-settings.component.ts b/apps/client/src/app/components/admin-settings/admin-settings.component.ts index 97066d533..35e6d5c63 100644 --- a/apps/client/src/app/components/admin-settings/admin-settings.component.ts +++ b/apps/client/src/app/components/admin-settings/admin-settings.component.ts @@ -8,7 +8,6 @@ import { Subject } from 'rxjs'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, - host: { class: 'page' }, selector: 'gf-admin-settings', styleUrls: ['./admin-settings.component.scss'], templateUrl: './admin-settings.component.html' diff --git a/apps/client/src/app/components/admin-settings/admin-settings.module.ts b/apps/client/src/app/components/admin-settings/admin-settings.module.ts index aaa16651f..e778c113d 100644 --- a/apps/client/src/app/components/admin-settings/admin-settings.module.ts +++ b/apps/client/src/app/components/admin-settings/admin-settings.module.ts @@ -2,12 +2,18 @@ import { CommonModule } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { RouterModule } from '@angular/router'; import { GfAdminPlatformModule } from '@ghostfolio/client/components/admin-platform/admin-platform.module'; +import { GfAdminTagModule } from '@ghostfolio/client/components/admin-tag/admin-tag.module'; import { AdminSettingsComponent } from './admin-settings.component'; @NgModule({ declarations: [AdminSettingsComponent], - imports: [CommonModule, GfAdminPlatformModule, RouterModule], + imports: [ + CommonModule, + GfAdminPlatformModule, + GfAdminTagModule, + RouterModule + ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }) export class GfAdminSettingsModule {} diff --git a/apps/client/src/app/components/admin-tag/admin-tag.component.html b/apps/client/src/app/components/admin-tag/admin-tag.component.html new file mode 100644 index 000000000..d21523321 --- /dev/null +++ b/apps/client/src/app/components/admin-tag/admin-tag.component.html @@ -0,0 +1,85 @@ +
    +
    +
    + + + + + + + + + + + + + + + + + + + +
    + Name + + {{ element.name }} + + Activities + + {{ element.activityCount }} + + + + + + +
    +
    +
    +
    diff --git a/apps/client/src/app/components/admin-tag/admin-tag.component.scss b/apps/client/src/app/components/admin-tag/admin-tag.component.scss new file mode 100644 index 000000000..b5b58f67e --- /dev/null +++ b/apps/client/src/app/components/admin-tag/admin-tag.component.scss @@ -0,0 +1,5 @@ +@import 'apps/client/src/styles/ghostfolio-style'; + +:host { + display: block; +} diff --git a/apps/client/src/app/components/admin-tag/admin-tag.component.ts b/apps/client/src/app/components/admin-tag/admin-tag.component.ts new file mode 100644 index 000000000..e0dce2477 --- /dev/null +++ b/apps/client/src/app/components/admin-tag/admin-tag.component.ts @@ -0,0 +1,204 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + OnDestroy, + OnInit, + ViewChild +} from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSort } from '@angular/material/sort'; +import { MatTableDataSource } from '@angular/material/table'; +import { ActivatedRoute, Router } from '@angular/router'; +import { CreateTagDto } from '@ghostfolio/api/app/tag/create-tag.dto'; +import { UpdateTagDto } from '@ghostfolio/api/app/tag/update-tag.dto'; +import { AdminService } from '@ghostfolio/client/services/admin.service'; +import { DataService } from '@ghostfolio/client/services/data.service'; +import { UserService } from '@ghostfolio/client/services/user/user.service'; +import { Tag } from '@prisma/client'; +import { get } from 'lodash'; +import { DeviceDetectorService } from 'ngx-device-detector'; +import { Subject, takeUntil } from 'rxjs'; + +import { CreateOrUpdateTagDialog } from './create-or-update-tag-dialog/create-or-update-tag-dialog.component'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'gf-admin-tag', + styleUrls: ['./admin-tag.component.scss'], + templateUrl: './admin-tag.component.html' +}) +export class AdminTagComponent implements OnInit, OnDestroy { + @ViewChild(MatSort) sort: MatSort; + + public dataSource: MatTableDataSource = new MatTableDataSource(); + public deviceType: string; + public displayedColumns = ['name', 'activities', 'actions']; + public tags: Tag[]; + + private unsubscribeSubject = new Subject(); + + public constructor( + private adminService: AdminService, + private changeDetectorRef: ChangeDetectorRef, + private dataService: DataService, + private deviceService: DeviceDetectorService, + private dialog: MatDialog, + private route: ActivatedRoute, + private router: Router, + private userService: UserService + ) { + this.route.queryParams + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((params) => { + if (params['createTagDialog']) { + this.openCreateTagDialog(); + } else if (params['editTagDialog']) { + if (this.tags) { + const tag = this.tags.find(({ id }) => { + return id === params['tagId']; + }); + + this.openUpdateTagDialog(tag); + } else { + this.router.navigate(['.'], { relativeTo: this.route }); + } + } + }); + } + + public ngOnInit() { + this.deviceType = this.deviceService.getDeviceInfo().deviceType; + + this.fetchTags(); + } + + public onDeleteTag(aId: string) { + const confirmation = confirm( + $localize`Do you really want to delete this tag?` + ); + + if (confirmation) { + this.deleteTag(aId); + } + } + + public onUpdateTag({ id }: Tag) { + this.router.navigate([], { + queryParams: { editTagDialog: true, tagId: id } + }); + } + + public ngOnDestroy() { + this.unsubscribeSubject.next(); + this.unsubscribeSubject.complete(); + } + + private deleteTag(aId: string) { + this.adminService + .deleteTag(aId) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe({ + next: () => { + this.userService + .get(true) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(); + + this.fetchTags(); + } + }); + } + + private fetchTags() { + this.adminService + .fetchTags() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((tags) => { + this.tags = tags; + + this.dataSource = new MatTableDataSource(this.tags); + this.dataSource.sort = this.sort; + this.dataSource.sortingDataAccessor = get; + + this.dataService.updateInfo(); + + this.changeDetectorRef.markForCheck(); + }); + } + + private openCreateTagDialog() { + const dialogRef = this.dialog.open(CreateOrUpdateTagDialog, { + data: { + tag: { + name: null + } + }, + height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', + width: this.deviceType === 'mobile' ? '100vw' : '50rem' + }); + + dialogRef + .afterClosed() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((data) => { + const tag: CreateTagDto = data?.tag; + + if (tag) { + this.adminService + .postTag(tag) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe({ + next: () => { + this.userService + .get(true) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(); + + this.fetchTags(); + } + }); + } + + this.router.navigate(['.'], { relativeTo: this.route }); + }); + } + + private openUpdateTagDialog({ id, name }) { + const dialogRef = this.dialog.open(CreateOrUpdateTagDialog, { + data: { + tag: { + id, + name + } + }, + height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', + width: this.deviceType === 'mobile' ? '100vw' : '50rem' + }); + + dialogRef + .afterClosed() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((data) => { + const tag: UpdateTagDto = data?.tag; + + if (tag) { + this.adminService + .putTag(tag) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe({ + next: () => { + this.userService + .get(true) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(); + + this.fetchTags(); + } + }); + } + + this.router.navigate(['.'], { relativeTo: this.route }); + }); + } +} diff --git a/apps/client/src/app/components/admin-tag/admin-tag.module.ts b/apps/client/src/app/components/admin-tag/admin-tag.module.ts new file mode 100644 index 000000000..aec5ac5a6 --- /dev/null +++ b/apps/client/src/app/components/admin-tag/admin-tag.module.ts @@ -0,0 +1,26 @@ +import { CommonModule } from '@angular/common'; +import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatSortModule } from '@angular/material/sort'; +import { MatTableModule } from '@angular/material/table'; +import { RouterModule } from '@angular/router'; + +import { AdminTagComponent } from './admin-tag.component'; +import { GfCreateOrUpdateTagDialogModule } from './create-or-update-tag-dialog/create-or-update-tag-dialog.module'; + +@NgModule({ + declarations: [AdminTagComponent], + exports: [AdminTagComponent], + imports: [ + CommonModule, + GfCreateOrUpdateTagDialogModule, + MatButtonModule, + MatMenuModule, + MatSortModule, + MatTableModule, + RouterModule + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] +}) +export class GfAdminTagModule {} diff --git a/apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/create-or-update-tag-dialog.component.ts b/apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/create-or-update-tag-dialog.component.ts new file mode 100644 index 000000000..aaa5a0221 --- /dev/null +++ b/apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/create-or-update-tag-dialog.component.ts @@ -0,0 +1,30 @@ +import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Subject } from 'rxjs'; + +import { CreateOrUpdateTagDialogParams } from './interfaces/interfaces'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'h-100' }, + selector: 'gf-create-or-update-tag-dialog', + styleUrls: ['./create-or-update-tag-dialog.scss'], + templateUrl: 'create-or-update-tag-dialog.html' +}) +export class CreateOrUpdateTagDialog { + private unsubscribeSubject = new Subject(); + + public constructor( + @Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateTagDialogParams, + public dialogRef: MatDialogRef + ) {} + + public onCancel() { + this.dialogRef.close(); + } + + public ngOnDestroy() { + this.unsubscribeSubject.next(); + this.unsubscribeSubject.complete(); + } +} diff --git a/apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/create-or-update-tag-dialog.html b/apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/create-or-update-tag-dialog.html new file mode 100644 index 000000000..c2e8f4ee1 --- /dev/null +++ b/apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/create-or-update-tag-dialog.html @@ -0,0 +1,23 @@ +
    +

    Update tag

    +

    Add tag

    +
    +
    + + Name + + +
    +
    +
    + + +
    +
    diff --git a/apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/create-or-update-tag-dialog.module.ts b/apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/create-or-update-tag-dialog.module.ts new file mode 100644 index 000000000..d8b12edc2 --- /dev/null +++ b/apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/create-or-update-tag-dialog.module.ts @@ -0,0 +1,23 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialogModule } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; + +import { CreateOrUpdateTagDialog } from './create-or-update-tag-dialog.component'; + +@NgModule({ + declarations: [CreateOrUpdateTagDialog], + imports: [ + CommonModule, + FormsModule, + MatButtonModule, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + ReactiveFormsModule + ] +}) +export class GfCreateOrUpdateTagDialogModule {} diff --git a/apps/client/src/app/pages/user-account/create-or-update-access-dialog/create-or-update-access-dialog.scss b/apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/create-or-update-tag-dialog.scss similarity index 100% rename from apps/client/src/app/pages/user-account/create-or-update-access-dialog/create-or-update-access-dialog.scss rename to apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/create-or-update-tag-dialog.scss diff --git a/apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/interfaces/interfaces.ts b/apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/interfaces/interfaces.ts new file mode 100644 index 000000000..bd7214786 --- /dev/null +++ b/apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/interfaces/interfaces.ts @@ -0,0 +1,5 @@ +import { Tag } from '@prisma/client'; + +export interface CreateOrUpdateTagDialogParams { + tag: Tag; +} diff --git a/apps/client/src/app/components/header/header.component.html b/apps/client/src/app/components/header/header.component.html index 5c412a1b9..45986df95 100644 --- a/apps/client/src/app/components/header/header.component.html +++ b/apps/client/src/app/components/header/header.component.html @@ -1,14 +1,17 @@ - + - - - +
    + + + +
    -
      +
      • About
      • +
      • + + + + +
      • +
        + {{ baseCurrency }} {{ price }} {{ baseCurrency }} {{ price - coupon + }} + {{ baseCurrency }} {{ price }} per year +
        + + Try Premium + + Redeem Coupon +
    +
    +
    + + + diff --git a/apps/client/src/app/components/user-account-membership/user-account-membership.module.ts b/apps/client/src/app/components/user-account-membership/user-account-membership.module.ts new file mode 100644 index 000000000..bef027c62 --- /dev/null +++ b/apps/client/src/app/components/user-account-membership/user-account-membership.module.ts @@ -0,0 +1,23 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { RouterModule } from '@angular/router'; +import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator'; +import { GfValueModule } from '@ghostfolio/ui/value'; + +import { UserAccountMembershipComponent } from './user-account-membership.component'; + +@NgModule({ + declarations: [UserAccountMembershipComponent], + exports: [UserAccountMembershipComponent], + imports: [ + CommonModule, + GfPremiumIndicatorModule, + GfValueModule, + MatButtonModule, + MatCardModule, + RouterModule + ] +}) +export class GfUserAccountMembershipModule {} diff --git a/apps/client/src/app/components/user-account-membership/user-account-membership.scss b/apps/client/src/app/components/user-account-membership/user-account-membership.scss new file mode 100644 index 000000000..39eb6792e --- /dev/null +++ b/apps/client/src/app/components/user-account-membership/user-account-membership.scss @@ -0,0 +1,8 @@ +:host { + color: rgb(var(--dark-primary-text)); + display: block; +} + +:host-context(.is-dark-theme) { + color: rgb(var(--light-primary-text)); +} diff --git a/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts b/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts new file mode 100644 index 000000000..a52812ed3 --- /dev/null +++ b/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts @@ -0,0 +1,258 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + OnDestroy, + OnInit, + ViewChild +} from '@angular/core'; +import { MatCheckbox, MatCheckboxChange } from '@angular/material/checkbox'; +import { DataService } from '@ghostfolio/client/services/data.service'; +import { + STAY_SIGNED_IN, + SettingsStorageService +} from '@ghostfolio/client/services/settings-storage.service'; +import { UserService } from '@ghostfolio/client/services/user/user.service'; +import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service'; +import { downloadAsFile } from '@ghostfolio/common/helper'; +import { User } from '@ghostfolio/common/interfaces'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import { format, parseISO } from 'date-fns'; +import { uniq } from 'lodash'; +import { EMPTY, Subject } from 'rxjs'; +import { catchError, takeUntil } from 'rxjs/operators'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'gf-user-account-settings', + styleUrls: ['./user-account-settings.scss'], + templateUrl: './user-account-settings.html' +}) +export class UserAccountSettingsComponent implements OnDestroy, OnInit { + @ViewChild('toggleSignInWithFingerprintEnabledElement') + signInWithFingerprintElement: MatCheckbox; + + public appearancePlaceholder = $localize`Auto`; + public baseCurrency: string; + public currencies: string[] = []; + public hasPermissionToUpdateViewMode: boolean; + public hasPermissionToUpdateUserSettings: boolean; + public language = document.documentElement.lang; + public locales = [ + 'de', + 'de-CH', + 'en-GB', + 'en-US', + 'es', + 'fr', + 'it', + 'nl', + 'pt', + 'tr' + ]; + public user: User; + + private unsubscribeSubject = new Subject(); + + public constructor( + private changeDetectorRef: ChangeDetectorRef, + private dataService: DataService, + private settingsStorageService: SettingsStorageService, + private userService: UserService, + public webAuthnService: WebAuthnService + ) { + const { baseCurrency, currencies } = this.dataService.fetchInfo(); + + this.baseCurrency = baseCurrency; + this.currencies = currencies; + + this.userService.stateChanged + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((state) => { + if (state?.user) { + this.user = state.user; + + this.hasPermissionToUpdateUserSettings = hasPermission( + this.user.permissions, + permissions.updateUserSettings + ); + + this.hasPermissionToUpdateViewMode = hasPermission( + this.user.permissions, + permissions.updateViewMode + ); + + this.locales.push(this.user.settings.locale); + this.locales = uniq(this.locales.sort()); + + this.changeDetectorRef.markForCheck(); + } + }); + } + + public ngOnInit() { + this.update(); + } + + public onChangeUserSetting(aKey: string, aValue: string) { + this.dataService + .putUserSetting({ [aKey]: aValue }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(() => { + this.userService.remove(); + + this.userService + .get() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((user) => { + this.user = user; + + this.changeDetectorRef.markForCheck(); + + if (aKey === 'language') { + if (aValue) { + window.location.href = `../${aValue}/account`; + } else { + window.location.href = `../`; + } + } + }); + }); + } + + public onExperimentalFeaturesChange(aEvent: MatCheckboxChange) { + this.dataService + .putUserSetting({ isExperimentalFeatures: aEvent.checked }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(() => { + this.userService.remove(); + + this.userService + .get() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((user) => { + this.user = user; + + this.changeDetectorRef.markForCheck(); + }); + }); + } + + public onExport() { + this.dataService + .fetchExport() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((data) => { + for (const activity of data.activities) { + delete activity.id; + } + + downloadAsFile({ + content: data, + fileName: `ghostfolio-export-${format( + parseISO(data.meta.date), + 'yyyyMMddHHmm' + )}.json`, + format: 'json' + }); + }); + } + + public onRestrictedViewChange(aEvent: MatCheckboxChange) { + this.dataService + .putUserSetting({ isRestrictedView: aEvent.checked }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(() => { + this.userService.remove(); + + this.userService + .get() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((user) => { + this.user = user; + + this.changeDetectorRef.markForCheck(); + }); + }); + } + + public onSignInWithFingerprintChange(aEvent: MatCheckboxChange) { + if (aEvent.checked) { + this.registerDevice(); + } else { + const confirmation = confirm( + $localize`Do you really want to remove this sign in method?` + ); + + if (confirmation) { + this.deregisterDevice(); + } else { + this.update(); + } + } + } + + public onViewModeChange(aEvent: MatCheckboxChange) { + this.dataService + .putUserSetting({ viewMode: aEvent.checked === true ? 'ZEN' : 'DEFAULT' }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(() => { + this.userService.remove(); + + this.userService + .get() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((user) => { + this.user = user; + + this.changeDetectorRef.markForCheck(); + }); + }); + } + + public ngOnDestroy() { + this.unsubscribeSubject.next(); + this.unsubscribeSubject.complete(); + } + + private deregisterDevice() { + this.webAuthnService + .deregister() + .pipe( + takeUntil(this.unsubscribeSubject), + catchError(() => { + this.update(); + + return EMPTY; + }) + ) + .subscribe(() => { + this.update(); + }); + } + + private registerDevice() { + this.webAuthnService + .register() + .pipe( + takeUntil(this.unsubscribeSubject), + catchError(() => { + this.update(); + + return EMPTY; + }) + ) + .subscribe(() => { + this.settingsStorageService.removeSetting(STAY_SIGNED_IN); + + this.update(); + }); + } + + private update() { + if (this.signInWithFingerprintElement) { + this.signInWithFingerprintElement.checked = + this.webAuthnService.isEnabled() ?? false; + } + } +} diff --git a/apps/client/src/app/components/user-account-settings/user-account-settings.html b/apps/client/src/app/components/user-account-settings/user-account-settings.html new file mode 100644 index 000000000..12f3da458 --- /dev/null +++ b/apps/client/src/app/components/user-account-settings/user-account-settings.html @@ -0,0 +1,197 @@ +
    +

    Settings

    +
    +
    +
    +
    +
    Presenter View
    +
    + Protection for sensitive information like absolute performances and + quantity values +
    +
    +
    + +
    +
    +
    +
    +
    +
    + Base Currency +
    +
    + + + {{ currency }} + + +
    +
    +
    +
    +
    Language
    +
    +
    + + + + Deutsch + English + Español (Community) + Français (Community) + Italiano (Community) + Nederlands (Community) + Português (Community) + Türkçe (Community) + + +
    +
    +
    +
    +
    Locale
    +
    + Date and number format +
    +
    +
    + + + + {{ locale }} + + +
    +
    +
    +
    + Appearance +
    +
    + + + Auto + Light + Dark + + +
    +
    +
    +
    +
    +
    +
    Zen Mode
    +
    + Distraction-free experience for turbulent times +
    +
    +
    + +
    +
    +
    +
    +
    Biometric Authentication
    +
    Sign in with fingerprint
    +
    +
    + +
    +
    +
    +
    +
    Experimental Features
    +
    + Sneak peek at upcoming functionality +
    +
    +
    + +
    +
    +
    +
    User ID
    +
    {{ user?.id }}
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    diff --git a/apps/client/src/app/components/user-account-settings/user-account-settings.module.ts b/apps/client/src/app/components/user-account-settings/user-account-settings.module.ts new file mode 100644 index 000000000..24e57ff20 --- /dev/null +++ b/apps/client/src/app/components/user-account-settings/user-account-settings.module.ts @@ -0,0 +1,30 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; +import { RouterModule } from '@angular/router'; +import { GfValueModule } from '@ghostfolio/ui/value'; + +import { UserAccountSettingsComponent } from './user-account-settings.component'; + +@NgModule({ + declarations: [UserAccountSettingsComponent], + exports: [UserAccountSettingsComponent], + imports: [ + CommonModule, + FormsModule, + GfValueModule, + MatButtonModule, + MatCardModule, + MatCheckboxModule, + MatFormFieldModule, + MatSelectModule, + ReactiveFormsModule, + RouterModule + ] +}) +export class GfUserAccountSettingsModule {} diff --git a/apps/client/src/app/components/user-account-settings/user-account-settings.scss b/apps/client/src/app/components/user-account-settings/user-account-settings.scss new file mode 100644 index 000000000..1bcd1c65a --- /dev/null +++ b/apps/client/src/app/components/user-account-settings/user-account-settings.scss @@ -0,0 +1,13 @@ +:host { + color: rgb(var(--dark-primary-text)); + display: block; + + .hint-text { + font-size: 90%; + line-height: 1.2; + } +} + +:host-context(.is-dark-theme) { + color: rgb(var(--light-primary-text)); +} diff --git a/apps/client/src/app/directives/file-drop/file-drop.directive.ts b/apps/client/src/app/directives/file-drop/file-drop.directive.ts new file mode 100644 index 000000000..129f6f251 --- /dev/null +++ b/apps/client/src/app/directives/file-drop/file-drop.directive.ts @@ -0,0 +1,28 @@ +import { Directive, EventEmitter, HostListener, Output } from '@angular/core'; + +@Directive({ + selector: '[gfFileDrop]' +}) +export class FileDropDirective { + @Output() filesDropped = new EventEmitter(); + + @HostListener('dragenter', ['$event']) onDragEnter(event: DragEvent) { + event.preventDefault(); + event.stopPropagation(); + } + + @HostListener('dragover', ['$event']) onDragOver(event: DragEvent) { + event.preventDefault(); + event.stopPropagation(); + } + + @HostListener('drop', ['$event']) onDrop(event: DragEvent) { + event.preventDefault(); + event.stopPropagation(); + + // Prevent the browser's default behavior for handling the file drop + event.dataTransfer.dropEffect = 'copy'; + + this.filesDropped.emit(event.dataTransfer.files); + } +} diff --git a/apps/client/src/app/directives/file-drop/file-drop.module.ts b/apps/client/src/app/directives/file-drop/file-drop.module.ts new file mode 100644 index 000000000..a0148516e --- /dev/null +++ b/apps/client/src/app/directives/file-drop/file-drop.module.ts @@ -0,0 +1,9 @@ +import { NgModule } from '@angular/core'; + +import { FileDropDirective } from './file-drop.directive'; + +@NgModule({ + declarations: [FileDropDirective], + exports: [FileDropDirective] +}) +export class GfFileDropModule {} diff --git a/apps/client/src/app/pages/about/about-page.component.ts b/apps/client/src/app/pages/about/about-page.component.ts index ef000d49b..807dd8066 100644 --- a/apps/client/src/app/pages/about/about-page.component.ts +++ b/apps/client/src/app/pages/about/about-page.component.ts @@ -1,28 +1,20 @@ -import { - ChangeDetectorRef, - Component, - HostBinding, - OnDestroy, - OnInit -} from '@angular/core'; +import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { DataService } from '@ghostfolio/client/services/data.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { TabConfiguration, User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import { DeviceDetectorService } from 'ngx-device-detector'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; @Component({ + host: { class: 'page has-tabs' }, selector: 'gf-about-page', styleUrls: ['./about-page.scss'], templateUrl: './about-page.html' }) export class AboutPageComponent implements OnDestroy, OnInit { - @HostBinding('class.with-info-message') get getHasMessage() { - return this.hasMessage; - } - - public hasMessage: boolean; + public deviceType: string; public hasPermissionForSubscription: boolean; public tabs: TabConfiguration[] = []; public user: User; @@ -32,9 +24,10 @@ export class AboutPageComponent implements OnDestroy, OnInit { public constructor( private changeDetectorRef: ChangeDetectorRef, private dataService: DataService, + private deviceService: DeviceDetectorService, private userService: UserService ) { - const { globalPermissions, systemMessage } = this.dataService.fetchInfo(); + const { globalPermissions } = this.dataService.fetchInfo(); this.hasPermissionForSubscription = hasPermission( globalPermissions, @@ -71,12 +64,6 @@ export class AboutPageComponent implements OnDestroy, OnInit { }); this.user = state.user; - this.hasMessage = - hasPermission( - this.user?.permissions, - permissions.createUserAccount - ) || !!systemMessage; - this.changeDetectorRef.markForCheck(); } @@ -88,7 +75,9 @@ export class AboutPageComponent implements OnDestroy, OnInit { }); } - public ngOnInit() {} + public ngOnInit() { + this.deviceType = this.deviceService.getDeviceInfo().deviceType; + } public ngOnDestroy() { this.unsubscribeSubject.next(); diff --git a/apps/client/src/app/pages/about/about-page.html b/apps/client/src/app/pages/about/about-page.html index ce1d84851..d3fbca534 100644 --- a/apps/client/src/app/pages/about/about-page.html +++ b/apps/client/src/app/pages/about/about-page.html @@ -2,7 +2,12 @@ -
    - ✅ Yes❌ No - ✅ Yes❌ No -
    - - - - - {{ element.type }} -
    +
    + + +
    +
    +
    Holdings
    + + + +
    No entries...
    +
    +
    +
    + diff --git a/libs/ui/src/lib/assistant/assistant.module.ts b/libs/ui/src/lib/assistant/assistant.module.ts new file mode 100644 index 000000000..e4f48e8f3 --- /dev/null +++ b/libs/ui/src/lib/assistant/assistant.module.ts @@ -0,0 +1,25 @@ +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 { RouterModule } from '@angular/router'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; + +import { GfAssistantListItemModule } from './assistant-list-item/assistant-list-item.module'; +import { AssistantComponent } from './assistant.component'; + +@NgModule({ + declarations: [AssistantComponent], + exports: [AssistantComponent], + imports: [ + CommonModule, + FormsModule, + GfAssistantListItemModule, + MatButtonModule, + NgxSkeletonLoaderModule, + ReactiveFormsModule, + RouterModule + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] +}) +export class GfAssistantModule {} diff --git a/libs/ui/src/lib/assistant/assistant.scss b/libs/ui/src/lib/assistant/assistant.scss new file mode 100644 index 000000000..3339009d4 --- /dev/null +++ b/libs/ui/src/lib/assistant/assistant.scss @@ -0,0 +1,37 @@ +:host { + display: block; + + .result-container { + max-height: 15rem; + } + + .search-container { + border-bottom: 1px solid rgba(var(--dark-dividers)); + height: 2.5rem; + + input { + background: transparent; + outline: 0; + } + + .hot-key-hint { + border: 1px solid rgba(var(--dark-dividers)); + border-radius: 0.25rem; + cursor: default; + } + } +} + +:host-context(.is-dark-theme) { + .search-container { + border-color: rgba(var(--light-dividers)); + + input { + color: rgba(var(--light-primary-text)); + } + + .hot-key-hint { + border-color: rgba(var(--light-dividers)); + } + } +} diff --git a/libs/ui/src/lib/assistant/index.ts b/libs/ui/src/lib/assistant/index.ts new file mode 100644 index 000000000..f58d1b2f3 --- /dev/null +++ b/libs/ui/src/lib/assistant/index.ts @@ -0,0 +1 @@ +export * from './assistant.module'; diff --git a/libs/ui/src/lib/assistant/interfaces/interfaces.ts b/libs/ui/src/lib/assistant/interfaces/interfaces.ts new file mode 100644 index 000000000..922091fb5 --- /dev/null +++ b/libs/ui/src/lib/assistant/interfaces/interfaces.ts @@ -0,0 +1,5 @@ +import { Position } from '@ghostfolio/common/interfaces'; + +export interface ISearchResults { + holdings: Position[]; +} diff --git a/libs/ui/src/lib/carousel/carousel-item.directive.ts b/libs/ui/src/lib/carousel/carousel-item.directive.ts new file mode 100644 index 000000000..95fefe5cc --- /dev/null +++ b/libs/ui/src/lib/carousel/carousel-item.directive.ts @@ -0,0 +1,16 @@ +import { FocusableOption } from '@angular/cdk/a11y'; +import { Directive, ElementRef, HostBinding } from '@angular/core'; + +@Directive({ + selector: '[gf-carousel-item]' +}) +export class CarouselItem implements FocusableOption { + @HostBinding('attr.role') readonly role = 'listitem'; + @HostBinding('tabindex') tabindex = '-1'; + + public constructor(readonly element: ElementRef) {} + + public focus(): void { + this.element.nativeElement.focus({ preventScroll: true }); + } +} diff --git a/libs/ui/src/lib/carousel/carousel.component.html b/libs/ui/src/lib/carousel/carousel.component.html new file mode 100644 index 000000000..59966b8a6 --- /dev/null +++ b/libs/ui/src/lib/carousel/carousel.component.html @@ -0,0 +1,34 @@ + + +
    + +
    + + diff --git a/libs/ui/src/lib/carousel/carousel.component.scss b/libs/ui/src/lib/carousel/carousel.component.scss new file mode 100644 index 000000000..38da7c100 --- /dev/null +++ b/libs/ui/src/lib/carousel/carousel.component.scss @@ -0,0 +1,34 @@ +:host { + display: block; + position: relative; + + ::ng-deep { + [gf-carousel-item] { + flex-shrink: 0; + width: 100%; + } + } + + button { + top: 50%; + transform: translateY(-50%); + + &.carousel-nav-prev { + left: -50px; + } + + &.carousel-nav-next { + right: -50px; + } + } + + .carousel-content { + flex-direction: row; + outline: none; + transition: transform 0.5s ease-in-out; + + .animations-disabled & { + transition: none; + } + } +} diff --git a/libs/ui/src/lib/carousel/carousel.component.ts b/libs/ui/src/lib/carousel/carousel.component.ts new file mode 100644 index 000000000..a0eb0f8a1 --- /dev/null +++ b/libs/ui/src/lib/carousel/carousel.component.ts @@ -0,0 +1,147 @@ +import { FocusKeyManager } from '@angular/cdk/a11y'; +import { LEFT_ARROW, RIGHT_ARROW, TAB } from '@angular/cdk/keycodes'; +import { + AfterContentInit, + ChangeDetectionStrategy, + Component, + ContentChildren, + ElementRef, + HostBinding, + Inject, + Input, + Optional, + QueryList, + ViewChild +} from '@angular/core'; +import { ANIMATION_MODULE_TYPE } from '@angular/platform-browser/animations'; + +import { CarouselItem } from './carousel-item.directive'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'gf-carousel', + styleUrls: ['./carousel.component.scss'], + templateUrl: './carousel.component.html' +}) +export class CarouselComponent implements AfterContentInit { + @ContentChildren(CarouselItem) public items!: QueryList; + + @HostBinding('class.animations-disabled') + public readonly animationsDisabled: boolean; + + @Input('aria-label') public ariaLabel: string | undefined; + + @ViewChild('list') public list!: ElementRef; + + public showPrevArrow = false; + public showNextArrow = true; + + private index = 0; + private keyManager!: FocusKeyManager; + private position = 0; + + public constructor( + @Optional() @Inject(ANIMATION_MODULE_TYPE) animationsModule?: string + ) { + this.animationsDisabled = animationsModule === 'NoopAnimations'; + } + + public ngAfterContentInit() { + this.keyManager = new FocusKeyManager(this.items); + } + + public next() { + for (let i = this.index; i < this.items.length; i++) { + if (this.isOutOfView(i)) { + this.index = i; + this.scrollToActiveItem(); + break; + } + } + } + + public onKeydown({ keyCode }: KeyboardEvent) { + const manager = this.keyManager; + const previousActiveIndex = manager.activeItemIndex; + + if (keyCode === LEFT_ARROW) { + manager.setPreviousItemActive(); + } else if (keyCode === RIGHT_ARROW) { + manager.setNextItemActive(); + } else if (keyCode === TAB && !manager.activeItem) { + manager.setFirstItemActive(); + } + + if ( + manager.activeItemIndex != null && + manager.activeItemIndex !== previousActiveIndex + ) { + this.index = manager.activeItemIndex; + this.updateItemTabIndices(); + + if (this.isOutOfView(this.index)) { + this.scrollToActiveItem(); + } + } + } + + public previous() { + for (let i = this.index; i > -1; i--) { + if (this.isOutOfView(i)) { + this.index = i; + this.scrollToActiveItem(); + break; + } + } + } + + private isOutOfView(index: number, side?: 'start' | 'end') { + const { offsetWidth, offsetLeft } = + this.items.toArray()[index].element.nativeElement; + + if ((!side || side === 'start') && offsetLeft - this.position < 0) { + return true; + } + + return ( + (!side || side === 'end') && + offsetWidth + offsetLeft - this.position > + this.list.nativeElement.clientWidth + ); + } + + private scrollToActiveItem() { + if (!this.isOutOfView(this.index)) { + return; + } + + const itemsArray = this.items.toArray(); + let targetItemIndex = this.index; + + if (this.index > 0 && !this.isOutOfView(this.index - 1)) { + targetItemIndex = + itemsArray.findIndex((_, i) => !this.isOutOfView(i)) + 1; + } + + this.position = + itemsArray[targetItemIndex].element.nativeElement.offsetLeft; + this.list.nativeElement.style.transform = `translateX(-${this.position}px)`; + this.showPrevArrow = this.index > 0; + this.showNextArrow = false; + + for (let i = itemsArray.length - 1; i > -1; i--) { + if (this.isOutOfView(i, 'end')) { + this.showNextArrow = true; + break; + } + } + } + + private updateItemTabIndices() { + this.items.forEach((item: CarouselItem) => { + if (this.keyManager != null) { + item.tabindex = item === this.keyManager.activeItem ? '0' : '-1'; + } + }); + } +} diff --git a/libs/ui/src/lib/carousel/carousel.module.ts b/libs/ui/src/lib/carousel/carousel.module.ts new file mode 100644 index 000000000..4e43f23b0 --- /dev/null +++ b/libs/ui/src/lib/carousel/carousel.module.ts @@ -0,0 +1,14 @@ +import { CommonModule } from '@angular/common'; +import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; + +import { CarouselItem } from './carousel-item.directive'; +import { CarouselComponent } from './carousel.component'; + +@NgModule({ + declarations: [CarouselComponent, CarouselItem], + exports: [CarouselComponent, CarouselItem], + imports: [CommonModule, MatButtonModule], + schemas: [CUSTOM_ELEMENTS_SCHEMA] +}) +export class GfCarouselModule {} diff --git a/libs/ui/src/lib/carousel/index.ts b/libs/ui/src/lib/carousel/index.ts new file mode 100644 index 000000000..2e039a80b --- /dev/null +++ b/libs/ui/src/lib/carousel/index.ts @@ -0,0 +1 @@ +export * from './carousel.module'; diff --git a/libs/ui/src/lib/i18n.ts b/libs/ui/src/lib/i18n.ts index d0445ade9..6be2833f7 100644 --- a/libs/ui/src/lib/i18n.ts +++ b/libs/ui/src/lib/i18n.ts @@ -21,7 +21,6 @@ const locales = { PRESET_ID: $localize`Preset`, RETIREMENT_PROVISION: $localize`Retirement Provision`, SATELLITE: $localize`Satellite`, - SECURITIES: $localize`Securities`, SYMBOL: $localize`Symbol`, TAG: $localize`Tag`, YEAR: $localize`Year`, @@ -30,6 +29,8 @@ const locales = { // Activity types BUY: $localize`Buy`, DIVIDEND: $localize`Dividend`, + FEE: $localize`Fee`, + INTEREST: $localize`Interest`, ITEM: $localize`Valuable`, LIABILITY: $localize`Liability`, SELL: $localize`Sell`, diff --git a/package.json b/package.json index 20dfd65da..46c05f611 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostfolio", - "version": "2.0.0", + "version": "2.9.0", "homepage": "https://ghostfol.io", "license": "AGPL-3.0", "repository": "https://github.com/ghostfolio/ghostfolio", @@ -81,7 +81,7 @@ "@nestjs/platform-express": "10.1.3", "@nestjs/schedule": "3.0.2", "@nestjs/serve-static": "4.0.0", - "@prisma/client": "5.2.0", + "@prisma/client": "5.3.1", "@simplewebauthn/browser": "5.2.1", "@simplewebauthn/server": "5.2.1", "@stripe/stripe-js": "1.47.0", @@ -122,14 +122,14 @@ "passport": "0.6.0", "passport-google-oauth20": "2.0.0", "passport-jwt": "4.0.0", - "prisma": "5.2.0", + "prisma": "5.3.1", "reflect-metadata": "0.1.13", "rxjs": "7.5.6", "stripe": "11.12.0", "svgmap": "2.6.0", "twitter-api-v2": "1.14.2", "uuid": "9.0.0", - "yahoo-finance2": "2.5.0", + "yahoo-finance2": "2.8.0", "zone.js": "0.13.1" }, "devDependencies": { @@ -189,11 +189,12 @@ "jest-preset-angular": "13.1.1", "nx": "16.7.4", "nx-cloud": "16.3.0", - "prettier": "3.0.2", + "prettier": "3.0.3", "prettier-plugin-organize-attributes": "1.0.0", "react": "18.2.0", "react-dom": "18.2.0", "replace-in-file": "7.0.1", + "shx": "0.3.4", "storybook": "7.0.9", "ts-jest": "29.1.0", "ts-node": "10.9.1", diff --git a/prisma/migrations/20230915141658_changed_account_type_to_optional_in_account/migration.sql b/prisma/migrations/20230915141658_changed_account_type_to_optional_in_account/migration.sql new file mode 100644 index 000000000..715610542 --- /dev/null +++ b/prisma/migrations/20230915141658_changed_account_type_to_optional_in_account/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Account" ALTER COLUMN "accountType" DROP NOT NULL, +ALTER COLUMN "accountType" DROP DEFAULT; diff --git a/prisma/migrations/20230917074305_added_fee_to_order_type/migration.sql b/prisma/migrations/20230917074305_added_fee_to_order_type/migration.sql new file mode 100644 index 000000000..ab1c75455 --- /dev/null +++ b/prisma/migrations/20230917074305_added_fee_to_order_type/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "Type" ADD VALUE 'FEE'; diff --git a/prisma/migrations/20230918204124_added_interest_to_order_type/migration.sql b/prisma/migrations/20230918204124_added_interest_to_order_type/migration.sql new file mode 100644 index 000000000..d85858f23 --- /dev/null +++ b/prisma/migrations/20230918204124_added_interest_to_order_type/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "Type" ADD VALUE 'INTEREST'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ebc7ca3be..d4db1b708 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -21,7 +21,7 @@ model Access { } model Account { - accountType AccountType @default(SECURITIES) + accountType AccountType? balance Float @default(0) balances AccountBalance[] comment String? @@ -249,6 +249,8 @@ enum Role { enum Type { BUY DIVIDEND + FEE + INTEREST ITEM LIABILITY SELL diff --git a/test/import/ok-500-activities.json b/test/import/ok-500-activities.json index 9fe1aa8c8..f2aeab113 100644 --- a/test/import/ok-500-activities.json +++ b/test/import/ok-500-activities.json @@ -5,7 +5,6 @@ }, "accounts": [ { - "accountType": "SECURITIES", "balance": 2000, "currency": "USD", "id": "b2d3fe1d-d6a8-41a3-be39-07ef5e9480f0", @@ -6016,4 +6015,4 @@ "symbol": "AAPL" } ] -} \ No newline at end of file +} diff --git a/test/import/ok.csv b/test/import/ok.csv index 2d27422ab..9f1f1c768 100644 --- a/test/import/ok.csv +++ b/test/import/ok.csv @@ -1,5 +1,5 @@ -Date,Code,Currency,Price,Quantity,Action,Fee +Date,Code,Currency,Price,Quantity,Action,Fee,Note +16-09-2021,MSFT,USD,298.580,5,buy,19.00,My first order 🤓 17/11/2021,MSFT,USD,0.62,5,dividend,0.00 -16/09/2021,MSFT,USD,298.580,5,buy,19.00 -01/01/2022,Penthouse Apartment,USD,500000.0,1,item,0.00 -06/06/2050,MSFT,USD,0.00,0,buy,0.00 +01.01.2022,Penthouse Apartment,USD,500000.0,1,item,0.00 +20500606,MSFT,USD,0.00,0,buy,0.00 diff --git a/test/import/ok.json b/test/import/ok.json index 335d2cd8a..8803c9d08 100644 --- a/test/import/ok.json +++ b/test/import/ok.json @@ -5,7 +5,6 @@ }, "accounts": [ { - "accountType": "SECURITIES", "balance": 2000, "currency": "USD", "id": "b2d3fe1d-d6a8-41a3-be39-07ef5e9480f0", @@ -53,7 +52,7 @@ }, { "accountId": "b2d3fe1d-d6a8-41a3-be39-07ef5e9480f0", - "comment": "My first order", + "comment": "My first order 🤓", "fee": 19, "quantity": 5, "type": "BUY", @@ -62,6 +61,18 @@ "dataSource": "YAHOO", "date": "2021-09-15T22:00:00.000Z", "symbol": "MSFT" + }, + { + "accountId": "b2d3fe1d-d6a8-41a3-be39-07ef5e9480f0", + "comment": null, + "fee": 49, + "quantity": 0, + "type": "FEE", + "unitPrice": 0, + "currency": "USD", + "dataSource": "MANUAL", + "date": "2021-08-31T22:00:00.000Z", + "symbol": "Account Opening Fee" } ] } diff --git a/yarn.lock b/yarn.lock index 9c97a9607..69f995b5d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4293,22 +4293,22 @@ resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== -"@prisma/client@5.2.0": - version "5.2.0" - resolved "https://registry.yarnpkg.com/@prisma/client/-/client-5.2.0.tgz#cbfdd440614b38736563a7999f39922fcde0ed50" - integrity sha512-AiTjJwR4J5Rh6Z/9ZKrBBLel3/5DzUNntMohOy7yObVnVoTNVFi2kvpLZlFuKO50d7yDspOtW6XBpiAd0BVXbQ== +"@prisma/client@5.3.1": + version "5.3.1" + resolved "https://registry.yarnpkg.com/@prisma/client/-/client-5.3.1.tgz#fc7fc2d91e814cc4fe18a4bc5e78bf851c26985e" + integrity sha512-ArOKjHwdFZIe1cGU56oIfy7wRuTn0FfZjGuU/AjgEBOQh+4rDkB6nF+AGHP8KaVpkBIiHGPQh3IpwQ3xDMdO0Q== dependencies: - "@prisma/engines-version" "5.2.0-25.2804dc98259d2ea960602aca6b8e7fdc03c1758f" + "@prisma/engines-version" "5.3.1-2.61e140623197a131c2a6189271ffee05a7aa9a59" -"@prisma/engines-version@5.2.0-25.2804dc98259d2ea960602aca6b8e7fdc03c1758f": - version "5.2.0-25.2804dc98259d2ea960602aca6b8e7fdc03c1758f" - resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-5.2.0-25.2804dc98259d2ea960602aca6b8e7fdc03c1758f.tgz#11366e7ff031c908debf4983248d40046016de37" - integrity sha512-jsnKT5JIDIE01lAeCj2ghY9IwxkedhKNvxQeoyLs6dr4ZXynetD0vTy7u6wMJt8vVPv8I5DPy/I4CFaoXAgbtg== +"@prisma/engines-version@5.3.1-2.61e140623197a131c2a6189271ffee05a7aa9a59": + version "5.3.1-2.61e140623197a131c2a6189271ffee05a7aa9a59" + resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-5.3.1-2.61e140623197a131c2a6189271ffee05a7aa9a59.tgz#7eb6f5c6b7628b8b39df55c903f411528a6f761c" + integrity sha512-y5qbUi3ql2Xg7XraqcXEdMHh0MocBfnBzDn5GbV1xk23S3Mq8MGs+VjacTNiBh3dtEdUERCrUUG7Z3QaJ+h79w== -"@prisma/engines@5.2.0": - version "5.2.0" - resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-5.2.0.tgz#e5dff48eb324c8137393933292d44ea5c3bc2ce7" - integrity sha512-dT7FOLUCdZmq+AunLqB1Iz+ZH/IIS1Fz2THmKZQ6aFONrQD/BQ5ecJ7g2wGS2OgyUFf4OaLam6/bxmgdOBDqig== +"@prisma/engines@5.3.1": + version "5.3.1" + resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-5.3.1.tgz#53cc72a5ed176dc27d22305fe5569c64cc78b381" + integrity sha512-6QkILNyfeeN67BNEPEtkgh3Xo2tm6D7V+UhrkBbRHqKw9CTaz/vvTP/ROwYSP/3JT2MtIutZm/EnhxUiuOPVDA== "@radix-ui/number@1.0.1": version "1.0.1" @@ -14264,7 +14264,7 @@ minimatch@^9.0.0, minimatch@^9.0.1: dependencies: brace-expansion "^2.0.1" -minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6: +minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.5, minimist@^1.2.6: version "1.2.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== @@ -15881,10 +15881,10 @@ prettier-plugin-organize-attributes@1.0.0: resolved "https://registry.yarnpkg.com/prettier-plugin-organize-attributes/-/prettier-plugin-organize-attributes-1.0.0.tgz#037870ee3111b3c1d6371f677b64888de353cc63" integrity sha512-+NmameaLxbCcylEXsKPmawtzla5EE6ECqvGkpfQz4KM847fXDifB1gFnPQEpoADAq6IXg+cMI8Z0ISJEXa6fhg== -prettier@3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.0.2.tgz#78fcecd6d870551aa5547437cdae39d4701dca5b" - integrity sha512-o2YR9qtniXvwEZlOKbveKfDQVyqxbEIWn48Z8m3ZJjBjcCmUy3xZGIv+7AkaeuaTr6yPXJjwv07ZWlsWbEy1rQ== +prettier@3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.0.3.tgz#432a51f7ba422d1469096c0fdc28e235db8f9643" + integrity sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg== prettier@^2.8.0: version "2.8.8" @@ -15918,12 +15918,12 @@ pretty-hrtime@^1.0.3: resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1" integrity sha512-66hKPCr+72mlfiSjlEB1+45IjXSqvVAIy6mocupoww4tBFE9R9IhwwUGoI4G++Tc9Aq+2rxOt0RFU6gPcrte0A== -prisma@5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/prisma/-/prisma-5.2.0.tgz#a302dc2635cdec1d22d552ece837fb29a03563b9" - integrity sha512-FfFlpjVCkZwrqxDnP4smlNYSH1so+CbfjgdpioFzGGqlQAEm6VHAYSzV7jJgC3ebtY9dNOhDMS2+4/1DDSM7bQ== +prisma@5.3.1: + version "5.3.1" + resolved "https://registry.yarnpkg.com/prisma/-/prisma-5.3.1.tgz#a0932c1c1a5ed4ff449d064b193d9c7e94e8bf77" + integrity sha512-Wp2msQIlMPHe+5k5Od6xnsI/WNG7UJGgFUJgqv/ygc7kOECZapcSz/iU4NIEzISs3H1W9sFLjAPbg/gOqqtB7A== dependencies: - "@prisma/engines" "5.2.0" + "@prisma/engines" "5.3.1" prismjs@^1.28.0: version "1.29.0" @@ -16984,6 +16984,14 @@ shelljs@^0.8.5: interpret "^1.0.0" rechoir "^0.6.2" +shx@0.3.4: + version "0.3.4" + resolved "https://registry.yarnpkg.com/shx/-/shx-0.3.4.tgz#74289230b4b663979167f94e1935901406e40f02" + integrity sha512-N6A9MLVqjxZYcVn8hLmtneQWIJtp8IKzMP4eMnx+nqkvXoqinUPCbUFLp2UcWTEIUONhlk0ewxr/jaVGlc+J+g== + dependencies: + minimist "^1.2.3" + shelljs "^0.8.5" + side-channel@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf" @@ -17889,6 +17897,13 @@ toidentifier@1.0.1: resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== +tough-cookie-file-store@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/tough-cookie-file-store/-/tough-cookie-file-store-2.0.3.tgz#788f7a6fe5cd8f61a1afb71b2f0b964ebf914b80" + integrity sha512-sMpZVcmFf6EYFHFFl+SYH4W1/OnXBYMGDsv2IlbQ2caHyFElW/UR/gpj/KYU1JwmP4dE9xqwv2+vWcmlXHojSw== + dependencies: + tough-cookie "^4.0.0" + tough-cookie@^4.0.0, tough-cookie@^4.1.2: version "4.1.3" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.1.3.tgz#97b9adb0728b42280aa3d814b6b999b2ff0318bf" @@ -19035,16 +19050,17 @@ y18n@^5.0.5: resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== -yahoo-finance2@2.5.0: - version "2.5.0" - resolved "https://registry.yarnpkg.com/yahoo-finance2/-/yahoo-finance2-2.5.0.tgz#847834b33d24dc8ce96357401aba7dae1bcfda9f" - integrity sha512-YTniHzTx17lrs7tUonFZMWvY0dF4UJSrPkrTNMqNrb+la7Nde/5KY7mQFf+8VdXhngPup2V9ex27M2WK3ADtbw== +yahoo-finance2@2.8.0: + version "2.8.0" + resolved "https://registry.yarnpkg.com/yahoo-finance2/-/yahoo-finance2-2.8.0.tgz#c1c1d139d6d16ff3e105af6269e6c869724ac16d" + integrity sha512-8KupoZQEBb+nynDXcOinYdrQ0anPjrX1wQQ8ehVOGZUGMW73fR2YznxumRlVyqSw9J9clS7eS8UhjcOUecmKUA== dependencies: "@types/tough-cookie" "^4.0.2" ajv "8.10.0" ajv-formats "2.1.1" node-fetch "^2.6.1" tough-cookie "^4.1.2" + tough-cookie-file-store "^2.0.3" yallist@^3.0.2: version "3.1.1"