Browse Source

Merge branch '2283-manual-asset-profile' of https://github.com/M27afk/ghostfolio into pr/2479

pull/2479/head
Thomas 2 years ago
parent
commit
52240002b2
  1. 60
      CHANGELOG.md
  2. 4
      apps/api/src/app/access/create-access.dto.ts
  3. 72
      apps/api/src/app/account/account.controller.ts
  4. 6
      apps/api/src/app/account/account.service.ts
  5. 59
      apps/api/src/app/admin/admin.controller.ts
  6. 2
      apps/api/src/app/admin/admin.module.ts
  7. 24
      apps/api/src/app/admin/admin.service.ts
  8. 11
      apps/api/src/app/admin/update-bulk-market-data.dto.ts
  9. 6
      apps/api/src/app/admin/update-market-data.dto.ts
  10. 30
      apps/api/src/app/auth/web-auth.service.ts
  11. 8
      apps/api/src/app/benchmark/benchmark.service.ts
  12. 8
      apps/api/src/app/info/info.service.ts
  13. 2
      apps/api/src/app/portfolio/portfolio.controller.ts
  14. 22
      apps/api/src/app/portfolio/portfolio.service.ts
  15. 7
      apps/api/src/app/user/user.service.ts
  16. 26
      apps/api/src/services/account-balance/account-balance.service.ts
  17. 16
      apps/api/src/services/api/api.service.ts
  18. 10
      apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts
  19. 14
      apps/api/src/services/market-data/market-data.service.ts
  20. 3
      apps/client/project.json
  21. 1
      apps/client/src/app/app.component.html
  22. 12
      apps/client/src/app/components/access-table/access-table.component.html
  23. 1
      apps/client/src/app/components/access-table/access-table.component.ts
  24. 2
      apps/client/src/app/components/accounts-table/accounts-table.component.html
  25. 17
      apps/client/src/app/components/admin-jobs/admin-jobs.component.ts
  26. 262
      apps/client/src/app/components/admin-jobs/admin-jobs.html
  27. 2
      apps/client/src/app/components/admin-jobs/admin-jobs.module.ts
  28. 2
      apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.ts
  29. 2
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  30. 7
      apps/client/src/app/components/admin-market-data/admin-market-data.scss
  31. 34
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  32. 30
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  33. 28
      apps/client/src/app/components/header/header.component.html
  34. 2
      apps/client/src/app/components/header/header.component.scss
  35. 40
      apps/client/src/app/components/header/header.component.ts
  36. 2
      apps/client/src/app/components/header/header.module.ts
  37. 23
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
  38. 19
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html
  39. 12
      apps/client/src/app/components/user-account-access/user-account-access.html
  40. 2
      apps/client/src/app/components/user-account-access/user-account-access.module.ts
  41. 16
      apps/client/src/app/components/user-account-settings/user-account-settings.component.ts
  42. 2
      apps/client/src/app/components/user-account-settings/user-account-settings.html
  43. 19
      apps/client/src/app/pages/accounts/accounts-page.component.ts
  44. 2
      apps/client/src/app/pages/accounts/accounts-page.html
  45. 7
      apps/client/src/app/pages/accounts/accounts-page.scss
  46. 72
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts
  47. 31
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html
  48. 4
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.module.ts
  49. 7
      apps/client/src/app/pages/portfolio/activities/activities-page.scss
  50. 15
      apps/client/src/app/services/admin.service.ts
  51. 18
      apps/client/src/app/services/data.service.ts
  52. 4
      apps/client/src/app/services/import-activities.service.ts
  53. 4
      apps/client/src/app/services/web-authn.service.ts
  54. 1000
      apps/client/src/locales/messages.de.xlf
  55. 1010
      apps/client/src/locales/messages.es.xlf
  56. 1016
      apps/client/src/locales/messages.fr.xlf
  57. 1016
      apps/client/src/locales/messages.it.xlf
  58. 1016
      apps/client/src/locales/messages.nl.xlf
  59. 1016
      apps/client/src/locales/messages.pt.xlf
  60. 1002
      apps/client/src/locales/messages.tr.xlf
  61. 987
      apps/client/src/locales/messages.xlf
  62. 23
      apps/client/src/styles.scss
  63. 4
      docker/docker-compose.dev.yml
  64. 2
      libs/common/src/lib/interfaces/admin-market-data.interface.ts
  65. 1
      libs/common/src/lib/interfaces/benchmark.interface.ts
  66. 1
      libs/common/src/lib/interfaces/enhanced-symbol-profile.interface.ts
  67. 1
      libs/common/src/lib/interfaces/filter.interface.ts
  68. 2
      libs/common/src/lib/interfaces/index.ts
  69. 4
      libs/common/src/lib/interfaces/info-item.interface.ts
  70. 6
      libs/common/src/lib/interfaces/position.interface.ts
  71. 5
      libs/common/src/lib/interfaces/responses/account-balances-response.interface.ts
  72. 5
      libs/common/src/lib/permissions.ts
  73. 78
      libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.component.ts
  74. 16
      libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.html
  75. 13
      libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.module.ts
  76. 19
      libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.scss
  77. 284
      libs/ui/src/lib/assistant/assistant.component.ts
  78. 88
      libs/ui/src/lib/assistant/assistant.html
  79. 25
      libs/ui/src/lib/assistant/assistant.module.ts
  80. 37
      libs/ui/src/lib/assistant/assistant.scss
  81. 1
      libs/ui/src/lib/assistant/index.ts
  82. 12
      libs/ui/src/lib/assistant/interfaces/interfaces.ts
  83. 27
      libs/ui/src/lib/benchmark/benchmark.component.html
  84. 2
      libs/ui/src/lib/benchmark/benchmark.component.ts
  85. 12
      package.json
  86. 11
      test/import/ok.csv
  87. 279
      yarn.lock

60
CHANGELOG.md

@ -9,13 +9,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added the endpoint `GET api/v1/account/:id/balances` which provides historical cash balances
- Added support to search for an asset profile by `isin`, `name` and `symbol` as an administrator (experimental)
- Added support for creating asset profiles with `MANUAL` data source
## Unreleased
### Changed
- Extended the `copy-assets` `Nx` target to copy the locales to the server’s assets
- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `5.2.1` to `8.3`
### Fixed
- Displayed the transfer cash balance button based on a permission
- Fixed the biometric authentication
## 2.11.0 - 2023-10-14
### Added
- Added support to transfer a part of the cash balance from one to another account
- Extended the markets overview by benchmarks (date of last all time high)
- Added support to import historical market data in the admin control panel
### Changed
- Harmonized the style of the create button on the page for granting and revoking public access to share the portfolio
- Improved the language localization for German (`de`)
- Upgraded `prisma` from version `5.3.1` to `5.4.2`
### Fixed
- Fixed `FEE` and `INTEREST` types in the activities import of `csv` files
- Fixed the displayed currency of the cash balance in the create or update account dialog
## 2.10.0 - 2023-10-09
### Added
- Supported enter key press to submit the form of the create or update access dialog
### Changed
- Improved the display of the results in the search for a holding
- Changed the queue jobs view in the admin control panel to an `@angular/material` data table
- Improved the symbol conversion in the _EOD Historical Data_ service
## 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
@ -98,13 +144,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Improved the preselected currency based on the account's currency in the create or edit activity dialog
- Improved the preselected currency based on the accounts 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
- Fixed a memory leak related to the servers timezone (behind UTC) in the data gathering
## 2.3.0 - 2023-09-17
@ -255,7 +301,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Optimized the activities import by allowing a different currency than the asset's official one
- Optimized the activities import by allowing a different currency than the assets official one
- Added a timeout to the _EOD Historical Data_ requests
- Migrated the requests from `bent` to `got` in the _EOD Historical Data_ service
@ -762,7 +808,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Persisted today's market data continuously
- Persisted todays market data continuously
### Fixed
@ -996,7 +1042,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Filtered activities with type `ITEM` from search results
- Considered the user's language in the _Stripe_ checkout
- Considered the users language in the _Stripe_ checkout
- Upgraded the _Stripe_ dependencies
- Upgraded `twitter-api-v2` from version `1.10.3` to `1.14.2`
@ -2670,7 +2716,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Moved the countries and sectors charts in the position detail dialog
- Distinguished today's data point of historical data in the admin control panel
- Distinguished todays data point of historical data in the admin control panel
- Restructured the server modules
### Fixed

4
apps/api/src/app/access/create-access.dto.ts

@ -8,4 +8,8 @@ export class CreateAccessDto {
@IsOptional()
@IsString()
granteeUserId?: string;
@IsOptional()
@IsString()
type?: 'PUBLIC';
}

72
apps/api/src/app/account/account.controller.ts

@ -1,8 +1,12 @@
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { Accounts } from '@ghostfolio/common/interfaces';
import {
AccountBalancesResponse,
Accounts
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type {
AccountWithValue,
@ -29,11 +33,13 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccountService } from './account.service';
import { CreateAccountDto } from './create-account.dto';
import { TransferBalanceDto } from './transfer-balance.dto';
import { UpdateAccountDto } from './update-account.dto';
@Controller('account')
export class AccountController {
public constructor(
private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService,
private readonly impersonationService: ImpersonationService,
private readonly portfolioService: PortfolioService,
@ -115,6 +121,18 @@ export class AccountController {
return accountsWithAggregations.accounts[0];
}
@Get(':id/balances')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(RedactValuesInResponseInterceptor)
public async getAccountBalancesById(
@Param('id') id: string
): Promise<AccountBalancesResponse> {
return this.accountBalanceService.getAccountBalances({
accountId: id,
userId: this.request.user.id
});
}
@Post()
@UseGuards(AuthGuard('jwt'))
public async createAccount(
@ -154,6 +172,58 @@ export class AccountController {
}
}
@Post('transfer-balance')
@UseGuards(AuthGuard('jwt'))
public async transferAccountBalance(
@Body() { accountIdFrom, accountIdTo, balance }: TransferBalanceDto
) {
if (
!hasPermission(this.request.user.permissions, permissions.updateAccount)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const accountsOfUser = await this.accountService.getAccounts(
this.request.user.id
);
const currentAccountIds = accountsOfUser.map(({ id }) => {
return id;
});
if (
![accountIdFrom, accountIdTo].every((accountId) => {
return currentAccountIds.includes(accountId);
})
) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
const { currency } = accountsOfUser.find(({ id }) => {
return id === accountIdFrom;
});
await this.accountService.updateAccountBalance({
currency,
accountId: accountIdFrom,
amount: -balance,
userId: this.request.user.id
});
await this.accountService.updateAccountBalance({
currency,
accountId: accountIdTo,
amount: balance,
userId: this.request.user.id
});
}
@Put(':id')
@UseGuards(AuthGuard('jwt'))
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {

6
apps/api/src/app/account/account.service.ts

@ -109,7 +109,7 @@ export class AccountService {
});
}
public async getAccounts(aUserId: string) {
public async getAccounts(aUserId: string): Promise<Account[]> {
const accounts = await this.accounts({
include: { Order: true, Platform: true },
orderBy: { name: 'asc' },
@ -218,13 +218,13 @@ export class AccountService {
accountId,
amount,
currency,
date,
date = new Date(),
userId
}: {
accountId: string;
amount: number;
currency: string;
date: Date;
date?: Date;
userId: string;
}) {
const { balance, currency: currencyOfAccount } = await this.account({

59
apps/api/src/app/admin/admin.controller.ts

@ -1,9 +1,9 @@
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import {
DEFAULT_PAGE_SIZE,
GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config';
@ -12,8 +12,7 @@ import {
AdminData,
AdminMarketData,
AdminMarketDataDetails,
EnhancedSymbolProfile,
Filter
EnhancedSymbolProfile
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type {
@ -43,12 +42,14 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AdminService } from './admin.service';
import { UpdateAssetProfileDto } from './update-asset-profile.dto';
import { UpdateBulkMarketDataDto } from './update-bulk-market-data.dto';
import { UpdateMarketDataDto } from './update-market-data.dto';
@Controller('admin')
export class AdminController {
public constructor(
private readonly adminService: AdminService,
private readonly apiService: ApiService,
private readonly dataGatheringService: DataGatheringService,
private readonly marketDataService: MarketDataService,
@Inject(REQUEST) private readonly request: RequestWithUser
@ -254,6 +255,7 @@ export class AdminController {
public async getMarketData(
@Query('assetSubClasses') filterByAssetSubClasses?: string,
@Query('presetId') presetId?: MarketDataPreset,
@Query('query') filterBySearchQuery?: string,
@Query('skip') skip?: number,
@Query('sortColumn') sortColumn?: string,
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
@ -271,16 +273,10 @@ export class AdminController {
);
}
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
const filters: Filter[] = [
...assetSubClasses.map((assetSubClass) => {
return <Filter>{
id: assetSubClass,
type: 'ASSET_SUB_CLASS'
};
})
];
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAssetSubClasses,
filterBySearchQuery
});
return this.adminService.getMarketData({
filters,
@ -313,6 +309,43 @@ export class AdminController {
return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
}
@Post('market-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
public async updateMarketData(
@Body() data: UpdateBulkMarketDataDto,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
) {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map(
({ date, marketPrice }) => ({
dataSource,
date,
marketPrice,
symbol,
state: 'CLOSE'
})
);
return this.marketDataService.updateMany({
data: dataBulkUpdate
});
}
/**
* @deprecated
*/
@Put('market-data/:dataSource/:symbol/:dateString')
@UseGuards(AuthGuard('jwt'))
public async update(

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

@ -1,4 +1,5 @@
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
@ -15,6 +16,7 @@ import { QueueModule } from './queue/queue.module';
@Module({
imports: [
ApiModule,
ConfigurationModule,
DataGatheringModule,
DataProviderModule,

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

@ -140,10 +140,14 @@ export class AdminService {
filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }];
}
const searchQuery = filters.find(({ type }) => {
return type === 'SEARCH_QUERY';
})?.id;
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
filters,
(filter) => {
return filter.type;
({ type }) => {
return type;
}
);
@ -156,6 +160,14 @@ export class AdminService {
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
}
if (searchQuery) {
where.OR = [
{ isin: { mode: 'insensitive', startsWith: searchQuery } },
{ name: { mode: 'insensitive', startsWith: searchQuery } },
{ symbol: { mode: 'insensitive', startsWith: searchQuery } }
];
}
if (sortColumn) {
orderBy = [{ [sortColumn]: sortDirection }];
@ -182,7 +194,9 @@ export class AdminService {
assetSubClass: true,
comment: true,
countries: true,
currency: true,
dataSource: true,
name: true,
Order: {
orderBy: [{ date: 'asc' }],
select: { date: true },
@ -203,7 +217,9 @@ export class AdminService {
assetSubClass,
comment,
countries,
currency,
dataSource,
name,
Order,
sectors,
symbol
@ -222,8 +238,10 @@ export class AdminService {
assetClass,
assetSubClass,
comment,
currency,
countriesCount,
dataSource,
name,
symbol,
marketDataItemCount,
sectorsCount,
@ -350,6 +368,8 @@ export class AdminService {
symbol,
assetClass: 'CASH',
countriesCount: 0,
currency: symbol.replace(DEFAULT_CURRENCY, ''),
name: symbol,
sectorsCount: 0
};
});

11
apps/api/src/app/admin/update-bulk-market-data.dto.ts

@ -0,0 +1,11 @@
import { Type } from 'class-transformer';
import { ArrayNotEmpty, IsArray, isNotEmptyObject } from 'class-validator';
import { UpdateMarketDataDto } from './update-market-data.dto';
export class UpdateBulkMarketDataDto {
@ArrayNotEmpty()
@IsArray()
@Type(() => UpdateMarketDataDto)
marketData: UpdateMarketDataDto[];
}

6
apps/api/src/app/admin/update-market-data.dto.ts

@ -1,6 +1,10 @@
import { IsNumber } from 'class-validator';
import { IsDate, IsNumber, IsOptional } from 'class-validator';
export class UpdateMarketDataDto {
@IsDate()
@IsOptional()
date?: Date;
@IsNumber()
marketPrice: number;
}

30
apps/api/src/app/auth/web-auth.service.ts

@ -64,7 +64,7 @@ export class WebAuthService {
}
};
const options = generateRegistrationOptions(opts);
const options = await generateRegistrationOptions(opts);
await this.userService.updateUser({
data: {
@ -88,10 +88,16 @@ export class WebAuthService {
let verification: VerifiedRegistrationResponse;
try {
const opts: VerifyRegistrationResponseOpts = {
credential,
expectedChallenge,
expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID
expectedRPID: this.rpID,
response: {
clientExtensionResults: credential.clientExtensionResults,
id: credential.id,
rawId: credential.rawId,
response: credential.response,
type: 'public-key'
}
};
verification = await verifyRegistrationResponse(opts);
} catch (error) {
@ -117,8 +123,8 @@ export class WebAuthService {
*/
existingDevice = await this.deviceService.createAuthDevice({
counter,
credentialPublicKey,
credentialId: credentialID,
credentialId: Buffer.from(credentialID),
credentialPublicKey: Buffer.from(credentialPublicKey),
User: { connect: { id: user.id } }
});
}
@ -152,7 +158,7 @@ export class WebAuthService {
userVerification: 'preferred'
};
const options = generateAuthenticationOptions(opts);
const options = await generateAuthenticationOptions(opts);
await this.userService.updateUser({
data: {
@ -181,7 +187,6 @@ export class WebAuthService {
let verification: VerifiedAuthenticationResponse;
try {
const opts: VerifyAuthenticationResponseOpts = {
credential,
authenticator: {
credentialID: device.credentialId,
credentialPublicKey: device.credentialPublicKey,
@ -189,9 +194,16 @@ export class WebAuthService {
},
expectedChallenge: `${user.authChallenge}`,
expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID
expectedRPID: this.rpID,
response: {
clientExtensionResults: credential.clientExtensionResults,
id: credential.id,
rawId: credential.rawId,
response: credential.response,
type: 'public-key'
}
};
verification = verifyAuthenticationResponse(opts);
verification = await verifyAuthenticationResponse(opts);
} catch (error) {
Logger.error(error, 'WebAuthService');
throw new InternalServerErrorException({ error: error.message });

8
apps/api/src/app/benchmark/benchmark.service.ts

@ -64,7 +64,7 @@ export class BenchmarkService {
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles();
const promises: Promise<number>[] = [];
const promises: Promise<{ date: Date; marketPrice: number }>[] = [];
const quotes = await this.dataProviderService.getQuotes({
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
@ -85,15 +85,14 @@ export class BenchmarkService {
let performancePercentFromAllTimeHigh = 0;
if (allTimeHigh && marketPrice) {
if (allTimeHigh?.marketPrice && marketPrice) {
performancePercentFromAllTimeHigh = this.calculateChangeInPercentage(
allTimeHigh,
allTimeHigh.marketPrice,
marketPrice
);
} else {
storeInCache = false;
}
return {
marketCondition: this.getMarketCondition(
performancePercentFromAllTimeHigh
@ -101,6 +100,7 @@ export class BenchmarkService {
name: benchmarkAssetProfiles[index].name,
performances: {
allTimeHigh: {
date: allTimeHigh.date,
performancePercent: performancePercentFromAllTimeHigh
}
}

8
apps/api/src/app/info/info.service.ts

@ -55,12 +55,8 @@ export class InfoService {
public async get(): Promise<InfoItem> {
const info: Partial<InfoItem> = {};
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;

2
apps/api/src/app/portfolio/portfolio.controller.ts

@ -391,12 +391,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<PortfolioPositions> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterBySearchQuery,
filterByTags
});

22
apps/api/src/app/portfolio/portfolio.service.ts

@ -1014,6 +1014,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 } =
@ -1042,9 +1045,9 @@ export class PortfolioService {
const currentPositions =
await portfolioCalculator.getCurrentPositions(startDate);
const positions = currentPositions.positions.filter(
(item) => !item.quantity.eq(0)
);
let positions = currentPositions.positions.filter(({ quantity }) => {
return !quantity.eq(0);
});
const dataGatheringItems = positions.map(({ dataSource, symbol }) => {
return {
@ -1067,12 +1070,25 @@ 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) => {
return {
...position,
assetClass: symbolProfileMap[position.symbol].assetClass,
assetSubClass: symbolProfileMap[position.symbol].assetSubClass,
averagePrice: new Big(position.averagePrice).toNumber(),
grossPerformance: position.grossPerformance?.toNumber() ?? null,
grossPerformancePercentage:

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

@ -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);

26
apps/api/src/services/account-balance/account-balance.service.ts

@ -1,4 +1,5 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { AccountBalancesResponse } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { AccountBalance, Prisma } from '@prisma/client';
@ -13,4 +14,29 @@ export class AccountBalanceService {
data
});
}
public async getAccountBalances({
accountId,
userId
}: {
accountId: string;
userId: string;
}): Promise<AccountBalancesResponse> {
const balances = await this.prismaService.accountBalance.findMany({
orderBy: {
date: 'asc'
},
select: {
date: true,
id: true,
value: true
},
where: {
accountId,
userId
}
});
return { balances };
}
}

16
apps/api/src/services/api/api.service.ts

@ -8,14 +8,20 @@ export class ApiService {
public buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByAssetSubClasses,
filterBySearchQuery,
filterByTags
}: {
filterByAccounts?: string;
filterByAssetClasses?: string;
filterByAssetSubClasses?: string;
filterBySearchQuery?: string;
filterByTags?: string;
}): Filter[] {
const accountIds = filterByAccounts?.split(',') ?? [];
const assetClasses = filterByAssetClasses?.split(',') ?? [];
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
const searchQuery = filterBySearchQuery?.toLowerCase();
const tagIds = filterByTags?.split(',') ?? [];
return [
@ -31,6 +37,16 @@ export class ApiService {
type: 'ASSET_CLASS'
};
}),
...assetSubClasses.map((assetClass) => {
return <Filter>{
id: assetClass,
type: 'ASSET_SUB_CLASS'
};
}),
{
id: searchQuery,
type: 'SEARCH_QUERY'
},
...tagIds.map((tagId) => {
return <Filter>{
id: tagId,

10
apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts

@ -283,7 +283,6 @@ export class EodHistoricalDataService implements DataProviderInterface {
if (symbol.endsWith('.FOREX')) {
symbol = symbol.replace('GBX', 'GBp');
symbol = symbol.replace('.FOREX', '');
symbol = `${DEFAULT_CURRENCY}${symbol}`;
}
return symbol;
@ -292,7 +291,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
/**
* Converts a symbol to a EOD symbol
*
* Currency: USDCHF -> CHF.FOREX
* Currency: USDCHF -> USDCHF.FOREX
*/
private convertToEodSymbol(aSymbol: string) {
if (
@ -304,9 +303,10 @@ export class EodHistoricalDataService implements DataProviderInterface {
aSymbol.substring(0, aSymbol.length - DEFAULT_CURRENCY.length)
)
) {
return `${aSymbol
.replace('GBp', 'GBX')
.replace(DEFAULT_CURRENCY, '')}.FOREX`;
let symbol = aSymbol;
symbol = symbol.replace('GBp', 'GBX');
return `${symbol}.FOREX`;
}
}

14
apps/api/src/services/market-data/market-data.service.ts

@ -39,18 +39,22 @@ export class MarketDataService {
});
}
public async getMax({ dataSource, symbol }: UniqueAsset): Promise<number> {
const aggregations = await this.prismaService.marketData.aggregate({
_max: {
public async getMax({ dataSource, symbol }: UniqueAsset) {
return this.prismaService.marketData.findFirst({
select: {
date: true,
marketPrice: true
},
orderBy: [
{
marketPrice: 'desc'
}
],
where: {
dataSource,
symbol
}
});
return aggregations._max.marketPrice;
}
public async getRange({

3
apps/client/project.json

@ -124,6 +124,9 @@
{
"command": "shx cp apps/client/src/assets/site.webmanifest dist/apps/client"
},
{
"command": "shx cp -r apps/client/src/locales dist/apps/api/assets"
},
{
"command": "shx cp node_modules/ionicons/dist/index.js dist/apps/client"
},

1
apps/client/src/app/app.component.html

@ -32,6 +32,7 @@
<gf-header
class="position-fixed w-100"
[currentRoute]="currentRoute"
[deviceType]="deviceType"
[hasTabs]="hasTabs"
[info]="info"
[pageTitle]="pageTitle"

12
apps/client/src/app/components/access-table/access-table.component.html

@ -1,15 +1,3 @@
<div *ngIf="hasPermissionToCreateAccess" class="d-flex justify-content-end">
<a
color="primary"
i18n
mat-flat-button
[queryParams]="{ createDialog: true }"
[routerLink]="[]"
>
Add Access
</a>
</div>
<table class="gf-table w-100" mat-table [dataSource]="dataSource">
<ng-container matColumnDef="alias">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Alias</th>

1
apps/client/src/app/components/access-table/access-table.component.ts

@ -19,7 +19,6 @@ 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<string>();

2
apps/client/src/app/components/accounts-table/accounts-table.component.html

@ -1,4 +1,4 @@
<div *ngIf="false" class="d-flex justify-content-end">
<div *ngIf="showActions" class="d-flex justify-content-end">
<button
class="align-items-center d-flex"
mat-stroked-button

17
apps/client/src/app/components/admin-jobs/admin-jobs.component.ts

@ -6,6 +6,7 @@ import {
OnInit
} from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import { MatTableDataSource } from '@angular/material/table';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { QUEUE_JOB_STATUS_LIST } from '@ghostfolio/common/config';
@ -24,7 +25,19 @@ import { takeUntil } from 'rxjs/operators';
export class AdminJobsComponent implements OnDestroy, OnInit {
public defaultDateTimeFormat: string;
public filterForm: FormGroup;
public jobs: AdminJobs['jobs'] = [];
public dataSource: MatTableDataSource<AdminJobs['jobs'][0]> =
new MatTableDataSource();
public displayedColumns = [
'index',
'type',
'symbol',
'dataSource',
'attempts',
'created',
'finished',
'status',
'actions'
];
public statusFilterOptions = QUEUE_JOB_STATUS_LIST;
public user: User;
@ -102,7 +115,7 @@ export class AdminJobsComponent implements OnDestroy, OnInit {
.fetchJobs({ status: aStatus })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ jobs }) => {
this.jobs = jobs;
this.dataSource = new MatTableDataSource(jobs);
this.changeDetectorRef.markForCheck();
});

262
apps/client/src/app/components/admin-jobs/admin-jobs.html

@ -13,122 +13,158 @@
</mat-select>
</mat-form-field>
</form>
<table class="gf-table w-100">
<thead>
<tr class="mat-header-row">
<th class="mat-header-cell px-1 py-2 text-right">#</th>
<th class="mat-header-cell px-1 py-2" i18n>Type</th>
<th class="mat-header-cell px-1 py-2" i18n>Symbol</th>
<th class="mat-header-cell px-1 py-2" i18n>Data Source</th>
<th class="mat-header-cell px-1 py-2 text-right" i18n>Attempts</th>
<th class="mat-header-cell px-1 py-2" i18n>Created</th>
<th class="mat-header-cell px-1 py-2" i18n>Finished</th>
<th class="mat-header-cell px-1 py-2" i18n>Status</th>
<th class="mat-header-cell px-1 py-2">
<table class="gf-table w-100" mat-table [dataSource]="dataSource">
<ng-container matColumnDef="index">
<th *matHeaderCellDef class="px-1 py-2 text-right" mat-header-cell>
#
</th>
<td *matCellDef="let element" class="px-1 py-2 text-right" mat-cell>
{{ element.id }}
</td>
</ng-container>
<ng-container matColumnDef="type">
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
<ng-container i18n>Type</ng-container>
</th>
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
<ng-container *ngIf="element.name === 'GATHER_ASSET_PROFILE'" i18n>
Asset Profile
</ng-container>
<ng-container
*ngIf="element.name === 'GATHER_HISTORICAL_MARKET_DATA'"
i18n
>
Historical Market Data
</ng-container>
</td>
</ng-container>
<ng-container matColumnDef="symbol">
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
<ng-container i18n>Symbol</ng-container>
</th>
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
{{ element.data?.symbol }}
</td>
</ng-container>
<ng-container matColumnDef="dataSource">
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
<ng-container i18n>Data Source</ng-container>
</th>
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
{{ element.data?.dataSource }}
</td>
</ng-container>
<ng-container matColumnDef="attempts">
<th *matHeaderCellDef class="px-1 py-2 text-right" mat-header-cell>
<ng-container i18n>Attempts</ng-container>
</th>
<td *matCellDef="let element" class="px-1 py-2 text-right" mat-cell>
{{ element.attemptsMade }}
</td>
</ng-container>
<ng-container matColumnDef="created">
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
<ng-container i18n>Created</ng-container>
</th>
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
{{ element.timestamp | date: defaultDateTimeFormat }}
</td>
</ng-container>
<ng-container matColumnDef="finished">
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
<ng-container i18n>Finished</ng-container>
</th>
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
{{ element.finishedOn | date: defaultDateTimeFormat }}
</td>
</ng-container>
<ng-container matColumnDef="status">
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
<ng-container i18n>Status</ng-container>
</th>
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
<ion-icon
*ngIf="element.state === 'active'"
name="play-outline"
></ion-icon>
<ion-icon
*ngIf="element.state === 'completed'"
class="text-success"
name="checkmark-circle-outline"
></ion-icon>
<ion-icon
*ngIf="element.state === 'delayed'"
name="time-outline"
[ngClass]="{ 'text-danger': element.stacktrace?.length > 0 }"
></ion-icon>
<ion-icon
*ngIf="element.state === 'failed'"
class="text-danger"
name="alert-circle-outline"
></ion-icon>
<ion-icon
*ngIf="element.state === 'paused'"
name="pause-outline"
></ion-icon>
<ion-icon
*ngIf="element.state === 'waiting'"
name="cafe-outline"
></ion-icon>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th *matHeaderCellDef class="px-1 py-2" mat-header-cell>
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="jobsActionsMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-vertical"></ion-icon>
</button>
<mat-menu #jobsActionsMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onDeleteJobs()">
<ng-container i18n>Delete Jobs</ng-container>
</button>
</mat-menu>
</th>
<td *matCellDef="let element" class="px-1 py-2" mat-cell>
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="jobActionsMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-horizontal"></ion-icon>
</button>
<mat-menu #jobActionsMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onViewData(element.data)">
<ng-container i18n>View Data</ng-container>
</button>
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="jobsActionsMenu"
(click)="$event.stopPropagation()"
mat-menu-item
[disabled]="element.stacktrace?.length <= 0"
(click)="onViewStacktrace(element.stacktrace)"
>
<ion-icon name="ellipsis-vertical"></ion-icon>
<ng-container i18n>View Stacktrace</ng-container>
</button>
<button mat-menu-item (click)="onDeleteJob(element.id)">
<ng-container i18n>Delete Job</ng-container>
</button>
<mat-menu #jobsActionsMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onDeleteJobs()">
<ng-container i18n>Delete Jobs</ng-container>
</button>
</mat-menu>
</th>
</tr>
</thead>
<tbody>
<ng-container *ngFor="let job of jobs">
<tr class="mat-row">
<td class="mat-cell px-1 py-2 text-right">{{ job.id }}</td>
<td class="mat-cell px-1 py-2">
<span class="align-items-center d-flex">
<ion-icon
class="mr-1"
name="arrow-down-circle-outline"
></ion-icon>
<ng-container *ngIf="job.name === 'GATHER_ASSET_PROFILE'">
<span i18n>Asset Profile</span>
</ng-container>
<ng-container
*ngIf="job.name === 'GATHER_HISTORICAL_MARKET_DATA'"
>
<span i18n>Historical Market Data</span>
</ng-container>
</span>
</td>
<td class="mat-cell px-1 py-2">{{ job.data?.symbol }}</td>
<td class="mat-cell px-1 py-2">{{ job.data?.dataSource }}</td>
<td class="mat-cell px-1 py-2 text-right">
{{ job.attemptsMade }}
</td>
<td class="mat-cell px-1 py-2">
{{ job.timestamp | date: defaultDateTimeFormat }}
</td>
<td class="mat-cell px-1 py-2">
{{ job.finishedOn | date: defaultDateTimeFormat }}
</td>
<td class="mat-cell px-1 py-2">
<ion-icon
*ngIf="job.state === 'active'"
name="play-outline"
></ion-icon>
<ion-icon
*ngIf="job.state === 'completed'"
class="text-success"
name="checkmark-circle-outline"
></ion-icon>
<ion-icon
*ngIf="job.state === 'delayed'"
name="time-outline"
[ngClass]="{ 'text-danger': job.stacktrace?.length > 0 }"
></ion-icon>
<ion-icon
*ngIf="job.state === 'failed'"
class="text-danger"
name="alert-circle-outline"
></ion-icon>
<ion-icon
*ngIf="job.state === 'paused'"
name="pause-outline"
></ion-icon>
<ion-icon
*ngIf="job.state === 'waiting'"
name="cafe-outline"
></ion-icon>
</td>
<td class="mat-cell px-1 py-2">
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="jobActionsMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-horizontal"></ion-icon>
</button>
<mat-menu #jobActionsMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onViewData(job.data)">
<ng-container i18n>View Data</ng-container>
</button>
<button
mat-menu-item
[disabled]="job.stacktrace?.length <= 0"
(click)="onViewStacktrace(job.stacktrace)"
>
<ng-container i18n>View Stacktrace</ng-container>
</button>
<button mat-menu-item (click)="onDeleteJob(job.id)">
<ng-container i18n>Delete Job</ng-container>
</button>
</mat-menu>
</td>
</tr>
</ng-container>
</tbody>
</mat-menu>
</td>
</ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table>
</div>
</div>

2
apps/client/src/app/components/admin-jobs/admin-jobs.module.ts

@ -4,6 +4,7 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { MatSelectModule } from '@angular/material/select';
import { MatTableModule } from '@angular/material/table';
import { AdminJobsComponent } from './admin-jobs.component';
@ -15,6 +16,7 @@ import { AdminJobsComponent } from './admin-jobs.component';
MatButtonModule,
MatMenuModule,
MatSelectModule,
MatTableModule,
ReactiveFormsModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]

2
apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.ts

@ -177,7 +177,7 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ withRefresh }) => {
.subscribe(({ withRefresh } = { withRefresh: false }) => {
this.marketDataChanged.next(withRefresh);
});
}

2
apps/client/src/app/components/admin-market-data/admin-market-data.component.ts

@ -342,7 +342,7 @@ export class AdminMarketDataComponent
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ dataSource, symbol }) => {
.subscribe(({ dataSource, symbol } = {}) => {
if (dataSource && symbol) {
this.adminService
.addAssetProfile({ dataSource, symbol })

7
apps/client/src/app/components/admin-market-data/admin-market-data.scss

@ -2,11 +2,4 @@
:host {
display: block;
.fab-container {
bottom: 2rem;
position: fixed;
right: 2rem;
z-index: 999;
}
}

34
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts

@ -11,12 +11,15 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import {
AdminMarketDataDetails,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { translate } from '@ghostfolio/ui/i18n';
import { MarketData, SymbolProfile } from '@prisma/client';
import { format, parseISO } from 'date-fns';
import { parse as csvToJson } from 'papaparse';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -42,12 +45,17 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
public countries: {
[code: string]: { name: string; value: number };
};
public historicalDataAsCsvString: string;
public isBenchmark = false;
public marketDataDetails: MarketData[] = [];
public sectors: {
[name: string]: { name: string; value: number };
};
private static readonly HISTORICAL_DATA_TEMPLATE = `date;marketPrice\n${format(
new Date(),
DATE_FORMAT
)};123.45`;
private unsubscribeSubject = new Subject<void>();
public constructor(
@ -66,6 +74,9 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
}
public initialize() {
this.historicalDataAsCsvString =
AssetProfileDialog.HISTORICAL_DATA_TEMPLATE;
this.adminService
.fetchAdminMarketDataBySymbol({
dataSource: this.data.dataSource,
@ -134,6 +145,29 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
.subscribe(() => {});
}
public onImportHistoricalData() {
const marketData = csvToJson(this.historicalDataAsCsvString, {
dynamicTyping: true,
header: true,
skipEmptyLines: true
}).data;
this.adminService
.postMarketData({
dataSource: this.data.dataSource,
marketData: {
marketData: marketData.map(({ date, marketPrice }) => {
return { marketPrice, date: parseISO(date) };
})
},
symbol: this.data.symbol
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.initialize();
});
}
public onMarketDataChanged(withRefresh: boolean = false) {
if (withRefresh) {
this.initialize();

30
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html

@ -51,6 +51,36 @@
[symbol]="data.symbol"
(marketDataChanged)="onMarketDataChanged($event)"
></gf-admin-market-data-detail>
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label>
<ng-container i18n>Historical Data</ng-container> (CSV)
</mat-label>
<textarea
cdkAutosizeMaxRows="5"
cdkTextareaAutosize
matInput
placeholder="e.g. 20230601;1.61"
type="text"
[ngModelOptions]="{standalone: true}"
[(ngModel)]="historicalDataAsCsvString"
(keyup.enter)="$event.stopPropagation()"
></textarea>
</mat-form-field>
</div>
<div class="d-flex justify-content-end mt-2">
<button
color="accent"
mat-flat-button
type="button"
(click)="onImportHistoricalData()"
>
<ng-container i18n>Import</ng-container>
</button>
</div>
<div class="row">
<div class="col-6 mb-3">
<gf-value i18n size="medium" [value]="assetProfile?.symbol"

28
apps/client/src/app/components/header/header.component.html

@ -110,6 +110,34 @@
>About</a
>
</li>
<li *ngIf="hasPermissionToAccessAssistant" class="list-inline-item">
<button
#assistantTrigger="matMenuTrigger"
class="h-100 no-min-width px-2"
mat-button
[mat-menu-trigger-for]="assistantMenu"
[matMenuTriggerRestoreFocus]="false"
(menuOpened)="onOpenAssistant()"
>
<ion-icon name="search-outline"></ion-icon>
</button>
<mat-menu
#assistantMenu="matMenu"
class="assistant"
xPosition="before"
[overlapTrigger]="true"
(closed)="assistantElement?.setIsOpen(false)"
>
<gf-assistant
#assistant
[deviceType]="deviceType"
[hasPermissionToAccessAdminControl]="
hasPermissionToAccessAdminControl
"
(closed)="closeAssistant()"
/>
</mat-menu>
</li>
<li class="list-inline-item">
<button
class="no-min-width px-1"

2
apps/client/src/app/components/header/header.component.scss

@ -22,7 +22,7 @@
}
.mdc-button {
height: unset;
height: 100%;
&:not(.mat-primary) {
background-color: transparent;

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

@ -2,11 +2,14 @@ import {
ChangeDetectionStrategy,
Component,
EventEmitter,
HostListener,
Input,
OnChanges,
Output
Output,
ViewChild
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatMenuTrigger } from '@angular/material/menu';
import { Router } from '@angular/router';
import { LoginWithAccessTokenDialog } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.component';
import { DataService } from '@ghostfolio/client/services/data.service';
@ -18,6 +21,7 @@ import {
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { AssistantComponent } from '@ghostfolio/ui/assistant/assistant.component';
import { EMPTY, Subject } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators';
@ -28,7 +32,24 @@ import { catchError, takeUntil } from 'rxjs/operators';
styleUrls: ['./header.component.scss']
})
export class HeaderComponent implements OnChanges {
@HostListener('window:keydown', ['$event'])
openAssistantWithHotKey(event: KeyboardEvent) {
if (
event.key === '/' &&
event.target instanceof Element &&
event.target?.nodeName?.toLowerCase() !== 'input' &&
event.target?.nodeName?.toLowerCase() !== 'textarea' &&
this.hasPermissionToAccessAssistant
) {
this.assistantElement.setIsOpen(true);
this.assistentMenuTriggerElement.openMenu();
event.preventDefault();
}
}
@Input() currentRoute: string;
@Input() deviceType: string;
@Input() hasTabs: boolean;
@Input() info: InfoItem;
@Input() pageTitle: string;
@ -36,9 +57,13 @@ export class HeaderComponent implements OnChanges {
@Output() signOut = new EventEmitter<void>();
@ViewChild('assistant') assistantElement: AssistantComponent;
@ViewChild('assistantTrigger') assistentMenuTriggerElement: MatMenuTrigger;
public hasPermissionForSocialLogin: boolean;
public hasPermissionForSubscription: boolean;
public hasPermissionToAccessAdminControl: boolean;
public hasPermissionToAccessAssistant: boolean;
public hasPermissionToAccessFearAndGreedIndex: boolean;
public hasPermissionToCreateUser: boolean;
public impersonationId: string;
@ -89,6 +114,11 @@ export class HeaderComponent implements OnChanges {
permissions.accessAdminControl
);
this.hasPermissionToAccessAssistant = hasPermission(
this.user?.permissions,
permissions.accessAssistant
);
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
this.info?.globalPermissions,
permissions.enableFearAndGreedIndex
@ -100,6 +130,10 @@ export class HeaderComponent implements OnChanges {
);
}
public closeAssistant() {
this.assistentMenuTriggerElement?.closeMenu();
}
public impersonateAccount(aId: string) {
if (aId) {
this.impersonationStorageService.setId(aId);
@ -118,6 +152,10 @@ export class HeaderComponent implements OnChanges {
this.isMenuOpen = true;
}
public onOpenAssistant() {
this.assistantElement.initialize();
}
public onSignOut() {
this.signOut.next();
}

2
apps/client/src/app/components/header/header.module.ts

@ -5,6 +5,7 @@ import { MatMenuModule } from '@angular/material/menu';
import { MatToolbarModule } from '@angular/material/toolbar';
import { RouterModule } from '@angular/router';
import { LoginWithAccessTokenDialogModule } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.module';
import { GfAssistantModule } from '@ghostfolio/ui/assistant';
import { GfLogoModule } from '@ghostfolio/ui/logo';
import { HeaderComponent } from './header.component';
@ -14,6 +15,7 @@ import { HeaderComponent } from './header.component';
exports: [HeaderComponent],
imports: [
CommonModule,
GfAssistantModule,
GfLogoModule,
LoginWithAccessTokenDialogModule,
MatButtonModule,

23
apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts

@ -4,7 +4,9 @@ import {
Inject,
OnDestroy
} from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
import { Subject } from 'rxjs';
import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
@ -17,19 +19,36 @@ import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
templateUrl: 'create-or-update-access-dialog.html'
})
export class CreateOrUpdateAccessDialog implements OnDestroy {
public accessForm: FormGroup;
private unsubscribeSubject = new Subject<void>();
public constructor(
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateAccessDialogParams,
public dialogRef: MatDialogRef<CreateOrUpdateAccessDialog>,
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateAccessDialogParams
private formBuilder: FormBuilder
) {}
ngOnInit() {}
ngOnInit() {
this.accessForm = this.formBuilder.group({
alias: [this.data.access.alias],
type: [this.data.access.type, Validators.required]
});
}
public onCancel() {
this.dialogRef.close();
}
public onSubmit() {
const access: CreateAccessDto = {
alias: this.accessForm.controls['alias'].value,
type: this.accessForm.controls['type'].value
};
this.dialogRef.close({ access });
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();

19
apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html

@ -1,33 +1,38 @@
<form #addAccessForm="ngForm" class="d-flex flex-column h-100">
<form
class="d-flex flex-column h-100"
[formGroup]="accessForm"
(keyup.enter)="accessForm.valid && onSubmit()"
(ngSubmit)="onSubmit()"
>
<h1 i18n mat-dialog-title>Grant access</h1>
<div class="flex-grow-1 py-3" mat-dialog-content>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Alias</mat-label>
<input
formControlName="alias"
matInput
name="alias"
type="text"
[(ngModel)]="data.access.alias"
(keydown.enter)="$event.stopPropagation()"
/>
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Type</mat-label>
<mat-select name="type" required [(value)]="data.access.type">
<mat-select formControlName="type">
<mat-option i18n value="PUBLIC">Public</mat-option>
</mat-select>
</mat-form-field>
</div>
</div>
<div class="justify-content-end" mat-dialog-actions>
<button i18n mat-button (click)="onCancel()">Cancel</button>
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button>
<button
color="primary"
mat-flat-button
[disabled]="!addAccessForm.form.valid"
[mat-dialog-close]="data"
type="submit"
[disabled]="!accessForm.valid"
>
<ng-container i18n>Save</ng-container>
</button>

12
apps/client/src/app/components/user-account-access/user-account-access.html

@ -10,8 +10,18 @@
</h1>
<gf-access-table
[accesses]="accesses"
[hasPermissionToCreateAccess]="hasPermissionToCreateAccess"
[showActions]="hasPermissionToDeleteAccess"
(accessDeleted)="onDeleteAccess($event)"
></gf-access-table>
<div *ngIf="hasPermissionToCreateAccess" class="fab-container">
<a
class="align-items-center d-flex justify-content-center"
color="primary"
mat-fab
[queryParams]="{ createDialog: true }"
[routerLink]="[]"
>
<ion-icon name="add-outline" size="large"></ion-icon>
</a>
</div>
</div>

2
apps/client/src/app/components/user-account-access/user-account-access.module.ts

@ -7,6 +7,7 @@ import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
import { GfCreateOrUpdateAccessDialogModule } from './create-or-update-access-dialog/create-or-update-access-dialog.module';
import { UserAccountAccessComponent } from './user-account-access.component';
import { MatButtonModule } from '@angular/material/button';
@NgModule({
declarations: [UserAccountAccessComponent],
@ -16,6 +17,7 @@ import { UserAccountAccessComponent } from './user-account-access.component';
GfCreateOrUpdateAccessDialogModule,
GfPortfolioAccessTableModule,
GfPremiumIndicatorModule,
MatButtonModule,
MatDialogModule,
RouterModule
]

16
apps/client/src/app/components/user-account-settings/user-account-settings.component.ts

@ -3,10 +3,9 @@ import {
ChangeDetectorRef,
Component,
OnDestroy,
OnInit,
ViewChild
OnInit
} from '@angular/core';
import { MatCheckbox, MatCheckboxChange } from '@angular/material/checkbox';
import { MatCheckboxChange } from '@angular/material/checkbox';
import { DataService } from '@ghostfolio/client/services/data.service';
import {
STAY_SIGNED_IN,
@ -29,14 +28,12 @@ import { catchError, takeUntil } from 'rxjs/operators';
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 isWebAuthnEnabled: boolean;
public language = document.documentElement.lang;
public locales = [
'de',
@ -250,9 +247,8 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit {
}
private update() {
if (this.signInWithFingerprintElement) {
this.signInWithFingerprintElement.checked =
this.webAuthnService.isEnabled() ?? false;
}
this.isWebAuthnEnabled = this.webAuthnService.isEnabled() ?? false;
this.changeDetectorRef.markForCheck();
}
}

2
apps/client/src/app/components/user-account-settings/user-account-settings.html

@ -154,8 +154,8 @@
</div>
<div class="pl-1 w-50">
<mat-checkbox
#toggleSignInWithFingerprintEnabledElement
color="primary"
[checked]="isWebAuthnEnabled === true"
[disabled]="!hasPermissionToUpdateUserSettings"
(change)="onSignInWithFingerprintChange($event)"
></mat-checkbox>

19
apps/client/src/app/pages/accounts/accounts-page.component.ts

@ -30,7 +30,7 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
public deviceType: string;
public hasImpersonationId: boolean;
public hasPermissionToCreateAccount: boolean;
public hasPermissionToDeleteAccount: boolean;
public hasPermissionToUpdateAccount: boolean;
public routeQueryParams: Subscription;
public totalBalanceInBaseCurrency = 0;
public totalValueInBaseCurrency = 0;
@ -95,9 +95,9 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
this.user.permissions,
permissions.createAccount
);
this.hasPermissionToDeleteAccount = hasPermission(
this.hasPermissionToUpdateAccount = hasPermission(
this.user.permissions,
permissions.deleteAccount
permissions.updateAccount
);
this.changeDetectorRef.markForCheck();
@ -295,9 +295,16 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
const { accountIdFrom, accountIdTo, balance }: TransferBalanceDto =
data?.account;
console.log(
`Transfer cash balance of ${balance} from account ${accountIdFrom} to account ${accountIdTo}`
);
this.dataService
.transferAccountBalance({
accountIdFrom,
accountIdTo,
balance
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.fetchAccounts();
});
}
this.router.navigate(['.'], { relativeTo: this.route });

2
apps/client/src/app/pages/accounts/accounts-page.html

@ -8,7 +8,7 @@
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[locale]="user?.settings?.locale"
[showActions]="!hasImpersonationId && hasPermissionToDeleteAccount && !user.settings.isRestrictedView"
[showActions]="!hasImpersonationId && hasPermissionToUpdateAccount && !user.settings.isRestrictedView"
[totalBalanceInBaseCurrency]="totalBalanceInBaseCurrency"
[totalValueInBaseCurrency]="totalValueInBaseCurrency"
[transactionCount]="transactionCount"

7
apps/client/src/app/pages/accounts/accounts-page.scss

@ -4,11 +4,4 @@
.accounts {
overflow-x: auto;
}
.fab-container {
position: fixed;
right: 2rem;
bottom: 2rem;
z-index: 999;
}
}

72
apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts

@ -4,12 +4,20 @@ import {
Inject,
OnDestroy
} from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import {
AbstractControl,
FormBuilder,
FormGroup,
ValidatorFn,
Validators
} from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
import { DataService } from '@ghostfolio/client/services/data.service';
import { Subject } from 'rxjs';
import { Platform } from '@prisma/client';
import { Observable, Subject } from 'rxjs';
import { map, startWith } from 'rxjs/operators';
import { CreateOrUpdateAccountDialogParams } from './interfaces/interfaces';
@ -23,7 +31,8 @@ import { CreateOrUpdateAccountDialogParams } from './interfaces/interfaces';
export class CreateOrUpdateAccountDialog implements OnDestroy {
public accountForm: FormGroup;
public currencies: string[] = [];
public platforms: { id: string; name: string }[];
public filteredPlatforms: Observable<Platform[]>;
public platforms: Platform[];
private unsubscribeSubject = new Subject<void>();
@ -34,7 +43,7 @@ export class CreateOrUpdateAccountDialog implements OnDestroy {
private formBuilder: FormBuilder
) {}
ngOnInit() {
public ngOnInit() {
const { currencies, platforms } = this.dataService.fetchInfo();
this.currencies = currencies;
@ -47,8 +56,41 @@ export class CreateOrUpdateAccountDialog implements OnDestroy {
currency: [this.data.account.currency, Validators.required],
isExcluded: [this.data.account.isExcluded],
name: [this.data.account.name, Validators.required],
platformId: [this.data.account.platformId]
platformId: [
this.platforms.find(({ id }) => {
return id === this.data.account.platformId;
}),
this.autocompleteObjectValidator()
]
});
this.filteredPlatforms = this.accountForm
.get('platformId')
.valueChanges.pipe(
startWith(''),
map((value) => {
const name = typeof value === 'string' ? value : value?.name;
return name ? this.filter(name as string) : this.platforms.slice();
})
);
}
public autoCompleteCheck() {
const inputValue = this.accountForm.controls['platformId'].value;
if (typeof inputValue === 'string') {
const matchingEntry = this.platforms.find(({ name }) => {
return name === inputValue;
});
if (matchingEntry) {
this.accountForm.controls['platformId'].setValue(matchingEntry);
}
}
}
public displayFn(platform: Platform) {
return platform?.name ?? '';
}
public onCancel() {
@ -63,7 +105,7 @@ export class CreateOrUpdateAccountDialog implements OnDestroy {
id: this.accountForm.controls['accountId'].value,
isExcluded: this.accountForm.controls['isExcluded'].value,
name: this.accountForm.controls['name'].value,
platformId: this.accountForm.controls['platformId'].value
platformId: this.accountForm.controls['platformId'].value?.id ?? null
};
if (this.data.account.id) {
@ -79,4 +121,22 @@ export class CreateOrUpdateAccountDialog implements OnDestroy {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private autocompleteObjectValidator(): ValidatorFn {
return (control: AbstractControl) => {
if (control.value && typeof control.value === 'string') {
return { invalidAutocompleteObject: { value: control.value } };
}
return null;
};
}
private filter(value: string): Platform[] {
const filterValue = value.toLowerCase();
return this.platforms.filter(({ name }) => {
return name.toLowerCase().startsWith(filterValue);
});
}
}

31
apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html

@ -36,18 +36,37 @@
type="number"
(keydown.enter)="$event.stopPropagation()"
/>
<span class="ml-2" matTextSuffix>{{ data.account.currency }}</span>
<span class="ml-2" matTextSuffix
>{{ accountForm.controls['currency'].value }}</span
>
</mat-form-field>
</div>
<div [ngClass]="{ 'd-none': platforms?.length < 1 }">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Platform</mat-label>
<mat-select formControlName="platformId">
<mat-option [value]="null"></mat-option>
<mat-option *ngFor="let platform of platforms" [value]="platform.id"
>{{ platform.name }}</mat-option
<input
formControlName="platformId"
matInput
type="text"
[matAutocomplete]="auto"
(blur)="autoCompleteCheck()"
(keydown.enter)="$event.stopPropagation()"
/>
<mat-autocomplete #auto="matAutocomplete" [displayWith]="displayFn">
<mat-option
*ngFor="let platformEntry of filteredPlatforms | async"
[value]="platformEntry"
>
</mat-select>
<span class="d-flex">
<gf-symbol-icon
class="mr-1"
[tooltip]="platformEntry.name"
[url]="platformEntry.url"
></gf-symbol-icon>
<span>{{ platformEntry.name }}</span>
</span>
</mat-option>
</mat-autocomplete>
</mat-form-field>
</div>
<div>

4
apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.module.ts

@ -7,6 +7,8 @@ import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module';
import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog.component';
@ -15,6 +17,8 @@ import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog.c
imports: [
CommonModule,
FormsModule,
GfSymbolIconModule,
MatAutocompleteModule,
MatButtonModule,
MatCheckboxModule,
MatDialogModule,

7
apps/client/src/app/pages/portfolio/activities/activities-page.scss

@ -1,10 +1,3 @@
:host {
display: block;
.fab-container {
position: fixed;
right: 2rem;
bottom: 2rem;
z-index: 999;
}
}

15
apps/client/src/app/services/admin.service.ts

@ -1,6 +1,7 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
import { UpdateBulkMarketDataDto } from '@ghostfolio/api/app/admin/update-bulk-market-data.dto';
import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto';
import { CreatePlatformDto } from '@ghostfolio/api/app/platform/create-platform.dto';
import { UpdatePlatformDto } from '@ghostfolio/api/app/platform/update-platform.dto';
@ -214,6 +215,20 @@ export class AdminService {
);
}
public postMarketData({
dataSource,
marketData,
symbol
}: {
dataSource: DataSource;
marketData: UpdateBulkMarketDataDto;
symbol: string;
}) {
const url = `/api/v1/admin/market-data/${dataSource}/${symbol}`;
return this.http.post<MarketData>(url, marketData);
}
public postPlatform(aPlatform: CreatePlatformDto) {
return this.http.post<Platform>(`/api/v1/platform`, aPlatform);
}

18
apps/client/src/app/services/data.service.ts

@ -2,6 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { TransferBalanceDto } from '@ghostfolio/api/app/account/transfer-balance.dto';
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activities } from '@ghostfolio/api/app/order/interfaces/activities.interface';
@ -57,6 +58,7 @@ export class DataService {
ASSET_CLASS: filtersByAssetClass,
ASSET_SUB_CLASS: filtersByAssetSubClass,
PRESET_ID: filtersByPresetId,
SEARCH_QUERY: filtersBySearchQuery,
TAG: filtersByTag
} = groupBy(filters, (filter) => {
return filter.type;
@ -99,6 +101,10 @@ export class DataService {
params = params.append('presetId', filtersByPresetId[0].id);
}
if (filtersBySearchQuery) {
params = params.append('query', filtersBySearchQuery[0].id);
}
if (filtersByTag) {
params = params.append(
'tags',
@ -500,6 +506,18 @@ export class DataService {
});
}
public transferAccountBalance({
accountIdFrom,
accountIdTo,
balance
}: TransferBalanceDto) {
return this.http.post('/api/v1/account/transfer-balance', {
accountIdFrom,
accountIdTo,
balance
});
}
public updateInfo() {
this.http.get<InfoItem>('/api/v1/info').subscribe((info) => {
const utmSource = <'ios' | 'trusted-web-activity'>(

4
apps/client/src/app/services/import-activities.service.ts

@ -337,6 +337,10 @@ export class ImportActivitiesService {
return Type.BUY;
case 'dividend':
return Type.DIVIDEND;
case 'fee':
return Type.FEE;
case 'interest':
return Type.INTEREST;
case 'item':
return Type.ITEM;
case 'liability':

4
apps/client/src/app/services/web-authn.service.ts

@ -88,7 +88,9 @@ export class WebAuthnService {
{ deviceId }
)
.pipe(
switchMap(startAuthentication),
switchMap((requestOptionsJSON) =>
startAuthentication(requestOptionsJSON, true)
),
switchMap((assertionResponse) => {
return this.http.post<{ authToken: string }>(
`/api/v1/auth/webauthn/verify-assertion`,

1000
apps/client/src/locales/messages.de.xlf

File diff suppressed because it is too large

1010
apps/client/src/locales/messages.es.xlf

File diff suppressed because it is too large

1016
apps/client/src/locales/messages.fr.xlf

File diff suppressed because it is too large

1016
apps/client/src/locales/messages.it.xlf

File diff suppressed because it is too large

1016
apps/client/src/locales/messages.nl.xlf

File diff suppressed because it is too large

1016
apps/client/src/locales/messages.pt.xlf

File diff suppressed because it is too large

1002
apps/client/src/locales/messages.tr.xlf

File diff suppressed because it is too large

987
apps/client/src/locales/messages.xlf

File diff suppressed because it is too large

23
apps/client/src/styles.scss

@ -214,6 +214,16 @@ body {
}
}
.mat-mdc-menu-panel {
&.assistant {
max-width: unset !important;
.mat-mdc-menu-content {
padding: 0;
}
}
}
&.is-dark-theme {
background: var(--dark-background);
color: rgba(var(--light-primary-text));
@ -481,6 +491,13 @@ ngx-skeleton-loader {
flex-direction: column;
overflow-y: auto;
.fab-container {
bottom: 2rem;
position: fixed;
right: 2rem;
z-index: 999;
}
&:not(.has-tabs) {
@media (min-width: 576px) {
padding: 2rem 0;
@ -492,6 +509,12 @@ ngx-skeleton-loader {
padding-bottom: env(safe-area-inset-bottom);
padding-bottom: constant(safe-area-inset-bottom);
.fab-container {
@media (max-width: 575.98px) {
bottom: 5rem;
}
}
.mat-mdc-tab-nav-bar {
--mat-tab-header-active-focus-indicator-color: transparent;
--mat-tab-header-active-hover-indicator-color: transparent;

4
docker/docker-compose.dev.yml

@ -7,7 +7,7 @@ services:
env_file:
- ../.env
ports:
- 5432:5432
- ${POSTGRES_PORT:-5432}:5432
volumes:
- postgres:/var/lib/postgresql/data
redis:
@ -15,7 +15,7 @@ services:
container_name: redis
restart: unless-stopped
ports:
- 6379:6379
- ${REDIS_PORT:-6379}:6379
volumes:
postgres:

2
libs/common/src/lib/interfaces/admin-market-data.interface.ts

@ -9,9 +9,11 @@ export interface AdminMarketDataItem {
assetClass?: AssetClass;
assetSubClass?: AssetSubClass;
countriesCount: number;
currency: string;
dataSource: DataSource;
date?: Date;
marketDataItemCount: number;
name: string;
sectorsCount: number;
symbol: string;
}

1
libs/common/src/lib/interfaces/benchmark.interface.ts

@ -5,6 +5,7 @@ export interface Benchmark {
name: EnhancedSymbolProfile['name'];
performances: {
allTimeHigh: {
date: Date;
performancePercent: number;
};
};

1
libs/common/src/lib/interfaces/enhanced-symbol-profile.interface.ts

@ -15,6 +15,7 @@ export interface EnhancedSymbolProfile {
dataSource: DataSource;
dateOfFirstActivity?: Date;
id: string;
isin: string | null;
name: string | null;
scraperConfiguration?: ScraperConfiguration | null;
sectors: Sector[];

1
libs/common/src/lib/interfaces/filter.interface.ts

@ -6,6 +6,7 @@ export interface Filter {
| 'ASSET_CLASS'
| 'ASSET_SUB_CLASS'
| 'PRESET_ID'
| 'SEARCH_QUERY'
| 'SYMBOL'
| 'TAG';
}

2
libs/common/src/lib/interfaces/index.ts

@ -33,6 +33,7 @@ import type { PortfolioReport } from './portfolio-report.interface';
import type { PortfolioSummary } from './portfolio-summary.interface';
import type { Position } from './position.interface';
import type { Product } from './product';
import type { AccountBalancesResponse } from './responses/account-balances-response.interface';
import type { BenchmarkResponse } from './responses/benchmark-response.interface';
import type { ResponseError } from './responses/errors.interface';
import type { ImportResponse } from './responses/import-response.interface';
@ -49,6 +50,7 @@ import type { User } from './user.interface';
export {
Access,
AccountBalancesResponse,
Accounts,
AdminData,
AdminJobs,

4
libs/common/src/lib/interfaces/info-item.interface.ts

@ -1,5 +1,5 @@
import { SubscriptionOffer } from '@ghostfolio/common/types';
import { SymbolProfile, Tag } from '@prisma/client';
import { Platform, SymbolProfile, Tag } from '@prisma/client';
import { Statistics } from './statistics.interface';
import { Subscription } from './subscription.interface';
@ -13,7 +13,7 @@ export interface InfoItem {
fearAndGreedDataSource?: string;
globalPermissions: string[];
isReadOnlyMode?: boolean;
platforms: { id: string; name: string }[];
platforms: Platform[];
statistics: Statistics;
stripePublicKey?: string;
subscriptions: { [offer in SubscriptionOffer]: Subscription };

6
libs/common/src/lib/interfaces/position.interface.ts

@ -1,9 +1,9 @@
import { AssetClass, DataSource } from '@prisma/client';
import { MarketState } from '../types';
import { MarketState } from '@ghostfolio/common/types';
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
export interface Position {
assetClass: AssetClass;
assetSubClass: AssetSubClass;
averagePrice: number;
currency: string;
dataSource: DataSource;

5
libs/common/src/lib/interfaces/responses/account-balances-response.interface.ts

@ -0,0 +1,5 @@
import { AccountBalance } from '@prisma/client';
export interface AccountBalancesResponse {
balances: Pick<AccountBalance, 'date' | 'id' | 'value'>[];
}

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

@ -3,6 +3,7 @@ import { Role } from '@prisma/client';
export const permissions = {
accessAdminControl: 'accessAdminControl',
accessAssistant: 'accessAssistant',
createAccess: 'createAccess',
createAccount: 'createAccount',
createOrder: 'createOrder',
@ -41,6 +42,7 @@ export function getPermissions(aRole: Role): string[] {
case 'ADMIN':
return [
permissions.accessAdminControl,
permissions.accessAssistant,
permissions.createAccess,
permissions.createAccount,
permissions.createOrder,
@ -63,10 +65,11 @@ export function getPermissions(aRole: Role): string[] {
];
case 'DEMO':
return [permissions.createUserAccount];
return [permissions.accessAssistant, permissions.createUserAccount];
case 'USER':
return [
permissions.accessAssistant,
permissions.createAccess,
permissions.createAccount,
permissions.createOrder,

78
libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.component.ts

@ -0,0 +1,78 @@
import { FocusableOption } from '@angular/cdk/a11y';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
EventEmitter,
HostBinding,
Input,
OnChanges,
Output,
ViewChild
} from '@angular/core';
import { Params } from '@angular/router';
import { ISearchResultItem } from '@ghostfolio/ui/assistant/interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-assistant-list-item',
templateUrl: './assistant-list-item.html',
styleUrls: ['./assistant-list-item.scss']
})
export class AssistantListItemComponent implements FocusableOption, OnChanges {
@HostBinding('attr.tabindex') tabindex = -1;
@HostBinding('class.has-focus') get getHasFocus() {
return this.hasFocus;
}
@Input() item: ISearchResultItem;
@Input() mode: 'assetProfile' | 'holding';
@Output() clicked = new EventEmitter<void>();
@ViewChild('link') public linkElement: ElementRef;
public hasFocus = false;
public queryParams: Params;
public routerLink: string[];
public constructor(private changeDetectorRef: ChangeDetectorRef) {}
public ngOnChanges() {
const dataSource = this.item?.dataSource;
const symbol = this.item?.symbol;
if (this.mode === 'assetProfile') {
this.queryParams = {
dataSource,
symbol,
assetProfileDialog: true
};
this.routerLink = ['/admin', 'market-data'];
} else if (this.mode === 'holding') {
this.queryParams = {
dataSource,
symbol,
positionDetailDialog: true
};
this.routerLink = ['/portfolio', 'holdings'];
}
}
public focus() {
this.hasFocus = true;
this.changeDetectorRef.markForCheck();
}
public onClick() {
this.clicked.emit();
}
public removeFocus() {
this.hasFocus = false;
this.changeDetectorRef.markForCheck();
}
}

16
libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.html

@ -0,0 +1,16 @@
<a
#link
class="d-block line-height-1 px-2 py-1 text-truncate"
[queryParams]="queryParams"
[routerLink]="routerLink"
(click)="onClick()"
><span><b>{{ item?.name }}</b></span>
<br />
<small class="text-muted"
>{{ item?.symbol | gfSymbol }} · {{ item?.currency }}<ng-container
*ngIf="item?.assetSubClassString"
>
· {{ item?.assetSubClassString }}</ng-container
></small
></a
>

13
libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.module.ts

@ -0,0 +1,13 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { AssistantListItemComponent } from './assistant-list-item.component';
@NgModule({
declarations: [AssistantListItemComponent],
exports: [AssistantListItemComponent],
imports: [CommonModule, GfSymbolModule, RouterModule]
})
export class GfAssistantListItemModule {}

19
libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.scss

@ -0,0 +1,19 @@
:host {
display: block;
&.has-focus {
background-color: rgba(var(--palette-primary-500), 1);
a {
color: rgba(var(--light-primary-text));
}
}
}
:host-context(.is-dark-theme) {
&.has-focus {
a {
color: rgba(var(--dark-primary-text));
}
}
}

284
libs/ui/src/lib/assistant/assistant.component.ts

@ -0,0 +1,284 @@
import { FocusKeyManager } from '@angular/cdk/a11y';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
EventEmitter,
HostListener,
Input,
OnDestroy,
OnInit,
Output,
QueryList,
ViewChild,
ViewChildren
} from '@angular/core';
import { FormControl } from '@angular/forms';
import { MatMenuTrigger } from '@angular/material/menu';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { translate } from '@ghostfolio/ui/i18n';
import { EMPTY, Observable, Subject, lastValueFrom } from 'rxjs';
import {
catchError,
debounceTime,
distinctUntilChanged,
map,
mergeMap,
takeUntil
} from 'rxjs/operators';
import { AssistantListItemComponent } from './assistant-list-item/assistant-list-item.component';
import { ISearchResultItem, ISearchResults } from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-assistant',
styleUrls: ['./assistant.scss'],
templateUrl: './assistant.html'
})
export class AssistantComponent implements OnDestroy, OnInit {
@HostListener('document:keydown', ['$event']) onKeydown(
event: KeyboardEvent
) {
if (!this.isOpen) {
return;
}
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
for (const item of this.assistantListItems) {
item.removeFocus();
}
this.keyManager.onKeydown(event);
const currentAssistantListItem = this.getCurrentAssistantListItem();
if (currentAssistantListItem?.linkElement) {
currentAssistantListItem.linkElement.nativeElement?.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
}
} else if (event.key === 'Enter') {
const currentAssistantListItem = this.getCurrentAssistantListItem();
if (currentAssistantListItem?.linkElement) {
currentAssistantListItem.linkElement.nativeElement?.click();
event.stopPropagation();
}
}
}
@Input() deviceType: string;
@Input() hasPermissionToAccessAdminControl: boolean;
@Output() closed = new EventEmitter<void>();
@ViewChild('menuTrigger') menuTriggerElement: MatMenuTrigger;
@ViewChild('search', { static: true }) searchElement: ElementRef;
@ViewChildren(AssistantListItemComponent)
assistantListItems: QueryList<AssistantListItemComponent>;
public static readonly SEARCH_RESULTS_DEFAULT_LIMIT = 5;
public isLoading = false;
public isOpen = false;
public placeholder = $localize`Find holding...`;
public searchFormControl = new FormControl('');
public searchResults: ISearchResults = {
assetProfiles: [],
holdings: []
};
private keyManager: FocusKeyManager<AssistantListItemComponent>;
private unsubscribeSubject = new Subject<void>();
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService
) {}
public ngOnInit() {
this.searchFormControl.valueChanges
.pipe(
map((searchTerm) => {
this.isLoading = true;
this.searchResults = {
assetProfiles: [],
holdings: []
};
this.changeDetectorRef.markForCheck();
return searchTerm;
}),
debounceTime(300),
distinctUntilChanged(),
mergeMap(async (searchTerm) => {
const result = <ISearchResults>{
assetProfiles: [],
holdings: []
};
try {
if (searchTerm) {
return await this.getSearchResults(searchTerm);
}
} catch {}
return result;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe((searchResults) => {
this.searchResults = searchResults;
this.isLoading = false;
this.changeDetectorRef.markForCheck();
});
}
public async initialize() {
this.isLoading = true;
this.keyManager = new FocusKeyManager(this.assistantListItems).withWrap();
this.searchResults = {
assetProfiles: [],
holdings: []
};
for (const item of this.assistantListItems) {
item.removeFocus();
}
this.searchFormControl.setValue('');
setTimeout(() => {
this.searchElement?.nativeElement?.focus();
});
this.isLoading = false;
this.setIsOpen(true);
this.changeDetectorRef.markForCheck();
}
public onCloseAssistant() {
this.setIsOpen(false);
this.closed.emit();
}
public setIsOpen(aIsOpen: boolean) {
this.isOpen = aIsOpen;
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private getCurrentAssistantListItem() {
return this.assistantListItems.find(({ getHasFocus }) => {
return getHasFocus;
});
}
private async getSearchResults(aSearchTerm: string) {
let assetProfiles: ISearchResultItem[] = [];
let holdings: ISearchResultItem[] = [];
if (this.hasPermissionToAccessAdminControl) {
try {
assetProfiles = await lastValueFrom(
this.searchAssetProfiles(aSearchTerm)
);
assetProfiles = assetProfiles.slice(
0,
AssistantComponent.SEARCH_RESULTS_DEFAULT_LIMIT
);
} catch {}
}
try {
holdings = await lastValueFrom(this.searchHoldings(aSearchTerm));
holdings = holdings.slice(
0,
AssistantComponent.SEARCH_RESULTS_DEFAULT_LIMIT
);
} catch {}
return {
assetProfiles,
holdings
};
}
private searchAssetProfiles(
aSearchTerm: string
): Observable<ISearchResultItem[]> {
return this.adminService
.fetchAdminMarketData({
filters: [
{
id: aSearchTerm,
type: 'SEARCH_QUERY'
}
],
take: AssistantComponent.SEARCH_RESULTS_DEFAULT_LIMIT
})
.pipe(
catchError(() => {
return EMPTY;
}),
map(({ marketData }) => {
return marketData.map(
({ assetSubClass, currency, dataSource, name, symbol }) => {
return {
currency,
dataSource,
name,
symbol,
assetSubClassString: translate(assetSubClass)
};
}
);
}),
takeUntil(this.unsubscribeSubject)
);
}
private searchHoldings(aSearchTerm: string): Observable<ISearchResultItem[]> {
return this.dataService
.fetchPositions({
filters: [
{
id: aSearchTerm,
type: 'SEARCH_QUERY'
}
],
range: '1d'
})
.pipe(
catchError(() => {
return EMPTY;
}),
map(({ positions }) => {
return positions.map(
({ assetSubClass, currency, dataSource, name, symbol }) => {
return {
currency,
dataSource,
name,
symbol,
assetSubClassString: translate(assetSubClass)
};
}
);
}),
takeUntil(this.unsubscribeSubject)
);
}
}

88
libs/ui/src/lib/assistant/assistant.html

@ -0,0 +1,88 @@
<div
[style.width]="deviceType === 'mobile' ? '85vw' : '30rem'"
(click)="$event.stopPropagation();"
(keydown.tab)="$event.stopPropagation()"
>
<div class="align-items-center d-flex search-container">
<ion-icon class="ml-2 mr-0" name="search-outline"></ion-icon>
<input
#search
autocomplete="off"
autocorrect="off"
class="border-0 p-2 w-100"
name="search"
type="text"
[formControl]="searchFormControl"
[placeholder]="placeholder"
/>
<div
*ngIf="deviceType !== 'mobile' && !searchFormControl.value"
class="hot-key-hint mx-1 px-1"
>
/
</div>
<button
*ngIf="searchFormControl.value"
class="h-100 no-min-width px-3 rounded-0"
mat-button
(click)="initialize()"
>
<ion-icon class="m-0" name="close-circle-outline"></ion-icon>
</button>
<button
*ngIf="!searchFormControl.value"
class="h-100 no-min-width px-3 rounded-0"
mat-button
(click)="onCloseAssistant()"
>
<ion-icon class="m-0" name="close-outline"></ion-icon>
</button>
</div>
<div
*ngIf="isLoading || searchFormControl.value"
class="overflow-auto py-3 result-container"
>
<div>
<div class="h6 mb-1 px-2" i18n>Holdings</div>
<gf-assistant-list-item
*ngFor="let searchResultItem of searchResults?.holdings"
mode="holding"
[item]="searchResultItem"
(clicked)="onCloseAssistant()"
/>
<ng-container *ngIf="searchResults?.holdings?.length === 0">
<ngx-skeleton-loader
*ngIf="isLoading"
animation="pulse"
class="mx-2"
[theme]="{
height: '1.5rem',
width: '100%'
}"
></ngx-skeleton-loader>
<div *ngIf="!isLoading" class="px-2 py-1" i18n>No entries...</div>
</ng-container>
</div>
<div *ngIf="hasPermissionToAccessAdminControl" class="mt-3">
<div class="h6 mb-1 px-2" i18n>Asset Profiles</div>
<gf-assistant-list-item
*ngFor="let searchResultItem of searchResults?.assetProfiles"
mode="assetProfile"
[item]="searchResultItem"
(clicked)="onCloseAssistant()"
/>
<ng-container *ngIf="searchResults?.assetProfiles?.length === 0">
<ngx-skeleton-loader
*ngIf="isLoading"
animation="pulse"
class="mx-2"
[theme]="{
height: '1.5rem',
width: '100%'
}"
></ngx-skeleton-loader>
<div *ngIf="!isLoading" class="px-2 py-1" i18n>No entries...</div>
</ng-container>
</div>
</div>
</div>

25
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 {}

37
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));
}
}
}

1
libs/ui/src/lib/assistant/index.ts

@ -0,0 +1 @@
export * from './assistant.module';

12
libs/ui/src/lib/assistant/interfaces/interfaces.ts

@ -0,0 +1,12 @@
import { UniqueAsset } from '@ghostfolio/common/interfaces';
export interface ISearchResultItem extends UniqueAsset {
assetSubClassString: string;
currency: string;
name: string;
}
export interface ISearchResults {
assetProfiles: ISearchResultItem[];
holdings: ISearchResultItem[];
}

27
libs/ui/src/lib/benchmark/benchmark.component.html

@ -6,14 +6,33 @@
</td>
</ng-container>
<ng-container matColumnDef="date">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell px-2 text-right"
mat-header-cell
>
<ng-container i18n>Last All Time High</ng-container>
</th>
<td *matCellDef="let element" class="d-none d-lg-table-cell px-2" mat-cell>
<div class="d-flex justify-content-end">
<gf-value
[isDate]="true"
[locale]="locale"
[value]="element?.performances?.allTimeHigh?.date"
></gf-value>
</div>
</td>
</ng-container>
<ng-container matColumnDef="change">
<th *matHeaderCellDef class="text-right" mat-header-cell>
<th *matHeaderCellDef class="px-2 text-right" mat-header-cell>
<span class="d-none d-sm-block text-nowrap" i18n
>Change from All Time High</span
>
<span class="d-block d-sm-none text-nowrap" i18n>from ATH</span>
</th>
<td *matCellDef="let element" class="text-right" mat-cell>
<td *matCellDef="let element" class="px-2 text-right" mat-cell>
<gf-value
class="d-inline-block justify-content-end"
size="medium"
@ -25,9 +44,7 @@
'text-success':
element?.performances?.allTimeHigh?.performancePercent > 0
}"
[value]="
element?.performances?.allTimeHigh?.performancePercent ?? undefined
"
[value]="element?.performances?.allTimeHigh?.performancePercent"
></gf-value>
</td>
</ng-container>

2
libs/ui/src/lib/benchmark/benchmark.component.ts

@ -18,7 +18,7 @@ export class BenchmarkComponent implements OnChanges {
@Input() benchmarks: Benchmark[];
@Input() locale: string;
public displayedColumns = ['name', 'change', 'marketCondition'];
public displayedColumns = ['name', 'date', 'change', 'marketCondition'];
public resolveMarketCondition = resolveMarketCondition;
public constructor() {}

12
package.json

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "2.8.0",
"version": "2.11.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio",
@ -81,9 +81,9 @@
"@nestjs/platform-express": "10.1.3",
"@nestjs/schedule": "3.0.2",
"@nestjs/serve-static": "4.0.0",
"@prisma/client": "5.3.1",
"@simplewebauthn/browser": "5.2.1",
"@simplewebauthn/server": "5.2.1",
"@prisma/client": "5.4.2",
"@simplewebauthn/browser": "8.3.1",
"@simplewebauthn/server": "8.3.2",
"@stripe/stripe-js": "1.47.0",
"alphavantage": "2.2.0",
"big.js": "6.2.1",
@ -122,7 +122,7 @@
"passport": "0.6.0",
"passport-google-oauth20": "2.0.0",
"passport-jwt": "4.0.0",
"prisma": "5.3.1",
"prisma": "5.4.2",
"reflect-metadata": "0.1.13",
"rxjs": "7.5.6",
"stripe": "11.12.0",
@ -157,7 +157,7 @@
"@nx/web": "16.7.4",
"@nx/workspace": "16.7.4",
"@schematics/angular": "16.2.0",
"@simplewebauthn/typescript-types": "5.2.1",
"@simplewebauthn/typescript-types": "8.0.0",
"@storybook/addon-essentials": "7.3.2",
"@storybook/angular": "7.3.2",
"@storybook/core-server": "7.3.2",

11
test/import/ok.csv

@ -1,5 +1,6 @@
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
01.01.2022,Penthouse Apartment,USD,500000.0,1,item,0.00
20500606,MSFT,USD,0.00,0,buy,0.00
Date,Code,DataSource,Currency,Price,Quantity,Action,Fee,Note
01-09-2021,Account Opening Fee,MANUAL,USD,0,0,fee,49,
16-09-2021,MSFT,YAHOO,USD,298.580,5,buy,19.00,My first order 🤓
17/11/2021,MSFT,YAHOO,USD,0.62,5,dividend,0.00
01.01.2022,Penthouse Apartment,MANUAL,USD,500000.0,1,item,0.00
20500606,MSFT,YAHOO,USD,0.00,0,buy,0.00

Can't render this file because it has a wrong number of fields in line 3.

279
yarn.lock

@ -1819,6 +1819,36 @@
resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-6.0.2.tgz#6110f918d273fe2af8ea1c4398a88774bb9fc12f"
integrity sha512-Tbsj02wXCbqGmzdnXNk0SOF19ChhRU70BsroIi4Pm6Ehp56in6vch94mfbdQ17DozxkL3BAVjbZ4Qc1a0HFRAg==
"@cbor-extract/cbor-extract-darwin-arm64@2.1.1":
version "2.1.1"
resolved "https://registry.yarnpkg.com/@cbor-extract/cbor-extract-darwin-arm64/-/cbor-extract-darwin-arm64-2.1.1.tgz#5721f6dd3feae0b96d23122853ce977e0671b7a6"
integrity sha512-blVBy5MXz6m36Vx0DfLd7PChOQKEs8lK2bD1WJn/vVgG4FXZiZmZb2GECHFvVPA5T7OnODd9xZiL3nMCv6QUhA==
"@cbor-extract/cbor-extract-darwin-x64@2.1.1":
version "2.1.1"
resolved "https://registry.yarnpkg.com/@cbor-extract/cbor-extract-darwin-x64/-/cbor-extract-darwin-x64-2.1.1.tgz#c25e7d0133950d87d101d7b3afafea8d50d83f5f"
integrity sha512-h6KFOzqk8jXTvkOftyRIWGrd7sKQzQv2jVdTL9nKSf3D2drCvQB/LHUxAOpPXo3pv2clDtKs3xnHalpEh3rDsw==
"@cbor-extract/cbor-extract-linux-arm64@2.1.1":
version "2.1.1"
resolved "https://registry.yarnpkg.com/@cbor-extract/cbor-extract-linux-arm64/-/cbor-extract-linux-arm64-2.1.1.tgz#48f78e7d8f0fcc84ed074b6bfa6d15dd83187c63"
integrity sha512-SxAaRcYf8S0QHaMc7gvRSiTSr7nUYMqbUdErBEu+HYA4Q6UNydx1VwFE68hGcp1qvxcy9yT5U7gA+a5XikfwSQ==
"@cbor-extract/cbor-extract-linux-arm@2.1.1":
version "2.1.1"
resolved "https://registry.yarnpkg.com/@cbor-extract/cbor-extract-linux-arm/-/cbor-extract-linux-arm-2.1.1.tgz#7507d346389cb682e44fab8fae9534edd52e2e41"
integrity sha512-ds0uikdcIGUjPyraV4oJqyVE5gl/qYBpa/Wnh6l6xLE2lj/hwnjT2XcZCChdXwW/YFZ1LUHs6waoYN8PmK0nKQ==
"@cbor-extract/cbor-extract-linux-x64@2.1.1":
version "2.1.1"
resolved "https://registry.yarnpkg.com/@cbor-extract/cbor-extract-linux-x64/-/cbor-extract-linux-x64-2.1.1.tgz#b7c1d2be61c58ec18d58afbad52411ded63cd4cd"
integrity sha512-GVK+8fNIE9lJQHAlhOROYiI0Yd4bAZ4u++C2ZjlkS3YmO6hi+FUxe6Dqm+OKWTcMpL/l71N6CQAmaRcb4zyJuA==
"@cbor-extract/cbor-extract-win32-x64@2.1.1":
version "2.1.1"
resolved "https://registry.yarnpkg.com/@cbor-extract/cbor-extract-win32-x64/-/cbor-extract-win32-x64-2.1.1.tgz#21b11a1a3f18c3e7d62fd5f87438b7ed2c64c1f7"
integrity sha512-2Niq1C41dCRIDeD8LddiH+mxGlO7HJ612Ll3D/E73ZWBmycued+8ghTr/Ho3CMOWPUEr08XtyBMVXAjqF+TcKw==
"@codewithdan/observable-store@2.2.15":
version "2.2.15"
resolved "https://registry.yarnpkg.com/@codewithdan/observable-store/-/observable-store-2.2.15.tgz#6d27e0988e182853def59a714b712f4389e558d2"
@ -2540,6 +2570,11 @@
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.1.1.tgz#1a5b1959a528e374e8037c4396c3e825d6cf4a83"
integrity sha512-m0G6wlnhm/AX0H12IOWtK8gASEMffnX08RtKkCgTdHb9JpHKGloI7icFfLg9ZmQeavcvR0PKmzxClyuFPSjKWw==
"@hexagon/base64@^1.1.27":
version "1.1.28"
resolved "https://registry.yarnpkg.com/@hexagon/base64/-/base64-1.1.28.tgz#7d306a97f1423829be5b27c9d388fe50e3099d48"
integrity sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==
"@humanwhocodes/config-array@^0.11.8":
version "0.11.10"
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.10.tgz#5a3ffe32cc9306365fb3fd572596cd602d5e12d2"
@ -4252,7 +4287,7 @@
node-addon-api "^3.2.1"
node-gyp-build "^4.3.0"
"@peculiar/asn1-android@^2.1.7":
"@peculiar/asn1-android@^2.3.6":
version "2.3.6"
resolved "https://registry.yarnpkg.com/@peculiar/asn1-android/-/asn1-android-2.3.6.tgz#20363c23bc5b9a91f7ffd80d7c3842dccff8c20b"
integrity sha512-zkYh4DsiRhiNfg6tWaUuRc+huwlb9XJbmeZLrjTz9v76UK1Ehq3EnfJFED6P3sdznW/nqWe46LoM9JrqxcD58g==
@ -4261,7 +4296,27 @@
asn1js "^3.0.5"
tslib "^2.4.0"
"@peculiar/asn1-schema@^2.1.7", "@peculiar/asn1-schema@^2.3.6":
"@peculiar/asn1-ecc@^2.3.6":
version "2.3.6"
resolved "https://registry.yarnpkg.com/@peculiar/asn1-ecc/-/asn1-ecc-2.3.6.tgz#f155f33f5c61df463d9b33b911d25578a19694b7"
integrity sha512-Hu1xzMJQWv8/GvzOiinaE6XiD1/kEhq2C/V89UEoWeZ2fLUcGNIvMxOr/pMyL0OmpRWj/mhCTXOZp4PP+a0aTg==
dependencies:
"@peculiar/asn1-schema" "^2.3.6"
"@peculiar/asn1-x509" "^2.3.6"
asn1js "^3.0.5"
tslib "^2.4.0"
"@peculiar/asn1-rsa@^2.3.6":
version "2.3.6"
resolved "https://registry.yarnpkg.com/@peculiar/asn1-rsa/-/asn1-rsa-2.3.6.tgz#e2af2c52a914c60f33853a86a48905ec555b29c2"
integrity sha512-DswjJyAXZnvESuImGNTvbNKvh1XApBVqU+r3UmrFFTAI23gv62byl0f5OFKWTNhCf66WQrd3sklpsCZc/4+jwA==
dependencies:
"@peculiar/asn1-schema" "^2.3.6"
"@peculiar/asn1-x509" "^2.3.6"
asn1js "^3.0.5"
tslib "^2.4.0"
"@peculiar/asn1-schema@^2.3.6":
version "2.3.6"
resolved "https://registry.yarnpkg.com/@peculiar/asn1-schema/-/asn1-schema-2.3.6.tgz#3dd3c2ade7f702a9a94dfb395c192f5fa5d6b922"
integrity sha512-izNRxPoaeJeg/AyH8hER6s+H7p4itk+03QCa4sbxI3lNdseQYCuxzgsuNK8bTXChtLTjpJz6NmXKA73qLa3rCA==
@ -4270,7 +4325,7 @@
pvtsutils "^1.3.2"
tslib "^2.4.0"
"@peculiar/asn1-x509@^2.1.7":
"@peculiar/asn1-x509@^2.3.6":
version "2.3.6"
resolved "https://registry.yarnpkg.com/@peculiar/asn1-x509/-/asn1-x509-2.3.6.tgz#e50154a460cdf43da8a41b23ee807a53e0036af0"
integrity sha512-dRwX31R1lcbIdzbztiMvLNTDoGptxdV7HocNx87LfKU0fEWh7fTWJjx4oV+glETSy6heF/hJHB2J4RGB3vVSYg==
@ -4293,22 +4348,22 @@
resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33"
integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==
"@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==
"@prisma/client@5.4.2":
version "5.4.2"
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-5.4.2.tgz#786f9c1d8f06d955933004ac638d14da4bf14025"
integrity sha512-2xsPaz4EaMKj1WS9iW6MlPhmbqtBsXAOeVttSePp8vTFTtvzh2hZbDgswwBdSCgPzmmwF+tLB259QzggvCmJqA==
dependencies:
"@prisma/engines-version" "5.3.1-2.61e140623197a131c2a6189271ffee05a7aa9a59"
"@prisma/engines-version" "5.4.1-2.ac9d7041ed77bcc8a8dbd2ab6616b39013829574"
"@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-version@5.4.1-2.ac9d7041ed77bcc8a8dbd2ab6616b39013829574":
version "5.4.1-2.ac9d7041ed77bcc8a8dbd2ab6616b39013829574"
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-5.4.1-2.ac9d7041ed77bcc8a8dbd2ab6616b39013829574.tgz#ff14f2926890edee47e8f1d08df7b4f392ee34bf"
integrity sha512-wvupDL4AA1vf4TQNANg7kR7y98ITqPsk6aacfBxZKtrJKRIsWjURHkZCGcQliHdqCiW/hGreO6d6ZuSv9MhdAA==
"@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==
"@prisma/engines@5.4.2":
version "5.4.2"
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-5.4.2.tgz#ba2b7faeb227c76e423e88f962afe6a031319f3f"
integrity sha512-fqeucJ3LH0e1eyFdT0zRx+oETLancu5+n4lhiYECyEz6H2RDskPJHJYHkVc0LhkU4Uv7fuEnppKU3nVKNzMh8g==
"@radix-ui/number@1.0.1":
version "1.0.1"
@ -4638,38 +4693,32 @@
"@sigstore/protobuf-specs" "^0.1.0"
tuf-js "^1.1.7"
"@simplewebauthn/browser@5.2.1":
version "5.2.1"
resolved "https://registry.yarnpkg.com/@simplewebauthn/browser/-/browser-5.2.1.tgz#569252a9f235a99aae90c4d1cc6c441f42637b8e"
integrity sha512-TxL3OPHJf57hmnfQoF3zRIQWEdsJLxrA9NcGdRK0sB/h3jd13kpGQonBtMnj4YBQnWTtRDZ804wlpI9IEMaJ9g==
"@simplewebauthn/server@5.2.1":
version "5.2.1"
resolved "https://registry.yarnpkg.com/@simplewebauthn/server/-/server-5.2.1.tgz#49038d2951ad2ac065bdf8342fdb13f78ee4df1c"
integrity sha512-+CQ8oJf9Io8y4ReYLagX5JG9ShntIkdeCPkMoyHLBSRPlNY0N/Yv3Iun4YPQ8d4LJUU9f8S1eD5bibIEMjWDRg==
dependencies:
"@peculiar/asn1-android" "^2.1.7"
"@peculiar/asn1-schema" "^2.1.7"
"@peculiar/asn1-x509" "^2.1.7"
"@simplewebauthn/typescript-types" "^5.2.1"
base64url "^3.0.1"
cbor "^5.1.0"
debug "^4.3.2"
elliptic "^6.5.3"
jsrsasign "^10.4.0"
jwk-to-pem "^2.0.4"
node-fetch "^2.6.0"
node-rsa "^1.1.1"
"@simplewebauthn/browser@8.3.1":
version "8.3.1"
resolved "https://registry.yarnpkg.com/@simplewebauthn/browser/-/browser-8.3.1.tgz#f5c1aed6313d61944a9e13f16ae4495750bddf93"
integrity sha512-bMW7oOkxX4ydRAkkPtJ1do2k9yOoIGc/hZYebcuEOVdJoC6wwVpu97mYY7Mz8B9hLlcaR5WFgBsLl5tSJVzm8A==
dependencies:
"@simplewebauthn/typescript-types" "^8.0.0"
"@simplewebauthn/typescript-types@5.2.1":
version "5.2.1"
resolved "https://registry.yarnpkg.com/@simplewebauthn/typescript-types/-/typescript-types-5.2.1.tgz#a8229ce4f71be7edafe3bfdce062b332ef494f0d"
integrity sha512-t/NzbjaD0zu4ivUmiof2cPA8X5LHhFX+DflBBl71/dzEhl15qepDI2rxWdjB+Hc0FfOT1fBQnb1uP19fPcDUiA==
"@simplewebauthn/server@8.3.2":
version "8.3.2"
resolved "https://registry.yarnpkg.com/@simplewebauthn/server/-/server-8.3.2.tgz#dfdbe7af4c1258e786c4a0b1c83c54743ba7568c"
integrity sha512-ceo8t5gdO5W/JOePQWPDH+rAd8tO6QNalLU56rc9ItdzaTjk+qcYwQg/BKXDDg6117P3HKrRBkZwBrMJl4dOdA==
dependencies:
"@hexagon/base64" "^1.1.27"
"@peculiar/asn1-android" "^2.3.6"
"@peculiar/asn1-ecc" "^2.3.6"
"@peculiar/asn1-rsa" "^2.3.6"
"@peculiar/asn1-schema" "^2.3.6"
"@peculiar/asn1-x509" "^2.3.6"
"@simplewebauthn/typescript-types" "^8.0.0"
cbor-x "^1.5.2"
cross-fetch "^4.0.0"
"@simplewebauthn/typescript-types@^5.2.1":
version "5.4.0"
resolved "https://registry.yarnpkg.com/@simplewebauthn/typescript-types/-/typescript-types-5.4.0.tgz#533b28e7cabcc092396ecd07bbb953b71e7696b6"
integrity sha512-LeJq6Jx+o7D6iIlCy8CH5jCjwVcUvAReEo66VcF3nysfc/yKW5yCAPLSRmPITF4CRZTfnVPxUBUcveUQL6aBMA==
"@simplewebauthn/typescript-types@8.0.0", "@simplewebauthn/typescript-types@^8.0.0":
version "8.0.0"
resolved "https://registry.yarnpkg.com/@simplewebauthn/typescript-types/-/typescript-types-8.0.0.tgz#1698a7228aba880c5c1deba1f13a4f9fd8851cb3"
integrity sha512-d7Izb2H+LZJteXMkS8DmpAarD6mZdpIOu/av/yH4/u/3Pd6DKFLyBM3j8BMmUvUqpzvJvHARNrRfQYto58mtTQ==
"@sinclair/typebox@^0.27.8":
version "0.27.8"
@ -7122,17 +7171,7 @@ arrify@^2.0.0:
resolved "https://registry.yarnpkg.com/arrify/-/arrify-2.0.1.tgz#c9655e9331e0abcd588d2a7cad7e9956f66701fa"
integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==
asn1.js@^5.3.0:
version "5.4.1"
resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-5.4.1.tgz#11a980b84ebb91781ce35b0fdc2ee294e3783f07"
integrity sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==
dependencies:
bn.js "^4.0.0"
inherits "^2.0.1"
minimalistic-assert "^1.0.0"
safer-buffer "^2.1.0"
asn1@^0.2.4, asn1@~0.2.3:
asn1@~0.2.3:
version "0.2.6"
resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.6.tgz#0d3a7bb6e64e02a90c0303b31f292868ea09a08d"
integrity sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==
@ -7461,7 +7500,7 @@ base64-js@^1.2.0, base64-js@^1.3.0, base64-js@^1.3.1:
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
base64url@3.x.x, base64url@^3.0.1:
base64url@3.x.x:
version "3.0.1"
resolved "https://registry.yarnpkg.com/base64url/-/base64url-3.0.1.tgz#6399d572e2bc3f90a9a8b22d5dbb0a32d33f788d"
integrity sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==
@ -7527,7 +7566,7 @@ big.js@^5.2.2:
resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328"
integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==
bignumber.js@^9.0.0, bignumber.js@^9.0.1:
bignumber.js@^9.0.0:
version "9.1.1"
resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.1.1.tgz#c4df7dc496bd849d4c9464344c1aa74228b4dac6"
integrity sha512-pHm4LsMJ6lzgNGVfZHjMoO8sdoRhOzOH4MLmY65Jg70bpxCKu5iOHNJyfF6OyvYw7t8Fpf35RuzUyqnQsj8Vig==
@ -7556,11 +7595,6 @@ bluebird@^3.7.2:
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f"
integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==
bn.js@^4.0.0, bn.js@^4.11.9:
version "4.12.0"
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.0.tgz#775b3f278efbb9718eec7361f483fb36fbbfea88"
integrity sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==
body-parser@1.20.1:
version "1.20.1"
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.1.tgz#b1812a8912c195cd371a3ee5e66faa2338a5c668"
@ -7689,11 +7723,6 @@ braces@^3.0.2, braces@~3.0.2:
dependencies:
fill-range "^7.0.1"
brorand@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f"
integrity sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==
browser-assert@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/browser-assert/-/browser-assert-1.2.1.tgz#9aaa5a2a8c74685c2ae05bfe46efd606f068c200"
@ -7953,13 +7982,26 @@ caseless@~0.12.0:
resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
integrity sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==
cbor@^5.1.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/cbor/-/cbor-5.2.0.tgz#4cca67783ccd6de7b50ab4ed62636712f287a67c"
integrity sha512-5IMhi9e1QU76ppa5/ajP1BmMWZ2FHkhAhjeVKQ/EFCgYSEaeVaoGtL7cxJskf9oCCk+XjzaIdc3IuU/dbA/o2A==
cbor-extract@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/cbor-extract/-/cbor-extract-2.1.1.tgz#f154b31529fdb6b7c70fb3ca448f44eda96a1b42"
integrity sha512-1UX977+L+zOJHsp0mWFG13GLwO6ucKgSmSW6JTl8B9GUvACvHeIVpFqhU92299Z6PfD09aTXDell5p+lp1rUFA==
dependencies:
bignumber.js "^9.0.1"
nofilter "^1.0.4"
node-gyp-build-optional-packages "5.0.3"
optionalDependencies:
"@cbor-extract/cbor-extract-darwin-arm64" "2.1.1"
"@cbor-extract/cbor-extract-darwin-x64" "2.1.1"
"@cbor-extract/cbor-extract-linux-arm" "2.1.1"
"@cbor-extract/cbor-extract-linux-arm64" "2.1.1"
"@cbor-extract/cbor-extract-linux-x64" "2.1.1"
"@cbor-extract/cbor-extract-win32-x64" "2.1.1"
cbor-x@^1.5.2:
version "1.5.4"
resolved "https://registry.yarnpkg.com/cbor-x/-/cbor-x-1.5.4.tgz#8f0754fa8589cbd7339b613b2b5717d133508e98"
integrity sha512-PVKILDn+Rf6MRhhcyzGXi5eizn1i0i3F8Fe6UMMxXBnWkalq9+C5+VTmlIjAYM4iF2IYF2N+zToqAfYOp+3rfw==
optionalDependencies:
cbor-extract "^2.1.1"
chalk@^1.0.0, chalk@^1.1.3:
version "1.1.3"
@ -8691,6 +8733,13 @@ cross-fetch@^3.0.5:
dependencies:
node-fetch "^2.6.12"
cross-fetch@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-4.0.0.tgz#f037aef1580bb3a1a35164ea2a848ba81b445983"
integrity sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==
dependencies:
node-fetch "^2.6.12"
cross-spawn@^6.0.0, cross-spawn@^6.0.5:
version "6.0.5"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4"
@ -9741,19 +9790,6 @@ elkjs@^0.8.2:
resolved "https://registry.yarnpkg.com/elkjs/-/elkjs-0.8.2.tgz#c37763c5a3e24e042e318455e0147c912a7c248e"
integrity sha512-L6uRgvZTH+4OF5NE/MBbzQx/WYpru1xCBE9respNj6qznEewGUIfhzmm7horWWxbNO2M0WckQypGctR8lH79xQ==
elliptic@^6.5.3, elliptic@^6.5.4:
version "6.5.4"
resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.4.tgz#da37cebd31e79a1367e941b592ed1fbebd58abbb"
integrity sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==
dependencies:
bn.js "^4.11.9"
brorand "^1.1.0"
hash.js "^1.0.0"
hmac-drbg "^1.0.1"
inherits "^2.0.4"
minimalistic-assert "^1.0.1"
minimalistic-crypto-utils "^1.0.1"
emittery@^0.13.1:
version "0.13.1"
resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.13.1.tgz#c04b8c3457490e0847ae51fced3af52d338e3dad"
@ -11673,14 +11709,6 @@ has@^1.0.3:
dependencies:
function-bind "^1.1.1"
hash.js@^1.0.0, hash.js@^1.0.3:
version "1.1.7"
resolved "https://registry.yarnpkg.com/hash.js/-/hash.js-1.1.7.tgz#0babca538e8d4ee4a0f8988d68866537a003cf42"
integrity sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==
dependencies:
inherits "^2.0.3"
minimalistic-assert "^1.0.1"
hdr-histogram-js@^2.0.1:
version "2.0.3"
resolved "https://registry.yarnpkg.com/hdr-histogram-js/-/hdr-histogram-js-2.0.3.tgz#0b860534655722b6e3f3e7dca7b78867cf43dcb5"
@ -11710,15 +11738,6 @@ helmet@7.0.0:
resolved "https://registry.yarnpkg.com/helmet/-/helmet-7.0.0.tgz#ac3011ba82fa2467f58075afa58a49427ba6212d"
integrity sha512-MsIgYmdBh460ZZ8cJC81q4XJknjG567wzEmv46WOBblDb6TUd3z8/GhgmsM9pn8g2B80tAJ4m5/d3Bi1KrSUBQ==
hmac-drbg@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1"
integrity sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==
dependencies:
hash.js "^1.0.3"
minimalistic-assert "^1.0.0"
minimalistic-crypto-utils "^1.0.1"
hosted-git-info@^2.1.4:
version "2.8.9"
resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9"
@ -13490,11 +13509,6 @@ jsprim@^2.0.2:
json-schema "0.4.0"
verror "1.10.0"
jsrsasign@^10.4.0:
version "10.8.6"
resolved "https://registry.yarnpkg.com/jsrsasign/-/jsrsasign-10.8.6.tgz#ebf7f3c812c6517af84f0d8a10115e0dbfabe145"
integrity sha512-bQmbVtsfbgaKBTWCKiDCPlUPbdlRIK/FzSwT3BzIgZl/cU6TqXu6pZJsCI/dJVrZ9Gir5GC4woqw9shH/v7MBw==
jwa@^1.4.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a"
@ -13513,15 +13527,6 @@ jwa@^2.0.0:
ecdsa-sig-formatter "1.0.11"
safe-buffer "^5.0.1"
jwk-to-pem@^2.0.4:
version "2.0.5"
resolved "https://registry.yarnpkg.com/jwk-to-pem/-/jwk-to-pem-2.0.5.tgz#151310bcfbcf731adc5ad9f379cbc8b395742906"
integrity sha512-L90jwellhO8jRKYwbssU9ifaMVqajzj3fpRjDKcsDzrslU9syRbFqfkXtT4B89HYAap+xsxNcxgBSB09ig+a7A==
dependencies:
asn1.js "^5.3.0"
elliptic "^6.5.4"
safe-buffer "^5.0.1"
jws@^3.2.2:
version "3.2.2"
resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304"
@ -14226,16 +14231,11 @@ mini-css-extract-plugin@~2.4.7:
dependencies:
schema-utils "^4.0.0"
minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1:
minimalistic-assert@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7"
integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==
minimalistic-crypto-utils@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a"
integrity sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==
minimatch@3.0.5:
version "3.0.5"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.5.tgz#4da8f1290ee0f0f8e83d60ca69f8f134068604a3"
@ -14600,7 +14600,7 @@ node-fetch-native@^1.0.2:
resolved "https://registry.yarnpkg.com/node-fetch-native/-/node-fetch-native-1.2.0.tgz#13ec6df98f33168958dbfb6945f10aedf42e7ea8"
integrity sha512-5IAMBTl9p6PaAjYCnMv5FmqIF6GcZnawAVnzaCG0rX2aYZJ4CxEkZNtVPuTRug7fL7wyM5BQYTlAzcyMPi6oTQ==
node-fetch@^2.0.0, node-fetch@^2.6.0, node-fetch@^2.6.1, node-fetch@^2.6.12, node-fetch@^2.6.7:
node-fetch@^2.0.0, node-fetch@^2.6.1, node-fetch@^2.6.12, node-fetch@^2.6.7:
version "2.6.12"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.12.tgz#02eb8e22074018e3d5a83016649d04df0e348fba"
integrity sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==
@ -14612,6 +14612,11 @@ node-forge@^1, node-forge@^1.3.1:
resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.1.tgz#be8da2af243b2417d5f646a770663a92b7e9ded3"
integrity sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==
node-gyp-build-optional-packages@5.0.3:
version "5.0.3"
resolved "https://registry.yarnpkg.com/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.0.3.tgz#92a89d400352c44ad3975010368072b41ad66c17"
integrity sha512-k75jcVzk5wnnc/FMxsf4udAoTEUv2jY3ycfdSd3yWu6Cnd1oee6/CfZJApyscA4FJOmdoixWwiwOyf16RzD5JA==
node-gyp-build-optional-packages@5.0.7:
version "5.0.7"
resolved "https://registry.yarnpkg.com/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.0.7.tgz#5d2632bbde0ab2f6e22f1bbac2199b07244ae0b3"
@ -14654,18 +14659,6 @@ node-releases@^2.0.12:
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.13.tgz#d5ed1627c23e3461e819b02e57b75e4899b1c81d"
integrity sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==
node-rsa@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/node-rsa/-/node-rsa-1.1.1.tgz#efd9ad382097782f506153398496f79e4464434d"
integrity sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw==
dependencies:
asn1 "^0.2.4"
nofilter@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/nofilter/-/nofilter-1.0.4.tgz#78d6f4b6a613e7ced8b015cec534625f7667006e"
integrity sha512-N8lidFp+fCz+TD51+haYdbDGrcBWwuHX40F5+z0qkUjMJ5Tp+rdSuAkMJ9N9eoolDlEVTf6u5icM+cNKkKW2mA==
non-layered-tidy-tree-layout@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/non-layered-tidy-tree-layout/-/non-layered-tidy-tree-layout-2.0.2.tgz#57d35d13c356643fc296a55fb11ac15e74da7804"
@ -15918,12 +15911,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.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==
prisma@5.4.2:
version "5.4.2"
resolved "https://registry.yarnpkg.com/prisma/-/prisma-5.4.2.tgz#7eac9276439ec7073ec697c6c0dfa259d96e955e"
integrity sha512-GDMZwZy7mysB2oXU+angQqJ90iaPFdD0rHaZNkn+dio5NRkGLmMqmXs31//tg/qXT3iB0cTQwnGGQNuirhSTZg==
dependencies:
"@prisma/engines" "5.3.1"
"@prisma/engines" "5.4.2"
prismjs@^1.28.0:
version "1.29.0"

Loading…
Cancel
Save