Browse Source

Merge branch 'main' into subhajit20/Create-carousel-component-for-testimonials

pull/2394/head
Thomas Kaul 2 years ago
committed by GitHub
parent
commit
ace907dc98
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 53
      CHANGELOG.md
  2. 6
      DEVELOPMENT.md
  3. 2
      apps/api/src/app/account/create-account.dto.ts
  4. 2
      apps/api/src/app/account/update-account.dto.ts
  5. 4
      apps/api/src/app/admin/admin.service.ts
  6. 2
      apps/api/src/app/app.module.ts
  7. 85
      apps/api/src/app/benchmark/benchmark.controller.ts
  8. 37
      apps/api/src/app/benchmark/benchmark.service.ts
  9. 6
      apps/api/src/app/order/order.controller.ts
  10. 6
      apps/api/src/app/order/order.service.ts
  11. 40
      apps/api/src/app/portfolio/portfolio.service.ts
  12. 6
      apps/api/src/app/tag/create-tag.dto.ts
  13. 104
      apps/api/src/app/tag/tag.controller.ts
  14. 13
      apps/api/src/app/tag/tag.module.ts
  15. 79
      apps/api/src/app/tag/tag.service.ts
  16. 9
      apps/api/src/app/tag/update-tag.dto.ts
  17. 36
      apps/api/src/assets/sitemap.xml
  18. 2
      apps/api/src/helper/object.helper.ts
  19. 4
      apps/api/src/middlewares/html-template.middleware.ts
  20. 9
      apps/api/src/models/rules/account-cluster-risk/current-investment.ts
  21. 9
      apps/api/src/models/rules/account-cluster-risk/single-account.ts
  22. 9
      apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts
  23. 9
      apps/api/src/models/rules/currency-cluster-risk/current-investment.ts
  24. 46
      apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts
  25. 19
      apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts
  26. 25
      apps/client/project.json
  27. 1
      apps/client/src/app/app.component.ts
  28. 5
      apps/client/src/app/app.module.ts
  29. 22
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  30. 18
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  31. 2
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.module.ts
  32. 2
      apps/client/src/app/components/admin-overview/admin-overview.component.ts
  33. 21
      apps/client/src/app/components/admin-overview/admin-overview.html
  34. 6
      apps/client/src/app/components/admin-platform/admin-platform.component.ts
  35. 0
      apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-platform-dialog.component.ts
  36. 2
      apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-platform-dialog.module.ts
  37. 5
      apps/client/src/app/components/admin-settings/admin-settings.component.html
  38. 8
      apps/client/src/app/components/admin-settings/admin-settings.module.ts
  39. 85
      apps/client/src/app/components/admin-tag/admin-tag.component.html
  40. 5
      apps/client/src/app/components/admin-tag/admin-tag.component.scss
  41. 204
      apps/client/src/app/components/admin-tag/admin-tag.component.ts
  42. 26
      apps/client/src/app/components/admin-tag/admin-tag.module.ts
  43. 30
      apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/create-or-update-tag-dialog.component.ts
  44. 23
      apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/create-or-update-tag-dialog.html
  45. 23
      apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/create-or-update-tag-dialog.module.ts
  46. 0
      apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/create-or-update-tag-dialog.scss
  47. 5
      apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/interfaces/interfaces.ts
  48. 2
      apps/client/src/app/components/header/header.component.html
  49. 4
      apps/client/src/app/components/home-market/home-market.html
  50. 5
      apps/client/src/app/components/portfolio-performance/portfolio-performance.module.ts
  51. 8
      apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.html
  52. 0
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
  53. 0
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html
  54. 0
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.module.ts
  55. 7
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.scss
  56. 0
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/interfaces/interfaces.ts
  57. 146
      apps/client/src/app/components/user-account-access/user-account-access.component.ts
  58. 17
      apps/client/src/app/components/user-account-access/user-account-access.html
  59. 23
      apps/client/src/app/components/user-account-access/user-account-access.module.ts
  60. 12
      apps/client/src/app/components/user-account-access/user-account-access.scss
  61. 160
      apps/client/src/app/components/user-account-membership/user-account-membership.component.ts
  62. 69
      apps/client/src/app/components/user-account-membership/user-account-membership.html
  63. 23
      apps/client/src/app/components/user-account-membership/user-account-membership.module.ts
  64. 8
      apps/client/src/app/components/user-account-membership/user-account-membership.scss
  65. 258
      apps/client/src/app/components/user-account-settings/user-account-settings.component.ts
  66. 197
      apps/client/src/app/components/user-account-settings/user-account-settings.html
  67. 30
      apps/client/src/app/components/user-account-settings/user-account-settings.module.ts
  68. 13
      apps/client/src/app/components/user-account-settings/user-account-settings.scss
  69. 2
      apps/client/src/app/directives/file-drop/file-drop.directive.ts
  70. 37
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts
  71. 43
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html
  72. 14
      apps/client/src/app/pages/blog/2023/09/hacktoberfest-2023/hacktoberfest-2023-page.component.ts
  73. 194
      apps/client/src/app/pages/blog/2023/09/hacktoberfest-2023/hacktoberfest-2023-page.html
  74. 9
      apps/client/src/app/pages/blog/blog-page-routing.module.ts
  75. 24
      apps/client/src/app/pages/blog/blog-page.html
  76. 1
      apps/client/src/app/pages/features/features-page.component.ts
  77. 2
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html
  78. 3
      apps/client/src/app/pages/portfolio/fire/fire-page.component.ts
  79. 25
      apps/client/src/app/pages/portfolio/fire/fire-page.html
  80. 2
      apps/client/src/app/pages/public/public-page.html
  81. 18
      apps/client/src/app/pages/resources/personal-finance-tools/products.ts
  82. 31
      apps/client/src/app/pages/resources/personal-finance-tools/products/finary-page.component.ts
  83. 31
      apps/client/src/app/pages/resources/personal-finance-tools/products/stockle-page.component.ts
  84. 20
      apps/client/src/app/pages/user-account/user-account-page-routing.module.ts
  85. 433
      apps/client/src/app/pages/user-account/user-account-page.component.ts
  86. 336
      apps/client/src/app/pages/user-account/user-account-page.html
  87. 38
      apps/client/src/app/pages/user-account/user-account-page.module.ts
  88. 12
      apps/client/src/app/pages/user-account/user-account-page.scss
  89. 20
      apps/client/src/app/services/admin.service.ts
  90. 20
      apps/client/src/app/services/data.service.ts
  91. 16
      apps/client/src/app/services/import-activities.service.ts
  92. BIN
      apps/client/src/assets/fonts/Inter-Black.woff
  93. BIN
      apps/client/src/assets/fonts/Inter-Black.woff2
  94. BIN
      apps/client/src/assets/fonts/Inter-BlackItalic.woff
  95. BIN
      apps/client/src/assets/fonts/Inter-BlackItalic.woff2
  96. BIN
      apps/client/src/assets/fonts/Inter-Bold.woff
  97. BIN
      apps/client/src/assets/fonts/Inter-Bold.woff2
  98. BIN
      apps/client/src/assets/fonts/Inter-BoldItalic.woff
  99. BIN
      apps/client/src/assets/fonts/Inter-BoldItalic.woff2
  100. BIN
      apps/client/src/assets/fonts/Inter-ExtraBold.woff

53
CHANGELOG.md

@ -9,8 +9,61 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added support for notes in the activities import
- Added the application version to the endpoint `GET api/v1/admin`
- Introduced a carousel component for the testimonial section on the landing page
### Fixed
- Fixed the style of the active features page in the navigation on desktop
## 2.8.0 - 2023-10-03
### Added
- Supported enter key press to submit the form of the create or update account dialog
- Added the application version to the admin control panel
- Added pagination parameters (`skip`, `take`) to the endpoint `GET api/v1/order`
### Changed
- Harmonized the settings icon of the user account page
- Improved the usability to set an asset profile as a benchmark
- Reload platforms after making a change in the admin control panel
- Reload tags after making a change in the admin control panel
### Fixed
- Fixed the sidebar navigation on the user account page
## 2.7.0 - 2023-09-30
### Added
- Added a new static portfolio analysis rule: Emergency fund setup
- Added tabs to the user account page
### Changed
- Set up the _Inter_ font family
- Upgraded `yahoo-finance2` from version `2.7.0` to `2.8.0`
### Fixed
- Fixed a link on the features page
## 2.6.0 - 2023-09-26
### Added
- Added the management of tags in the admin control panel
- Added a blog post: _Hacktoberfest 2023_
### Changed
- Upgraded `prettier` from version `3.0.2` to `3.0.3`
- Upgraded `yahoo-finance2` from version `2.5.0` to `2.7.0`
## 2.5.0 - 2023-09-23
### Added

6
DEVELOPMENT.md

@ -18,6 +18,12 @@
### Prisma
#### Access database via GUI
Run `yarn database:gui`
https://www.prisma.io/studio
#### Synchronize schema with database for prototyping
Run `yarn database:push`

2
apps/api/src/app/account/create-account.dto.ts

@ -12,7 +12,7 @@ import { isString } from 'lodash';
export class CreateAccountDto {
@IsOptional()
@IsString()
accountType: AccountType;
accountType?: AccountType;
@IsNumber()
balance: number;

2
apps/api/src/app/account/update-account.dto.ts

@ -12,7 +12,7 @@ import { isString } from 'lodash';
export class UpdateAccountDto {
@IsOptional()
@IsString()
accountType: AccountType;
accountType?: AccountType;
@IsNumber()
balance: number;

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

@ -1,4 +1,5 @@
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { environment } from '@ghostfolio/api/environments/environment';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
@ -97,7 +98,8 @@ export class AdminService {
settings: await this.propertyService.get(),
transactionCount: await this.prismaService.order.count(),
userCount: await this.prismaService.user.count(),
users: await this.getUsersWithAnalytics()
users: await this.getUsersWithAnalytics(),
version: environment.version
};
}

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

@ -39,6 +39,7 @@ import { RedisCacheModule } from './redis-cache/redis-cache.module';
import { SitemapModule } from './sitemap/sitemap.module';
import { SubscriptionModule } from './subscription/subscription.module';
import { SymbolModule } from './symbol/symbol.module';
import { TagModule } from './tag/tag.module';
import { UserModule } from './user/user.module';
@Module({
@ -101,6 +102,7 @@ import { UserModule } from './user/user.module';
SitemapModule,
SubscriptionModule,
SymbolModule,
TagModule,
TwitterBotModule,
UserModule
],

85
apps/api/src/app/benchmark/benchmark.controller.ts

@ -10,6 +10,7 @@ import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Delete,
Get,
HttpException,
Inject,
@ -32,35 +33,49 @@ export class BenchmarkController {
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Get()
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getBenchmark(): Promise<BenchmarkResponse> {
return {
benchmarks: await this.benchmarkService.getBenchmarks()
};
}
@Get(':dataSource/:symbol/:startDateString')
@Post()
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getBenchmarkMarketDataBySymbol(
@Param('dataSource') dataSource: DataSource,
@Param('startDateString') startDateString: string,
@Param('symbol') symbol: string
): Promise<BenchmarkMarketDataDetails> {
const startDate = new Date(startDateString);
public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.benchmarkService.getMarketDataBySymbol({
try {
const benchmark = await this.benchmarkService.addBenchmark({
dataSource,
startDate,
symbol
});
if (!benchmark) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
@Post()
return benchmark;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
@Delete(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) {
public async deleteBenchmark(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
) {
if (
!hasPermission(
this.request.user.permissions,
@ -74,7 +89,7 @@ export class BenchmarkController {
}
try {
const benchmark = await this.benchmarkService.addBenchmark({
const benchmark = await this.benchmarkService.deleteBenchmark({
dataSource,
symbol
});
@ -94,4 +109,30 @@ export class BenchmarkController {
);
}
}
@Get()
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getBenchmark(): Promise<BenchmarkResponse> {
return {
benchmarks: await this.benchmarkService.getBenchmarks()
};
}
@Get(':dataSource/:symbol/:startDateString')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getBenchmarkMarketDataBySymbol(
@Param('dataSource') dataSource: DataSource,
@Param('startDateString') startDateString: string,
@Param('symbol') symbol: string
): Promise<BenchmarkMarketDataDetails> {
const startDate = new Date(startDateString);
return this.benchmarkService.getMarketDataBySymbol({
dataSource,
startDate,
symbol
});
}
}

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

@ -245,6 +245,43 @@ export class BenchmarkService {
};
}
public async deleteBenchmark({
dataSource,
symbol
}: UniqueAsset): Promise<Partial<SymbolProfile>> {
const assetProfile = await this.prismaService.symbolProfile.findFirst({
where: {
dataSource,
symbol
}
});
if (!assetProfile) {
return null;
}
let benchmarks =
((await this.propertyService.getByKey(
PROPERTY_BENCHMARKS
)) as BenchmarkProperty[]) ?? [];
benchmarks = benchmarks.filter(({ symbolProfileId }) => {
return symbolProfileId !== assetProfile.id;
});
await this.propertyService.put({
key: PROPERTY_BENCHMARKS,
value: JSON.stringify(benchmarks)
});
return {
dataSource,
symbol,
id: assetProfile.id,
name: assetProfile.name
};
}
private getMarketCondition(aPerformanceInPercent: number) {
return aPerformanceInPercent <= -0.2 ? 'BEAR_MARKET' : 'NEUTRAL_MARKET';
}

6
apps/api/src/app/order/order.controller.ts

@ -89,7 +89,9 @@ export class OrderController {
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('tags') filterByTags?: string
@Query('skip') skip?: number,
@Query('tags') filterByTags?: string,
@Query('take') take?: number
): Promise<Activities> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
@ -105,6 +107,8 @@ export class OrderController {
filters,
userCurrency,
includeDrafts: true,
skip: isNaN(skip) ? undefined : skip,
take: isNaN(take) ? undefined : take,
userId: impersonationUserId || this.request.user.id,
withExcludedAccounts: true
});

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

@ -230,6 +230,8 @@ export class OrderService {
public async getOrders({
filters,
includeDrafts = false,
skip,
take = Number.MAX_SAFE_INTEGER,
types,
userCurrency,
userId,
@ -237,6 +239,8 @@ export class OrderService {
}: {
filters?: Filter[];
includeDrafts?: boolean;
skip?: number;
take?: number;
types?: TypeOfOrder[];
userCurrency: string;
userId: string;
@ -315,6 +319,8 @@ export class OrderService {
return (
await this.orders({
skip,
take,
where,
include: {
// eslint-disable-next-line @typescript-eslint/naming-convention

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

@ -10,6 +10,7 @@ import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rule
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment';
import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment';
import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup';
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
@ -50,13 +51,13 @@ import { Inject, Injectable } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import {
Account,
Type as ActivityType,
AssetClass,
DataSource,
Order,
Platform,
Prisma,
Tag,
Type as ActivityType
Tag
} from '@prisma/client';
import Big from 'big.js';
import {
@ -1214,12 +1215,6 @@ export class PortfolioService {
userId
});
if (isEmpty(orders)) {
return {
rules: {}
};
}
const portfolioCalculator = new PortfolioCalculator({
currency: userCurrency,
currentRateService: this.currentRateService,
@ -1228,7 +1223,9 @@ export class PortfolioService {
portfolioCalculator.setTransactionPoints(transactionPoints);
const portfolioStart = parseDate(transactionPoints[0].date);
const portfolioStart = parseDate(
transactionPoints[0]?.date ?? format(new Date(), DATE_FORMAT)
);
const currentPositions =
await portfolioCalculator.getCurrentPositions(portfolioStart);
@ -1249,9 +1246,13 @@ export class PortfolioService {
userId
});
const userSettings = <UserSettings>this.request.user.Settings.settings;
return {
rules: {
accountClusterRisk: await this.rulesService.evaluate(
accountClusterRisk: isEmpty(orders)
? undefined
: await this.rulesService.evaluate(
[
new AccountClusterRiskCurrentInvestment(
this.exchangeRateDataService,
@ -1262,9 +1263,11 @@ export class PortfolioService {
accounts
)
],
<UserSettings>this.request.user.Settings.settings
userSettings
),
currencyClusterRisk: await this.rulesService.evaluate(
currencyClusterRisk: isEmpty(orders)
? undefined
: await this.rulesService.evaluate(
[
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
this.exchangeRateDataService,
@ -1275,7 +1278,16 @@ export class PortfolioService {
positions
)
],
<UserSettings>this.request.user.Settings.settings
userSettings
),
emergencyFund: await this.rulesService.evaluate(
[
new EmergencyFundSetup(
this.exchangeRateDataService,
userSettings.emergencyFund
)
],
userSettings
),
fees: await this.rulesService.evaluate(
[
@ -1285,7 +1297,7 @@ export class PortfolioService {
this.getFees({ userCurrency, activities: orders }).toNumber()
)
],
<UserSettings>this.request.user.Settings.settings
userSettings
)
}
};

6
apps/api/src/app/tag/create-tag.dto.ts

@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class CreateTagDto {
@IsString()
name: string;
}

104
apps/api/src/app/tag/tag.controller.ts

@ -0,0 +1,104 @@
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Delete,
Get,
HttpException,
Inject,
Param,
Post,
Put,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Tag } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreateTagDto } from './create-tag.dto';
import { TagService } from './tag.service';
import { UpdateTagDto } from './update-tag.dto';
@Controller('tag')
export class TagController {
public constructor(
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly tagService: TagService
) {}
@Get()
@UseGuards(AuthGuard('jwt'))
public async getTags() {
return this.tagService.getTagsWithActivityCount();
}
@Post()
@UseGuards(AuthGuard('jwt'))
public async createTag(@Body() data: CreateTagDto): Promise<Tag> {
if (!hasPermission(this.request.user.permissions, permissions.createTag)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.tagService.createTag(data);
}
@Put(':id')
@UseGuards(AuthGuard('jwt'))
public async updateTag(@Param('id') id: string, @Body() data: UpdateTagDto) {
if (!hasPermission(this.request.user.permissions, permissions.updateTag)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalTag = await this.tagService.getTag({
id
});
if (!originalTag) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.tagService.updateTag({
data: {
...data
},
where: {
id
}
});
}
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
public async deleteTag(@Param('id') id: string) {
if (!hasPermission(this.request.user.permissions, permissions.deleteTag)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalTag = await this.tagService.getTag({
id
});
if (!originalTag) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.tagService.deleteTag({ id });
}
}

13
apps/api/src/app/tag/tag.module.ts

@ -0,0 +1,13 @@
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common';
import { TagController } from './tag.controller';
import { TagService } from './tag.service';
@Module({
controllers: [TagController],
exports: [TagService],
imports: [PrismaModule],
providers: [TagService]
})
export class TagModule {}

79
apps/api/src/app/tag/tag.service.ts

@ -0,0 +1,79 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Injectable } from '@nestjs/common';
import { Prisma, Tag } from '@prisma/client';
@Injectable()
export class TagService {
public constructor(private readonly prismaService: PrismaService) {}
public async createTag(data: Prisma.TagCreateInput) {
return this.prismaService.tag.create({
data
});
}
public async deleteTag(where: Prisma.TagWhereUniqueInput): Promise<Tag> {
return this.prismaService.tag.delete({ where });
}
public async getTag(
tagWhereUniqueInput: Prisma.TagWhereUniqueInput
): Promise<Tag> {
return this.prismaService.tag.findUnique({
where: tagWhereUniqueInput
});
}
public async getTags({
cursor,
orderBy,
skip,
take,
where
}: {
cursor?: Prisma.TagWhereUniqueInput;
orderBy?: Prisma.TagOrderByWithRelationInput;
skip?: number;
take?: number;
where?: Prisma.TagWhereInput;
} = {}) {
return this.prismaService.tag.findMany({
cursor,
orderBy,
skip,
take,
where
});
}
public async getTagsWithActivityCount() {
const tagsWithOrderCount = await this.prismaService.tag.findMany({
include: {
_count: {
select: { orders: true }
}
}
});
return tagsWithOrderCount.map(({ _count, id, name }) => {
return {
id,
name,
activityCount: _count.orders
};
});
}
public async updateTag({
data,
where
}: {
data: Prisma.TagUpdateInput;
where: Prisma.TagWhereUniqueInput;
}): Promise<Tag> {
return this.prismaService.tag.update({
data,
where
});
}
}

9
apps/api/src/app/tag/update-tag.dto.ts

@ -0,0 +1,9 @@
import { IsString } from 'class-validator';
export class UpdateTagDto {
@IsString()
id: string;
@IsString()
name: string;
}

36
apps/api/src/assets/sitemap.xml

@ -78,6 +78,10 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-exirio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-finary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-folishare</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -146,6 +150,10 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-snowball-analytics</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-stockle</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-stockmarketeye</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -262,6 +270,10 @@
<loc>https://ghostfol.io/en/blog/2023/09/ghostfolio-2</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/09/hacktoberfest-2023</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/faq</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -320,6 +332,10 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-exirio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-finary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-folishare</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -388,6 +404,10 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-snowball-analytics</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-stockle</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-stockmarketeye</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -594,6 +614,10 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-exirio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-finary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-folishare</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -662,6 +686,10 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-snowball-analytics</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-stockle</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-stockmarketeye</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -714,6 +742,10 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-exirio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-finary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-folishare</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -782,6 +814,10 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-snowball-analytics</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-stockle</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-stockmarketeye</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>

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

@ -3,7 +3,7 @@ import { cloneDeep, isArray, isObject } from 'lodash';
export function hasNotDefinedValuesInObject(aObject: Object): boolean {
for (const key in aObject) {
if (aObject[key] === null || aObject[key] === null) {
if (aObject[key] === null || aObject[key] === undefined) {
return true;
} else if (isObject(aObject[key])) {
return hasNotDefinedValuesInObject(aObject[key]);

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

@ -80,6 +80,10 @@ const locales = {
'/en/blog/2023/09/ghostfolio-2': {
featureGraphicPath: 'assets/images/blog/ghostfolio-2.jpg',
title: `Announcing Ghostfolio 2.0 - ${titleShort}`
},
'/en/blog/2023/09/hacktoberfest-2023': {
featureGraphicPath: 'assets/images/blog/hacktoberfest-2023.png',
title: `Hacktoberfest 2023 - ${titleShort}`
}
};

9
apps/api/src/models/rules/account-cluster-risk/current-investment.ts

@ -1,4 +1,5 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import {
PortfolioDetails,
@ -6,16 +7,18 @@ import {
UserSettings
} from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule';
export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
private accounts: PortfolioDetails['accounts'];
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
private accounts: PortfolioDetails['accounts']
accounts: PortfolioDetails['accounts']
) {
super(exchangeRateDataService, {
name: 'Investment'
});
this.accounts = accounts;
}
public evaluate(ruleSettings: Settings) {

9
apps/api/src/models/rules/account-cluster-risk/single-account.ts

@ -1,17 +1,20 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioDetails, UserSettings } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule';
export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
private accounts: PortfolioDetails['accounts'];
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
private accounts: PortfolioDetails['accounts']
accounts: PortfolioDetails['accounts']
) {
super(exchangeRateDataService, {
name: 'Single Account'
});
this.accounts = accounts;
}
public evaluate() {

9
apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts

@ -1,17 +1,20 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule';
export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Settings> {
private positions: TimelinePosition[];
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
private positions: TimelinePosition[]
positions: TimelinePosition[]
) {
super(exchangeRateDataService, {
name: 'Investment: Base Currency'
});
this.positions = positions;
}
public evaluate(ruleSettings: Settings) {

9
apps/api/src/models/rules/currency-cluster-risk/current-investment.ts

@ -1,17 +1,20 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule';
export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
private positions: TimelinePosition[];
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
private positions: TimelinePosition[]
positions: TimelinePosition[]
) {
super(exchangeRateDataService, {
name: 'Investment'
});
this.positions = positions;
}
public evaluate(ruleSettings: Settings) {

46
apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts

@ -0,0 +1,46 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { UserSettings } from '@ghostfolio/common/interfaces';
export class EmergencyFundSetup extends Rule<Settings> {
private emergencyFund: number;
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
emergencyFund: number
) {
super(exchangeRateDataService, {
name: 'Emergency Fund: Set up'
});
this.emergencyFund = emergencyFund;
}
public evaluate(ruleSettings: Settings) {
if (this.emergencyFund > ruleSettings.threshold) {
return {
evaluation: 'An emergency fund has been set up',
value: true
};
}
return {
evaluation: 'No emergency fund has been set up',
value: false
};
}
public getSettings(aUserSettings: UserSettings): Settings {
return {
baseCurrency: aUserSettings.baseCurrency,
isActive: true,
threshold: 0
};
}
}
interface Settings extends RuleSettings {
baseCurrency: string;
threshold: number;
}

19
apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts

@ -1,22 +1,29 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { UserSettings } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule';
export class FeeRatioInitialInvestment extends Rule<Settings> {
private fees: number;
private totalInvestment: number;
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
private totalInvestment: number,
private fees: number
totalInvestment: number,
fees: number
) {
super(exchangeRateDataService, {
name: 'Investment'
name: 'Fee Ratio'
});
this.fees = fees;
this.totalInvestment = totalInvestment;
}
public evaluate(ruleSettings: Settings) {
const feeRatio = this.fees / this.totalInvestment;
const feeRatio = this.totalInvestment
? this.fees / this.totalInvestment
: 0;
if (feeRatio > ruleSettings.threshold) {
return {

25
apps/client/project.json

@ -21,6 +21,7 @@
"tsConfig": "apps/client/tsconfig.app.json",
"assets": [],
"styles": [
"apps/client/src/assets/fonts/inter.css",
"apps/client/src/styles/theme.scss",
"apps/client/src/styles.scss"
],
@ -103,40 +104,40 @@
"options": {
"commands": [
{
"command": "mkdir -p dist/apps/client"
"command": "shx mkdir -p dist/apps/client"
},
{
"command": "cp -r apps/client/src/assets dist/apps/client"
"command": "shx cp -r apps/client/src/assets dist/apps/client"
},
{
"command": "cp -r apps/client/src/assets/.well-known dist/apps/client"
"command": "shx cp -r apps/client/src/assets/.well-known dist/apps/client"
},
{
"command": "cp apps/client/src/assets/favicon.ico dist/apps/client"
"command": "shx cp apps/client/src/assets/favicon.ico dist/apps/client"
},
{
"command": "cp apps/client/src/assets/index.html dist/apps/client"
"command": "shx cp apps/client/src/assets/index.html dist/apps/client"
},
{
"command": "cp apps/client/src/assets/robots.txt dist/apps/client"
"command": "shx cp apps/client/src/assets/robots.txt dist/apps/client"
},
{
"command": "cp apps/client/src/assets/site.webmanifest dist/apps/client"
"command": "shx cp apps/client/src/assets/site.webmanifest dist/apps/client"
},
{
"command": "cp node_modules/ionicons/dist/index.js dist/apps/client"
"command": "shx cp node_modules/ionicons/dist/index.js dist/apps/client"
},
{
"command": "cp node_modules/ionicons/dist/ionicons.js dist/apps/client"
"command": "shx cp node_modules/ionicons/dist/ionicons.js dist/apps/client"
},
{
"command": "cp -r node_modules/ionicons/dist/ionicons dist/apps/client/ionicons"
"command": "shx cp -r node_modules/ionicons/dist/ionicons dist/apps/client/ionicons"
},
{
"command": "cp CHANGELOG.md dist/apps/client/assets"
"command": "shx cp CHANGELOG.md dist/apps/client/assets"
},
{
"command": "cp LICENSE dist/apps/client/assets"
"command": "shx cp LICENSE dist/apps/client/assets"
}
]
}

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

@ -112,6 +112,7 @@ export class AppComponent implements OnDestroy, OnInit {
this.hasTabs =
(this.currentRoute === this.routerLinkAbout[0].slice(1) ||
this.currentRoute === 'account' ||
this.currentRoute === 'admin' ||
this.currentRoute === 'home' ||
this.currentRoute === 'portfolio' ||

5
apps/client/src/app/app.module.ts

@ -1,6 +1,6 @@
import { Platform } from '@angular/cdk/platform';
import { HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatChipsModule } from '@angular/material/chips';
import {
@ -35,6 +35,7 @@ export function NgxStripeFactory(): string {
}
@NgModule({
bootstrap: [AppComponent],
declarations: [AppComponent],
imports: [
AppRoutingModule,
@ -72,6 +73,6 @@ export function NgxStripeFactory(): string {
useFactory: NgxStripeFactory
}
],
bootstrap: [AppComponent]
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class AppModule {}

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

@ -13,7 +13,6 @@ import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import {
AdminMarketDataDetails,
ScraperConfiguration,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { translate } from '@ghostfolio/ui/i18n';
@ -146,9 +145,11 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
.postBenchmark({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
this.dataService.updateInfo();
this.isBenchmark = true;
this.changeDetectorRef.markForCheck();
});
}
@ -185,6 +186,19 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
});
}
public onUnsetBenchmark({ dataSource, symbol }: UniqueAsset) {
this.dataService
.deleteBenchmark({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.dataService.updateInfo();
this.isBenchmark = false;
this.changeDetectorRef.markForCheck();
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();

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

@ -37,13 +37,6 @@
>
<ng-container i18n>Gather Profile Data</ng-container>
</button>
<button
mat-menu-item
[disabled]="isBenchmark"
(click)="onSetBenchmark({dataSource: data.dataSource, symbol: data.symbol})"
>
<ng-container i18n>Set as Benchmark</ng-container>
</button>
</mat-menu>
</div>
@ -151,6 +144,17 @@
</ng-template>
</ng-container>
</div>
<div class="d-flex my-3">
<div class="w-50">
<mat-checkbox
color="primary"
i18n
[checked]="isBenchmark"
(change)="isBenchmark ? onUnsetBenchmark({dataSource: data.dataSource, symbol: data.symbol}) : onSetBenchmark({dataSource: data.dataSource, symbol: data.symbol})"
>Benchmark</mat-checkbox
>
</div>
</div>
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Symbol Mapping</mat-label>

2
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.module.ts

@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialogModule } from '@angular/material/dialog';
import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu';
@ -21,6 +22,7 @@ import { AssetProfileDialog } from './asset-profile-dialog.component';
GfPortfolioProportionChartModule,
GfValueModule,
MatButtonModule,
MatCheckboxModule,
MatDialogModule,
MatInputModule,
MatMenuModule,

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

@ -1,5 +1,6 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatCheckboxChange } from '@angular/material/checkbox';
import { environment } from '@ghostfolio/client/../environments/environment';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { CacheService } from '@ghostfolio/client/services/cache.service';
import { DataService } from '@ghostfolio/client/services/data.service';
@ -42,6 +43,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
public transactionCount: number;
public userCount: number;
public user: User;
public version = environment.version;
private unsubscribeSubject = new Subject<void>();

21
apps/client/src/app/components/admin-overview/admin-overview.html

@ -3,12 +3,16 @@
<div class="col">
<mat-card appearance="outlined" class="mb-3">
<mat-card-content>
<div class="d-flex my-3">
<div class="w-50" i18n>Version</div>
<div class="w-50">{{ version }}</div>
</div>
<div class="d-flex my-3">
<div class="w-50" i18n>User Count</div>
<div class="w-50">
<gf-value
precision="0"
[locale]="user?.settings?.locale"
[precision]="0"
[value]="userCount"
></gf-value>
</div>
@ -17,8 +21,8 @@
<div class="w-50" i18n>Activity Count</div>
<div class="w-50">
<gf-value
precision="0"
[locale]="user?.settings?.locale"
[precision]="0"
[value]="transactionCount"
></gf-value>
<div *ngIf="transactionCount && userCount">
@ -72,19 +76,6 @@
</div>
</div>
</div>
<div
*ngIf="info?.tags?.length > 0"
class="align-items-start d-flex my-3"
>
<div class="w-50" i18n>Tags</div>
<div class="w-50">
<table>
<tr *ngFor="let tag of info.tags">
<td class="pl-1">{{ tag.name }}</td>
</tr>
</table>
</div>
</div>
<div class="d-flex my-3">
<div class="w-50" i18n>User Signup</div>
<div class="w-50">

6
apps/client/src/app/components/admin-platform/admin-platform.component.ts

@ -13,13 +13,14 @@ import { ActivatedRoute, Router } from '@angular/router';
import { CreatePlatformDto } from '@ghostfolio/api/app/platform/create-platform.dto';
import { UpdatePlatformDto } from '@ghostfolio/api/app/platform/update-platform.dto';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { Platform } from '@prisma/client';
import { get } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, takeUntil } from 'rxjs';
import { CreateOrUpdatePlatformDialog } from './create-or-update-platform-dialog/create-or-update-account-platform.component';
import { CreateOrUpdatePlatformDialog } from './create-or-update-platform-dialog/create-or-update-platform-dialog.component';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -40,6 +41,7 @@ export class AdminPlatformComponent implements OnInit, OnDestroy {
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private route: ActivatedRoute,
@ -119,6 +121,8 @@ export class AdminPlatformComponent implements OnInit, OnDestroy {
this.dataSource.sort = this.sort;
this.dataSource.sortingDataAccessor = get;
this.dataService.updateInfo();
this.changeDetectorRef.markForCheck();
});
}

0
apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-account-platform.component.ts → apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-platform-dialog.component.ts

2
apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-platform-dialog.module.ts

@ -6,7 +6,7 @@ import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { CreateOrUpdatePlatformDialog } from './create-or-update-account-platform.component';
import { CreateOrUpdatePlatformDialog } from './create-or-update-platform-dialog.component';
@NgModule({
declarations: [CreateOrUpdatePlatformDialog],

5
apps/client/src/app/components/admin-settings/admin-settings.component.html

@ -2,14 +2,13 @@
<div class="mb-5 row">
<div class="col">
<h2 class="text-center" i18n>Platforms</h2>
<gf-admin-platform></gf-admin-platform>
<gf-admin-platform />
</div>
</div>
<!--
<div class="row">
<div class="col">
<h2 class="text-center" i18n>Tags</h2>
<gf-admin-tag />
</div>
</div>
-->
</div>

8
apps/client/src/app/components/admin-settings/admin-settings.module.ts

@ -2,12 +2,18 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { GfAdminPlatformModule } from '@ghostfolio/client/components/admin-platform/admin-platform.module';
import { GfAdminTagModule } from '@ghostfolio/client/components/admin-tag/admin-tag.module';
import { AdminSettingsComponent } from './admin-settings.component';
@NgModule({
declarations: [AdminSettingsComponent],
imports: [CommonModule, GfAdminPlatformModule, RouterModule],
imports: [
CommonModule,
GfAdminPlatformModule,
GfAdminTagModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfAdminSettingsModule {}

85
apps/client/src/app/components/admin-tag/admin-tag.component.html

@ -0,0 +1,85 @@
<div class="container">
<div class="row">
<div class="col">
<div class="d-flex justify-content-end">
<a
color="primary"
i18n
mat-flat-button
[queryParams]="{ createTagDialog: true }"
[routerLink]="[]"
>
Add Tag
</a>
</div>
<table
class="gf-table w-100"
mat-table
matSort
matSortActive="name"
matSortDirection="asc"
[dataSource]="dataSource"
>
<ng-container matColumnDef="name">
<th
*matHeaderCellDef
class="px-1"
mat-header-cell
mat-sort-header="name"
>
<ng-container i18n>Name</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.name }}
</td>
</ng-container>
<ng-container matColumnDef="activities">
<th
*matHeaderCellDef
class="px-1"
mat-header-cell
mat-sort-header="activityCount"
>
<ng-container i18n>Activities</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.activityCount }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th
*matHeaderCellDef
class="px-1 text-center"
i18n
mat-header-cell
></th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="tagMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-horizontal"></ion-icon>
</button>
<mat-menu #tagMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onUpdateTag(element)">
<ion-icon class="mr-2" name="create-outline"></ion-icon>
<span i18n>Edit</span>
</button>
<button mat-menu-item (click)="onDeleteTag(element.id)">
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete</span>
</button>
</mat-menu>
</td>
</ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table>
</div>
</div>
</div>

5
apps/client/src/app/components/admin-tag/admin-tag.component.scss

@ -0,0 +1,5 @@
@import 'apps/client/src/styles/ghostfolio-style';
:host {
display: block;
}

204
apps/client/src/app/components/admin-tag/admin-tag.component.ts

@ -0,0 +1,204 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
OnDestroy,
OnInit,
ViewChild
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router';
import { CreateTagDto } from '@ghostfolio/api/app/tag/create-tag.dto';
import { UpdateTagDto } from '@ghostfolio/api/app/tag/update-tag.dto';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { Tag } from '@prisma/client';
import { get } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, takeUntil } from 'rxjs';
import { CreateOrUpdateTagDialog } from './create-or-update-tag-dialog/create-or-update-tag-dialog.component';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-admin-tag',
styleUrls: ['./admin-tag.component.scss'],
templateUrl: './admin-tag.component.html'
})
export class AdminTagComponent implements OnInit, OnDestroy {
@ViewChild(MatSort) sort: MatSort;
public dataSource: MatTableDataSource<Tag> = new MatTableDataSource();
public deviceType: string;
public displayedColumns = ['name', 'activities', 'actions'];
public tags: Tag[];
private unsubscribeSubject = new Subject<void>();
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private route: ActivatedRoute,
private router: Router,
private userService: UserService
) {
this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (params['createTagDialog']) {
this.openCreateTagDialog();
} else if (params['editTagDialog']) {
if (this.tags) {
const tag = this.tags.find(({ id }) => {
return id === params['tagId'];
});
this.openUpdateTagDialog(tag);
} else {
this.router.navigate(['.'], { relativeTo: this.route });
}
}
});
}
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.fetchTags();
}
public onDeleteTag(aId: string) {
const confirmation = confirm(
$localize`Do you really want to delete this tag?`
);
if (confirmation) {
this.deleteTag(aId);
}
}
public onUpdateTag({ id }: Tag) {
this.router.navigate([], {
queryParams: { editTagDialog: true, tagId: id }
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private deleteTag(aId: string) {
this.adminService
.deleteTag(aId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
this.fetchTags();
}
});
}
private fetchTags() {
this.adminService
.fetchTags()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((tags) => {
this.tags = tags;
this.dataSource = new MatTableDataSource(this.tags);
this.dataSource.sort = this.sort;
this.dataSource.sortingDataAccessor = get;
this.dataService.updateInfo();
this.changeDetectorRef.markForCheck();
});
}
private openCreateTagDialog() {
const dialogRef = this.dialog.open(CreateOrUpdateTagDialog, {
data: {
tag: {
name: null
}
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
const tag: CreateTagDto = data?.tag;
if (tag) {
this.adminService
.postTag(tag)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
this.fetchTags();
}
});
}
this.router.navigate(['.'], { relativeTo: this.route });
});
}
private openUpdateTagDialog({ id, name }) {
const dialogRef = this.dialog.open(CreateOrUpdateTagDialog, {
data: {
tag: {
id,
name
}
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
const tag: UpdateTagDto = data?.tag;
if (tag) {
this.adminService
.putTag(tag)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
this.fetchTags();
}
});
}
this.router.navigate(['.'], { relativeTo: this.route });
});
}
}

26
apps/client/src/app/components/admin-tag/admin-tag.module.ts

@ -0,0 +1,26 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router';
import { AdminTagComponent } from './admin-tag.component';
import { GfCreateOrUpdateTagDialogModule } from './create-or-update-tag-dialog/create-or-update-tag-dialog.module';
@NgModule({
declarations: [AdminTagComponent],
exports: [AdminTagComponent],
imports: [
CommonModule,
GfCreateOrUpdateTagDialogModule,
MatButtonModule,
MatMenuModule,
MatSortModule,
MatTableModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfAdminTagModule {}

30
apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/create-or-update-tag-dialog.component.ts

@ -0,0 +1,30 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Subject } from 'rxjs';
import { CreateOrUpdateTagDialogParams } from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'h-100' },
selector: 'gf-create-or-update-tag-dialog',
styleUrls: ['./create-or-update-tag-dialog.scss'],
templateUrl: 'create-or-update-tag-dialog.html'
})
export class CreateOrUpdateTagDialog {
private unsubscribeSubject = new Subject<void>();
public constructor(
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateTagDialogParams,
public dialogRef: MatDialogRef<CreateOrUpdateTagDialog>
) {}
public onCancel() {
this.dialogRef.close();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

23
apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/create-or-update-tag-dialog.html

@ -0,0 +1,23 @@
<form #addTagForm="ngForm" class="d-flex flex-column h-100">
<h1 *ngIf="data.tag.id" i18n mat-dialog-title>Update tag</h1>
<h1 *ngIf="!data.tag.id" i18n mat-dialog-title>Add tag</h1>
<div class="flex-grow-1 py-3" mat-dialog-content>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Name</mat-label>
<input matInput name="name" required [(ngModel)]="data.tag.name" />
</mat-form-field>
</div>
</div>
<div class="justify-content-end" mat-dialog-actions>
<button i18n mat-button (click)="onCancel()">Cancel</button>
<button
color="primary"
mat-flat-button
[disabled]="!addTagForm.form.valid"
[mat-dialog-close]="data"
>
<ng-container i18n>Save</ng-container>
</button>
</div>
</form>

23
apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/create-or-update-tag-dialog.module.ts

@ -0,0 +1,23 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { CreateOrUpdateTagDialog } from './create-or-update-tag-dialog.component';
@NgModule({
declarations: [CreateOrUpdateTagDialog],
imports: [
CommonModule,
FormsModule,
MatButtonModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule
]
})
export class GfCreateOrUpdateTagDialogModule {}

0
apps/client/src/app/pages/user-account/create-or-update-access-dialog/create-or-update-access-dialog.scss → apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/create-or-update-tag-dialog.scss

5
apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/interfaces/interfaces.ts

@ -0,0 +1,5 @@
import { Tag } from '@prisma/client';
export interface CreateOrUpdateTagDialogParams {
tag: Tag;
}

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

@ -272,7 +272,7 @@
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === routeFeatures,
'text-decoration-underline': currentRoute === routeFeatuers
'text-decoration-underline': currentRoute === routeFeatures
}"
[routerLink]="routerLinkFeatures"
>Features</a

4
apps/client/src/app/components/home-market/home-market.html

@ -8,15 +8,15 @@
<gf-line-chart
class="mb-3"
symbol="Fear & Greed Index"
yMax="100"
yMin="0"
[colorScheme]="user?.settings?.colorScheme"
[historicalDataItems]="historicalDataItems"
[isAnimated]="true"
[locale]="user?.settings?.locale"
[showXAxis]="true"
[showYAxis]="true"
[yMax]="100"
[yMaxLabel]="greedLabel"
[yMin]="0"
[yMinLabel]="fearLabel"
></gf-line-chart>
<gf-fear-and-greed-index

5
apps/client/src/app/components/portfolio-performance/portfolio-performance.module.ts

@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -8,6 +8,7 @@ import { PortfolioPerformanceComponent } from './portfolio-performance.component
@NgModule({
declarations: [PortfolioPerformanceComponent],
exports: [PortfolioPerformanceComponent],
imports: [CommonModule, GfValueModule, NgxSkeletonLoaderModule]
imports: [CommonModule, GfValueModule, NgxSkeletonLoaderModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfPortfolioPerformanceModule {}

8
apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.html

@ -213,11 +213,11 @@
<div class="col-md-6 mb-3">
<div class="h5" i18n>Sectors</div>
<gf-portfolio-proportion-chart
[baseCurrency]="user?.settings?.baseCurrency"
[baseCurrency]="data.baseCurrency"
[colorScheme]="data.colorScheme"
[isInPercent]="true"
[keys]="['name']"
[locale]="user?.settings?.locale"
[locale]="data.locale"
[maxItems]="10"
[positions]="sectors"
></gf-portfolio-proportion-chart>
@ -225,11 +225,11 @@
<div class="col-md-6 mb-3">
<div class="h5" i18n>Countries</div>
<gf-portfolio-proportion-chart
[baseCurrency]="user?.settings?.baseCurrency"
[baseCurrency]="data.baseCurrency"
[colorScheme]="data.colorScheme"
[isInPercent]="true"
[keys]="['name']"
[locale]="user?.settings?.locale"
[locale]="data.locale"
[maxItems]="10"
[positions]="countries"
></gf-portfolio-proportion-chart>

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

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

0
apps/client/src/app/pages/user-account/create-or-update-access-dialog/create-or-update-access-dialog.module.ts → apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.module.ts

7
apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.scss

@ -0,0 +1,7 @@
:host {
display: block;
.mat-mdc-dialog-content {
max-height: unset;
}
}

0
apps/client/src/app/pages/user-account/create-or-update-access-dialog/interfaces/interfaces.ts → apps/client/src/app/components/user-account-access/create-or-update-access-dialog/interfaces/interfaces.ts

146
apps/client/src/app/components/user-account-access/user-account-access.component.ts

@ -0,0 +1,146 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
OnDestroy,
OnInit
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { Access, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { CreateOrUpdateAccessDialog } from './create-or-update-access-dialog/create-or-update-access-dialog.component';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-user-account-access',
styleUrls: ['./user-account-access.scss'],
templateUrl: './user-account-access.html'
})
export class UserAccountAccessComponent implements OnDestroy, OnInit {
public accesses: Access[];
public deviceType: string;
public hasPermissionToCreateAccess: boolean;
public hasPermissionToDeleteAccess: boolean;
public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private route: ActivatedRoute,
private router: Router,
private userService: UserService
) {
const { globalPermissions } = this.dataService.fetchInfo();
this.hasPermissionToDeleteAccess = hasPermission(
globalPermissions,
permissions.deleteAccess
);
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.hasPermissionToCreateAccess = hasPermission(
this.user.permissions,
permissions.createAccess
);
this.hasPermissionToDeleteAccess = hasPermission(
this.user.permissions,
permissions.deleteAccess
);
this.changeDetectorRef.markForCheck();
}
});
this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (params['createDialog']) {
this.openCreateAccessDialog();
}
});
}
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.update();
}
public onDeleteAccess(aId: string) {
this.dataService
.deleteAccess(aId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.update();
}
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private openCreateAccessDialog(): void {
const dialogRef = this.dialog.open(CreateOrUpdateAccessDialog, {
data: {
access: {
alias: '',
type: 'PUBLIC'
}
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data: any) => {
const access: CreateAccessDto = data?.access;
if (access) {
this.dataService
.postAccess({ alias: access.alias })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.update();
}
});
}
this.router.navigate(['.'], { relativeTo: this.route });
});
}
private update() {
this.dataService
.fetchAccesses()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((accesses) => {
this.accesses = accesses;
this.changeDetectorRef.markForCheck();
});
}
}

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

@ -0,0 +1,17 @@
<div class="container">
<h1
class="align-items-center d-none d-sm-flex h3 justify-content-center mb-3 text-center"
>
<span i18n>Granted Access</span>
<gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator>
</h1>
<gf-access-table
[accesses]="accesses"
[hasPermissionToCreateAccess]="hasPermissionToCreateAccess"
[showActions]="hasPermissionToDeleteAccess"
(accessDeleted)="onDeleteAccess($event)"
></gf-access-table>
</div>

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

@ -0,0 +1,23 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { MatDialogModule } from '@angular/material/dialog';
import { RouterModule } from '@angular/router';
import { GfPortfolioAccessTableModule } from '@ghostfolio/client/components/access-table/access-table.module';
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';
@NgModule({
declarations: [UserAccountAccessComponent],
exports: [UserAccountAccessComponent],
imports: [
CommonModule,
GfCreateOrUpdateAccessDialogModule,
GfPortfolioAccessTableModule,
GfPremiumIndicatorModule,
MatDialogModule,
RouterModule
]
})
export class GfUserAccountAccessModule {}

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

@ -0,0 +1,12 @@
:host {
color: rgb(var(--dark-primary-text));
display: block;
gf-access-table {
overflow-x: auto;
}
}
:host-context(.is-dark-theme) {
color: rgb(var(--light-primary-text));
}

160
apps/client/src/app/components/user-account-membership/user-account-membership.component.ts

@ -0,0 +1,160 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
OnDestroy,
OnInit
} from '@angular/core';
import {
MatSnackBar,
MatSnackBarRef,
TextOnlySnackBar
} from '@angular/material/snack-bar';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { getDateFormatString } from '@ghostfolio/common/helper';
import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { StripeService } from 'ngx-stripe';
import { EMPTY, Subject } from 'rxjs';
import { catchError, switchMap, takeUntil } from 'rxjs/operators';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-user-account-membership',
styleUrls: ['./user-account-membership.scss'],
templateUrl: './user-account-membership.html'
})
export class UserAccountMembershipComponent implements OnDestroy, OnInit {
public baseCurrency: string;
public coupon: number;
public couponId: string;
public defaultDateFormat: string;
public hasPermissionForSubscription: boolean;
public hasPermissionToUpdateUserSettings: boolean;
public price: number;
public priceId: string;
public routerLinkPricing = ['/' + $localize`pricing`];
public snackBarRef: MatSnackBarRef<TextOnlySnackBar>;
public trySubscriptionMail =
'mailto:hi@ghostfol.io?Subject=Ghostfolio Premium Trial&body=Hello%0D%0DI am interested in Ghostfolio Premium. Can you please send me a coupon code to try it for some time?%0D%0DKind regards';
public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private snackBar: MatSnackBar,
private stripeService: StripeService,
private userService: UserService
) {
const { baseCurrency, globalPermissions, subscriptions } =
this.dataService.fetchInfo();
this.baseCurrency = baseCurrency;
this.hasPermissionForSubscription = hasPermission(
globalPermissions,
permissions.enableSubscription
);
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.defaultDateFormat = getDateFormatString(
this.user.settings.locale
);
this.hasPermissionToUpdateUserSettings = hasPermission(
this.user.permissions,
permissions.updateUserSettings
);
this.coupon = subscriptions?.[this.user.subscription.offer]?.coupon;
this.couponId =
subscriptions?.[this.user.subscription.offer]?.couponId;
this.price = subscriptions?.[this.user.subscription.offer]?.price;
this.priceId = subscriptions?.[this.user.subscription.offer]?.priceId;
this.changeDetectorRef.markForCheck();
}
});
}
public ngOnInit() {}
public onCheckout() {
this.dataService
.createCheckoutSession({ couponId: this.couponId, priceId: this.priceId })
.pipe(
switchMap(({ sessionId }: { sessionId: string }) => {
return this.stripeService.redirectToCheckout({ sessionId });
}),
catchError((error) => {
alert(error.message);
throw error;
})
)
.subscribe((result) => {
if (result.error) {
alert(result.error.message);
}
});
}
public onRedeemCoupon() {
let couponCode = prompt($localize`Please enter your coupon code:`);
couponCode = couponCode?.trim();
if (couponCode) {
this.dataService
.redeemCoupon(couponCode)
.pipe(
takeUntil(this.unsubscribeSubject),
catchError(() => {
this.snackBar.open(
'😞 ' + $localize`Could not redeem coupon code`,
undefined,
{
duration: 3000
}
);
return EMPTY;
})
)
.subscribe(() => {
this.snackBarRef = this.snackBar.open(
'✅ ' + $localize`Coupon code has been redeemed`,
$localize`Reload`,
{
duration: 3000
}
);
this.snackBarRef
.afterDismissed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
window.location.reload();
});
this.snackBarRef
.onAction()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
window.location.reload();
});
});
}
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

69
apps/client/src/app/components/user-account-membership/user-account-membership.html

@ -0,0 +1,69 @@
<div class="container">
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Membership</h1>
<div class="row">
<div class="col">
<div class="d-flex">
<div class="mx-auto">
<div class="align-items-center d-flex mb-1">
<a [routerLink]="routerLinkPricing"
>{{ user?.subscription?.type }}</a
>
<gf-premium-indicator
*ngIf="user?.subscription?.type === 'Premium'"
class="ml-1"
></gf-premium-indicator>
</div>
<div *ngIf="user?.subscription?.type === 'Premium'">
<ng-container i18n>Valid until</ng-container> {{
user?.subscription?.expiresAt | date: defaultDateFormat }}
</div>
<div *ngIf="user?.subscription?.type === 'Basic'">
<ng-container
*ngIf="hasPermissionForSubscription && hasPermissionToUpdateUserSettings"
>
<button color="primary" mat-flat-button (click)="onCheckout()">
<ng-container *ngIf="user.subscription.offer === 'default'" i18n
>Upgrade</ng-container
>
<ng-container *ngIf="user.subscription.offer === 'renewal'" i18n
>Renew</ng-container
>
</button>
<div *ngIf="price" class="mt-1">
<ng-container *ngIf="coupon"
><del class="text-muted"
>{{ baseCurrency }}&nbsp;{{ price }}</del
>&nbsp;{{ baseCurrency }}&nbsp;{{ price - coupon
}}</ng-container
>
<ng-container *ngIf="!coupon"
>{{ baseCurrency }}&nbsp;{{ price }}</ng-container
>&nbsp;<span i18n>per year</span>
</div>
</ng-container>
<a
*ngIf="!user?.subscription?.expiresAt"
class="mr-2 my-2"
mat-stroked-button
[href]="trySubscriptionMail"
><span i18n>Try Premium</span>
<gf-premium-indicator
class="d-inline-block ml-1"
[enableLink]="false"
></gf-premium-indicator
></a>
<a
*ngIf="hasPermissionToUpdateUserSettings"
class="mr-2 my-2"
i18n
mat-stroked-button
[routerLink]=""
(click)="onRedeemCoupon()"
>Redeem Coupon</a
>
</div>
</div>
</div>
</div>
</div>
</div>

23
apps/client/src/app/components/user-account-membership/user-account-membership.module.ts

@ -0,0 +1,23 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { RouterModule } from '@angular/router';
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
import { GfValueModule } from '@ghostfolio/ui/value';
import { UserAccountMembershipComponent } from './user-account-membership.component';
@NgModule({
declarations: [UserAccountMembershipComponent],
exports: [UserAccountMembershipComponent],
imports: [
CommonModule,
GfPremiumIndicatorModule,
GfValueModule,
MatButtonModule,
MatCardModule,
RouterModule
]
})
export class GfUserAccountMembershipModule {}

8
apps/client/src/app/components/user-account-membership/user-account-membership.scss

@ -0,0 +1,8 @@
:host {
color: rgb(var(--dark-primary-text));
display: block;
}
:host-context(.is-dark-theme) {
color: rgb(var(--light-primary-text));
}

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

@ -0,0 +1,258 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
OnDestroy,
OnInit,
ViewChild
} from '@angular/core';
import { MatCheckbox, MatCheckboxChange } from '@angular/material/checkbox';
import { DataService } from '@ghostfolio/client/services/data.service';
import {
STAY_SIGNED_IN,
SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
import { downloadAsFile } from '@ghostfolio/common/helper';
import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { format, parseISO } from 'date-fns';
import { uniq } from 'lodash';
import { EMPTY, Subject } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-user-account-settings',
styleUrls: ['./user-account-settings.scss'],
templateUrl: './user-account-settings.html'
})
export class UserAccountSettingsComponent implements OnDestroy, OnInit {
@ViewChild('toggleSignInWithFingerprintEnabledElement')
signInWithFingerprintElement: MatCheckbox;
public appearancePlaceholder = $localize`Auto`;
public baseCurrency: string;
public currencies: string[] = [];
public hasPermissionToUpdateViewMode: boolean;
public hasPermissionToUpdateUserSettings: boolean;
public language = document.documentElement.lang;
public locales = [
'de',
'de-CH',
'en-GB',
'en-US',
'es',
'fr',
'it',
'nl',
'pt',
'tr'
];
public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private settingsStorageService: SettingsStorageService,
private userService: UserService,
public webAuthnService: WebAuthnService
) {
const { baseCurrency, currencies } = this.dataService.fetchInfo();
this.baseCurrency = baseCurrency;
this.currencies = currencies;
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.hasPermissionToUpdateUserSettings = hasPermission(
this.user.permissions,
permissions.updateUserSettings
);
this.hasPermissionToUpdateViewMode = hasPermission(
this.user.permissions,
permissions.updateViewMode
);
this.locales.push(this.user.settings.locale);
this.locales = uniq(this.locales.sort());
this.changeDetectorRef.markForCheck();
}
});
}
public ngOnInit() {
this.update();
}
public onChangeUserSetting(aKey: string, aValue: string) {
this.dataService
.putUserSetting({ [aKey]: aValue })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService.remove();
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
this.changeDetectorRef.markForCheck();
if (aKey === 'language') {
if (aValue) {
window.location.href = `../${aValue}/account`;
} else {
window.location.href = `../`;
}
}
});
});
}
public onExperimentalFeaturesChange(aEvent: MatCheckboxChange) {
this.dataService
.putUserSetting({ isExperimentalFeatures: aEvent.checked })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService.remove();
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
this.changeDetectorRef.markForCheck();
});
});
}
public onExport() {
this.dataService
.fetchExport()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
for (const activity of data.activities) {
delete activity.id;
}
downloadAsFile({
content: data,
fileName: `ghostfolio-export-${format(
parseISO(data.meta.date),
'yyyyMMddHHmm'
)}.json`,
format: 'json'
});
});
}
public onRestrictedViewChange(aEvent: MatCheckboxChange) {
this.dataService
.putUserSetting({ isRestrictedView: aEvent.checked })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService.remove();
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
this.changeDetectorRef.markForCheck();
});
});
}
public onSignInWithFingerprintChange(aEvent: MatCheckboxChange) {
if (aEvent.checked) {
this.registerDevice();
} else {
const confirmation = confirm(
$localize`Do you really want to remove this sign in method?`
);
if (confirmation) {
this.deregisterDevice();
} else {
this.update();
}
}
}
public onViewModeChange(aEvent: MatCheckboxChange) {
this.dataService
.putUserSetting({ viewMode: aEvent.checked === true ? 'ZEN' : 'DEFAULT' })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService.remove();
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
this.changeDetectorRef.markForCheck();
});
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private deregisterDevice() {
this.webAuthnService
.deregister()
.pipe(
takeUntil(this.unsubscribeSubject),
catchError(() => {
this.update();
return EMPTY;
})
)
.subscribe(() => {
this.update();
});
}
private registerDevice() {
this.webAuthnService
.register()
.pipe(
takeUntil(this.unsubscribeSubject),
catchError(() => {
this.update();
return EMPTY;
})
)
.subscribe(() => {
this.settingsStorageService.removeSetting(STAY_SIGNED_IN);
this.update();
});
}
private update() {
if (this.signInWithFingerprintElement) {
this.signInWithFingerprintElement.checked =
this.webAuthnService.isEnabled() ?? false;
}
}
}

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

@ -0,0 +1,197 @@
<div class="container">
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Settings</h1>
<div class="row">
<div class="col">
<div class="align-items-center d-flex py-1">
<div class="pr-1 w-50">
<div i18n>Presenter View</div>
<div class="hint-text text-muted" i18n>
Protection for sensitive information like absolute performances and
quantity values
</div>
</div>
<div class="pl-1 w-50">
<mat-checkbox
color="primary"
[checked]="user.settings.isRestrictedView"
[disabled]="!hasPermissionToUpdateUserSettings"
(change)="onRestrictedViewChange($event)"
></mat-checkbox>
</div>
</div>
<div class="d-flex mt-4 py-1">
<form #changeUserSettingsForm="ngForm" class="w-100">
<div class="d-flex mb-2">
<div class="align-items-center d-flex pt-1 pt-1 w-50">
<ng-container i18n>Base Currency</ng-container>
</div>
<div class="pl-1 w-50">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-select
name="baseCurrency"
[disabled]="!hasPermissionToUpdateUserSettings"
[value]="user.settings.baseCurrency"
(selectionChange)="onChangeUserSetting('baseCurrency', $event.value)"
>
<mat-option
*ngFor="let currency of currencies"
[value]="currency"
>{{ currency }}</mat-option
>
</mat-select>
</mat-form-field>
</div>
</div>
<div class="align-items-center d-flex mb-2">
<div class="pr-1 w-50">
<div i18n>Language</div>
</div>
<div class="pl-1 w-50">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-select
name="language"
[disabled]="!hasPermissionToUpdateUserSettings"
[value]="language"
(selectionChange)="onChangeUserSetting('language', $event.value)"
>
<mat-option [value]="null"></mat-option>
<mat-option value="de">Deutsch</mat-option>
<mat-option value="en">English</mat-option>
<mat-option value="es"
>Español (<ng-container i18n>Community</ng-container
>)</mat-option
>
<mat-option value="fr"
>Français (<ng-container i18n>Community</ng-container
>)</mat-option
>
<mat-option value="it"
>Italiano (<ng-container i18n>Community</ng-container
>)</mat-option
>
<mat-option value="nl"
>Nederlands (<ng-container i18n>Community</ng-container
>)</mat-option
>
<mat-option value="pt"
>Português (<ng-container i18n>Community</ng-container
>)</mat-option
>
<mat-option value="tr"
>Türkçe (<ng-container i18n>Community</ng-container
>)</mat-option
>
</mat-select>
</mat-form-field>
</div>
</div>
<div class="align-items-center d-flex mb-2">
<div class="pr-1 w-50">
<div i18n>Locale</div>
<div class="hint-text text-muted">
<ng-container i18n>Date and number format</ng-container>
</div>
</div>
<div class="pl-1 w-50">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-select
name="locale"
[disabled]="!hasPermissionToUpdateUserSettings"
[value]="user.settings.locale"
(selectionChange)="onChangeUserSetting('locale', $event.value)"
>
<mat-option [value]="null"></mat-option>
<mat-option *ngFor="let locale of locales" [value]="locale"
>{{ locale }}</mat-option
>
</mat-select>
</mat-form-field>
</div>
</div>
<div class="d-flex">
<div class="align-items-center d-flex pr-1 pt-1 w-50">
<ng-container i18n>Appearance</ng-container>
</div>
<div class="pl-1 w-50">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-select
class="with-placeholder-as-option"
name="colorScheme"
[disabled]="!hasPermissionToUpdateUserSettings"
[placeholder]="appearancePlaceholder"
[value]="user?.settings?.colorScheme"
(selectionChange)="onChangeUserSetting('colorScheme', $event.value)"
>
<mat-option i18n [value]="null">Auto</mat-option>
<mat-option i18n value="LIGHT">Light</mat-option>
<mat-option i18n value="DARK">Dark</mat-option>
</mat-select>
</mat-form-field>
</div>
</div>
</form>
</div>
<div class="d-flex mt-4 py-1">
<div class="pr-1 w-50">
<div i18n>Zen Mode</div>
<div class="hint-text text-muted" i18n>
Distraction-free experience for turbulent times
</div>
</div>
<div class="pl-1 w-50">
<mat-checkbox
color="primary"
[checked]="user.settings.viewMode === 'ZEN'"
[disabled]="!hasPermissionToUpdateViewMode"
(change)="onViewModeChange($event)"
></mat-checkbox>
</div>
</div>
<div class="align-items-center d-flex mt-4 py-1">
<div class="pr-1 w-50">
<div i18n>Biometric Authentication</div>
<div class="hint-text text-muted" i18n>Sign in with fingerprint</div>
</div>
<div class="pl-1 w-50">
<mat-checkbox
#toggleSignInWithFingerprintEnabledElement
color="primary"
[disabled]="!hasPermissionToUpdateUserSettings"
(change)="onSignInWithFingerprintChange($event)"
></mat-checkbox>
</div>
</div>
<div
*ngIf="hasPermissionToUpdateUserSettings"
class="align-items-center d-flex mt-4 py-1"
>
<div class="pr-1 w-50">
<div i18n>Experimental Features</div>
<div class="hint-text text-muted" i18n>
Sneak peek at upcoming functionality
</div>
</div>
<div class="pl-1 w-50">
<mat-checkbox
color="primary"
[checked]="user.settings.isExperimentalFeatures"
[disabled]="!hasPermissionToUpdateUserSettings"
(change)="onExperimentalFeaturesChange($event)"
></mat-checkbox>
</div>
</div>
<div class="align-items-center d-flex mt-4 py-1">
<div class="pr-1 w-50" i18n>User ID</div>
<div class="pl-1 text-monospace w-50">{{ user?.id }}</div>
</div>
<div class="align-items-center d-flex py-1">
<div class="pr-1 w-50"></div>
<div class="pl-1 text-monospace w-50">
<button color="primary" mat-flat-button (click)="onExport()">
<span i18n>Export Data</span>
</button>
</div>
</div>
</div>
</div>
</div>

30
apps/client/src/app/components/user-account-settings/user-account-settings.module.ts

@ -0,0 +1,30 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { RouterModule } from '@angular/router';
import { GfValueModule } from '@ghostfolio/ui/value';
import { UserAccountSettingsComponent } from './user-account-settings.component';
@NgModule({
declarations: [UserAccountSettingsComponent],
exports: [UserAccountSettingsComponent],
imports: [
CommonModule,
FormsModule,
GfValueModule,
MatButtonModule,
MatCardModule,
MatCheckboxModule,
MatFormFieldModule,
MatSelectModule,
ReactiveFormsModule,
RouterModule
]
})
export class GfUserAccountSettingsModule {}

13
apps/client/src/app/components/user-account-settings/user-account-settings.scss

@ -0,0 +1,13 @@
:host {
color: rgb(var(--dark-primary-text));
display: block;
.hint-text {
font-size: 90%;
line-height: 1.2;
}
}
:host-context(.is-dark-theme) {
color: rgb(var(--light-primary-text));
}

2
apps/client/src/app/directives/file-drop/file-drop.directive.ts

@ -1,4 +1,4 @@
import { Directive, HostListener, Output, EventEmitter } from '@angular/core';
import { Directive, EventEmitter, HostListener, Output } from '@angular/core';
@Directive({
selector: '[gfFileDrop]'

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

@ -4,7 +4,10 @@ import {
Inject,
OnDestroy
} from '@angular/core';
import { FormBuilder, FormGroup, 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';
@ -18,15 +21,17 @@ import { CreateOrUpdateAccountDialogParams } from './interfaces/interfaces';
templateUrl: 'create-or-update-account-dialog.html'
})
export class CreateOrUpdateAccountDialog implements OnDestroy {
public accountForm: FormGroup;
public currencies: string[] = [];
public platforms: { id: string; name: string }[];
private unsubscribeSubject = new Subject<void>();
public constructor(
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateAccountDialogParams,
private dataService: DataService,
public dialogRef: MatDialogRef<CreateOrUpdateAccountDialog>,
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateAccountDialogParams
private formBuilder: FormBuilder
) {}
ngOnInit() {
@ -34,12 +39,42 @@ export class CreateOrUpdateAccountDialog implements OnDestroy {
this.currencies = currencies;
this.platforms = platforms;
this.accountForm = this.formBuilder.group({
accountId: [{ disabled: true, value: this.data.account.id }],
balance: [this.data.account.balance, Validators.required],
comment: [this.data.account.comment],
currency: [this.data.account.currency, Validators.required],
isExcluded: [this.data.account.isExcluded],
name: [this.data.account.name, Validators.required],
platformId: [this.data.account.platformId]
});
}
public onCancel() {
this.dialogRef.close();
}
public onSubmit() {
const account: CreateAccountDto | UpdateAccountDto = {
balance: this.accountForm.controls['balance'].value,
comment: this.accountForm.controls['comment'].value,
currency: this.accountForm.controls['currency'].value,
id: this.accountForm.controls['accountId'].value,
isExcluded: this.accountForm.controls['isExcluded'].value,
name: this.accountForm.controls['name'].value,
platformId: this.accountForm.controls['platformId'].value
};
if (this.data.account.id) {
(account as UpdateAccountDto).id = this.data.account.id;
} else {
delete (account as CreateAccountDto).id;
}
this.dialogRef.close({ account });
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();

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

@ -1,17 +1,26 @@
<form #addAccountForm="ngForm" class="d-flex flex-column h-100">
<form
class="d-flex flex-column h-100"
[formGroup]="accountForm"
(keyup.enter)="accountForm.valid && onSubmit()"
(ngSubmit)="onSubmit()"
>
<h1 *ngIf="data.account.id" i18n mat-dialog-title>Update account</h1>
<h1 *ngIf="!data.account.id" i18n mat-dialog-title>Add account</h1>
<div class="flex-grow-1 py-3" mat-dialog-content>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Name</mat-label>
<input matInput name="name" required [(ngModel)]="data.account.name" />
<input
formControlName="name"
matInput
(keydown.enter)="$event.stopPropagation()"
/>
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Currency</mat-label>
<mat-select name="currency" required [(value)]="data.account.currency">
<mat-select formControlName="currency">
<mat-option *ngFor="let currency of currencies" [value]="currency"
>{{ currency }}</mat-option
>
@ -22,11 +31,10 @@
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Cash Balance</mat-label>
<input
formControlName="balance"
matInput
name="balance"
required
type="number"
[(ngModel)]="data.account.balance"
(keydown.enter)="$event.stopPropagation()"
/>
<span class="ml-2" matTextSuffix>{{ data.account.currency }}</span>
</mat-form-field>
@ -34,7 +42,7 @@
<div [ngClass]="{ 'd-none': platforms?.length < 1 }">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Platform</mat-label>
<mat-select name="platformId" [(value)]="data.account.platformId">
<mat-select formControlName="platformId">
<mat-option [value]="null"></mat-option>
<mat-option *ngFor="let platform of platforms" [value]="platform.id"
>{{ platform.name }}</mat-option
@ -48,40 +56,31 @@
<textarea
cdkAutosizeMinRows="2"
cdkTextareaAutosize
formControlName="comment"
matInput
name="comment"
[(ngModel)]="data.account.comment"
(keyup.enter)="$event.stopPropagation()"
></textarea>
</mat-form-field>
</div>
<div class="mb-3 px-2">
<mat-checkbox
color="primary"
name="isExcluded"
[(ngModel)]="data.account.isExcluded"
<mat-checkbox color="primary" formControlName="isExcluded"
>Exclude from Analysis</mat-checkbox
>
</div>
<div *ngIf="data.account.id">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Account ID</mat-label>
<input
disabled
matInput
name="accountId"
[(ngModel)]="data.account.id"
/>
<input formControlName="accountId" matInput />
</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]="!addAccountForm.form.valid"
[mat-dialog-close]="data"
type="submit"
[disabled]="!accountForm.valid"
>
<ng-container i18n>Save</ng-container>
</button>

14
apps/client/src/app/pages/blog/2023/09/hacktoberfest-2023/hacktoberfest-2023-page.component.ts

@ -0,0 +1,14 @@
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
@Component({
host: { class: 'page' },
imports: [MatButtonModule, RouterModule],
selector: 'gf-hacktoberfest-2023-page',
standalone: true,
templateUrl: './hacktoberfest-2023-page.html'
})
export class Hacktoberfest2023PageComponent {
public routerLinkAbout = ['/' + $localize`about`];
}

194
apps/client/src/app/pages/blog/2023/09/hacktoberfest-2023/hacktoberfest-2023-page.html

@ -0,0 +1,194 @@
<div class="blog container">
<div class="row">
<div class="col-md-8 offset-md-2">
<article>
<div class="mb-4 text-center">
<h1 class="mb-1">Hacktoberfest 2023</h1>
<div class="mb-3 text-muted"><small>2023-09-26</small></div>
<img
alt="Hacktoberfest 2023 with Ghostfolio Teaser"
class="rounded w-100"
src="../assets/images/blog/hacktoberfest-2023.png"
title="Hacktoberfest 2023 with Ghostfolio"
/>
</div>
<section class="mb-4">
<p>
At Ghostfolio, <a [routerLink]="routerLinkAbout">we</a> are very
excited to participate in
<a href="https://hacktoberfest.com">Hacktoberfest</a> for the second
time, looking forward to connecting with new and enthusiastic
open-source contributors. Hacktoberfest is a month-long celebration
of open-source projects, their maintainers, and the entire community
of contributors. Each October, open source maintainers from all over
the world give extra attention to new contributors while guiding
them through their first pull requests on
<a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>. This
year the event celebrates its 10th anniversary.
</p>
</section>
<section class="mb-4">
<h2 class="h4">About Ghostfolio</h2>
<p>
<a href="https://ghostfol.io">Ghostfolio</a> is a modern web
application for managing personal finances. The software aggregates
your assets and empowers informed decision-making to help you
balance your portfolio or plan for future investments.
</p>
<p>
Ghostfolio is written in
<a href="https://www.typescriptlang.org">TypeScript</a> and
organized as an <a href="https://nx.dev">Nx</a> workspace, utilizing
the latest framework releases. The backend is based on
<a href="https://nestjs.com">NestJS</a> in combination with
<a href="https://www.postgresql.org">PostgreSQL</a> as a database
together with <a href="https://www.prisma.io">Prisma</a> and
<a href="https://redis.io">Redis</a> for caching. The frontend is
built with <a href="https://angular.io">Angular</a>.
</p>
<p>
The software is used daily by a thriving global community. With over
<a [routerLink]="['/open']">2’600 stars on GitHub</a> and
<a [routerLink]="['/open']">300’000+ pulls on Docker Hub</a>,
Ghostfolio has gained widespread recognition for its user-friendly
experience and simplicity.
</p>
</section>
<section class="mb-4">
<h2 class="h4">How to contribute?</h2>
<p>
Each contribution can make a meaningful impact. Whether it involves
implementing new features, resolving bugs, refactoring code,
enhancing documentation, adding unit tests, or translating content
into another language, you can actively shape our project.
</p>
<p>
Are you not yet familiar with our code base? That is not a problem.
We have applied the label <code>hacktoberfest</code> to a few
<a
href="https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3Ahacktoberfest"
>issues</a
>
that are well suited for newcomers.
</p>
<p>
The official Hacktoberfest website provides some valuable
<a
href="https://hacktoberfest.com/participation/#beginner-resources"
>resources for beginners</a
>
to start contributing in open source.
</p>
</section>
<section class="mb-4">
<h2 class="h4">Get support</h2>
<p>
If you have further questions or ideas, please join our
<a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
>Slack</a
>
community or get in touch on X
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>.
</p>
<p>
We look forward to hearing from you.<br />
Thomas from Ghostfolio
</p>
</section>
<section class="mb-4">
<ul class="list-inline">
<li class="list-inline-item">
<span class="badge badge-light">Angular</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Community</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Docker</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Finance</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Fintech</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Ghostfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">GitHub</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Hacktoberfest</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Investment</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">NestJS</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Nx</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">October</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Open Source</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">OSS</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Personal Finance</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Portfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Portfolio Tracker</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Prisma</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Software</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">TypeScript</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">UX</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Wealth</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Wealth Management</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Web3</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Web 3.0</span>
</li>
</ul>
</section>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a i18n [routerLink]="['/blog']">Blog</a>
</li>
<li
aria-current="page"
class="active breadcrumb-item text-truncate"
>
Hacktoberfest 2023
</li>
</ol>
</nav>
</article>
</div>
</div>
</div>

9
apps/client/src/app/pages/blog/blog-page-routing.module.ts

@ -154,6 +154,15 @@ const routes: Routes = [
(c) => c.Ghostfolio2PageComponent
),
title: 'Ghostfolio 2.0'
},
{
canActivate: [AuthGuard],
path: '2023/09/hacktoberfest-2023',
loadComponent: () =>
import(
'./2023/09/hacktoberfest-2023/hacktoberfest-2023-page.component'
).then((c) => c.Hacktoberfest2023PageComponent),
title: 'Hacktoberfest 2023'
}
];

24
apps/client/src/app/pages/blog/blog-page.html

@ -8,6 +8,30 @@
finance</small
>
</h1>
<mat-card appearance="outlined" class="mb-3">
<mat-card-content>
<div class="container p-0">
<div class="flex-nowrap no-gutters row">
<a
class="d-flex overflow-hidden w-100"
href="../en/blog/2023/09/hacktoberfest-2023"
>
<div class="flex-grow-1 overflow-hidden">
<div class="h6 m-0 text-truncate">Hacktoberfest 2023</div>
<div class="d-flex text-muted">2023-09-26</div>
</div>
<div class="align-items-center d-flex">
<ion-icon
class="chevron text-muted"
name="chevron-forward-outline"
size="small"
></ion-icon>
</div>
</a>
</div>
</div>
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-content>
<div class="container p-0">

1
apps/client/src/app/pages/features/features-page.component.ts

@ -15,6 +15,7 @@ export class FeaturesPageComponent implements OnDestroy {
public hasPermissionForSubscription: boolean;
public info: InfoItem;
public routerLinkRegister = ['/' + $localize`register`];
public routerLinkResources = ['/' + $localize`resources`];
public user: User;
private unsubscribeSubject = new Subject<void>();

2
apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html

@ -8,7 +8,7 @@
<div class="flex-grow-1" mat-dialog-content>
<mat-stepper
#stepper
[animationDuration]="0"
animationDuration="0"
[linear]="true"
[orientation]="stepperOrientation"
[selectedIndex]="importStep"

3
apps/client/src/app/pages/portfolio/fire/fire-page.component.ts

@ -18,6 +18,7 @@ export class FirePageComponent implements OnDestroy, OnInit {
public accountClusterRiskRules: PortfolioReportRule[];
public currencyClusterRiskRules: PortfolioReportRule[];
public deviceType: string;
public emergencyFundRules: PortfolioReportRule[];
public feeRules: PortfolioReportRule[];
public fireWealth: Big;
public hasImpersonationId: boolean;
@ -67,6 +68,8 @@ export class FirePageComponent implements OnDestroy, OnInit {
portfolioReport.rules['accountClusterRisk'] || null;
this.currencyClusterRiskRules =
portfolioReport.rules['currencyClusterRisk'] || null;
this.emergencyFundRules =
portfolioReport.rules['emergencyFund'] || null;
this.feeRules = portfolioReport.rules['fees'] || null;
this.changeDetectorRef.markForCheck();

25
apps/client/src/app/pages/portfolio/fire/fire-page.html

@ -96,8 +96,10 @@
<div class="col">
<h2 class="h3 mb-3 text-center">X-ray</h2>
<p class="mb-4">
Ghostfolio X-ray uses static analysis to identify potential issues and
risks in your portfolio.
<span i18n
>Ghostfolio X-ray uses static analysis to identify potential issues
and risks in your portfolio.</span
>
<span class="d-none"
>It will be highly configurable in the future: activate / deactivate
rules and customize the thresholds to match your personal investment
@ -106,7 +108,20 @@
</p>
<div class="mb-4">
<h4 class="align-items-center d-flex m-0">
<span>Currency Cluster Risks</span
<span i18n>Emergency Fund</span
><gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator>
</h4>
<gf-rules
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
[rules]="emergencyFundRules"
></gf-rules>
</div>
<div class="mb-4">
<h4 class="align-items-center d-flex m-0">
<span i18n>Currency Cluster Risks</span
><gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
@ -119,7 +134,7 @@
</div>
<div class="mb-4">
<h4 class="align-items-center d-flex m-0">
<span>Account Cluster Risks</span
<span i18n>Account Cluster Risks</span
><gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
@ -132,7 +147,7 @@
</div>
<div>
<h4 class="align-items-center d-flex m-0">
<span>Fees</span
<span i18n>Fees</span
><gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"

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

@ -131,9 +131,9 @@
<div class="row">
<div class="col-lg">
<gf-holdings-table
pageSize="7"
[deviceType]="deviceType"
[hasPermissionToShowValues]="false"
[pageSize]="7"
[positions]="positionsArray"
></gf-holdings-table>
</div>

18
apps/client/src/app/pages/resources/personal-finance-tools/products.ts

@ -6,6 +6,7 @@ import { CopilotMoneyPageComponent } from './products/copilot-money-page.compone
import { DeltaPageComponent } from './products/delta-page.component';
import { DivvyDiaryPageComponent } from './products/divvydiary-page.component';
import { ExirioPageComponent } from './products/exirio-page.component';
import { FinaryPageComponent } from './products/finary-page.component';
import { FolisharePageComponent } from './products/folishare-page.component';
import { GetquinPageComponent } from './products/getquin-page.component';
import { GoSpatzPageComponent } from './products/gospatz-page.component';
@ -23,6 +24,7 @@ import { SeekingAlphaPageComponent } from './products/seeking-alpha-page.compone
import { SharesightPageComponent } from './products/sharesight-page.component';
import { SimplePortfolioPageComponent } from './products/simple-portfolio-page.component';
import { SnowballAnalyticsPageComponent } from './products/snowball-analytics-page.component';
import { StocklePageComponent } from './products/stockle-page.component';
import { StockMarketEyePageComponent } from './products/stockmarketeye-page.component';
import { SumioPageComponent } from './products/sumio-page.component';
import { UtlunaPageComponent } from './products/utluna-page.component';
@ -115,6 +117,15 @@ export const products: Product[] = [
pricingPerYear: '$100',
slogan: 'All your wealth, in one place.'
},
{
component: FinaryPageComponent,
founded: 2020,
key: 'finary',
languages: ['Deutsch', 'English', 'Français'],
name: 'Finary',
origin: $localize`United States`,
slogan: 'Real-Time Portfolio Tracker & Stock Tracker'
},
{
component: FolisharePageComponent,
hasFreePlan: true,
@ -304,6 +315,13 @@ export const products: Product[] = [
pricingPerYear: '$80',
slogan: 'Simple and powerful portfolio tracker'
},
{
component: StocklePageComponent,
key: 'stockle',
name: 'Stockle',
origin: $localize`Finland`,
slogan: 'Supercharge your investments tracking experience'
},
{
component: StockMarketEyePageComponent,
founded: 2008,

31
apps/client/src/app/pages/resources/personal-finance-tools/products/finary-page.component.ts

@ -0,0 +1,31 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
@Component({
host: { class: 'page' },
imports: [CommonModule, MatButtonModule, RouterModule],
selector: 'gf-finary-page',
standalone: true,
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class FinaryPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});
public product2 = products.find(({ key }) => {
return key === 'finary';
});
public routerLinkAbout = ['/' + $localize`about`];
public routerLinkFeatures = ['/' + $localize`features`];
public routerLinkResourcesPersonalFinanceTools = [
'/' + $localize`resources`,
'personal-finance-tools'
];
}

31
apps/client/src/app/pages/resources/personal-finance-tools/products/stockle-page.component.ts

@ -0,0 +1,31 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
@Component({
host: { class: 'page' },
imports: [CommonModule, MatButtonModule, RouterModule],
selector: 'gf-stockle-page',
standalone: true,
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class StocklePageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});
public product2 = products.find(({ key }) => {
return key === 'stockle';
});
public routerLinkAbout = ['/' + $localize`about`];
public routerLinkFeatures = ['/' + $localize`features`];
public routerLinkResourcesPersonalFinanceTools = [
'/' + $localize`resources`,
'personal-finance-tools'
];
}

20
apps/client/src/app/pages/user-account/user-account-page-routing.module.ts

@ -1,5 +1,8 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { UserAccountAccessComponent } from '@ghostfolio/client/components/user-account-access/user-account-access.component';
import { UserAccountMembershipComponent } from '@ghostfolio/client/components/user-account-membership/user-account-membership.component';
import { UserAccountSettingsComponent } from '@ghostfolio/client/components/user-account-settings/user-account-settings.component';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { UserAccountPageComponent } from './user-account-page.component';
@ -7,6 +10,23 @@ import { UserAccountPageComponent } from './user-account-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
children: [
{
path: '',
component: UserAccountSettingsComponent,
title: $localize`Settings`
},
{
path: 'membership',
component: UserAccountMembershipComponent,
title: $localize`Membership`
},
{
path: 'access',
component: UserAccountAccessComponent,
title: $localize`Access`
}
],
component: UserAccountPageComponent,
path: '',
title: $localize`My Ghostfolio`

433
apps/client/src/app/pages/user-account/user-account-page.component.ts

@ -1,448 +1,63 @@
import {
ChangeDetectorRef,
Component,
OnDestroy,
OnInit,
ViewChild
} from '@angular/core';
import { MatCheckbox, MatCheckboxChange } from '@angular/material/checkbox';
import { MatDialog } from '@angular/material/dialog';
import {
MatSnackBar,
MatSnackBarRef,
TextOnlySnackBar
} from '@angular/material/snack-bar';
import { ActivatedRoute, Router } from '@angular/router';
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
import { DataService } from '@ghostfolio/client/services/data.service';
import {
STAY_SIGNED_IN,
SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
import { downloadAsFile, getDateFormatString } from '@ghostfolio/common/helper';
import { Access, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { format, parseISO } from 'date-fns';
import { uniq } from 'lodash';
import { TabConfiguration, User } from '@ghostfolio/common/interfaces';
import { DeviceDetectorService } from 'ngx-device-detector';
import { StripeService } from 'ngx-stripe';
import { EMPTY, Subject } from 'rxjs';
import { catchError, switchMap, takeUntil } from 'rxjs/operators';
import { CreateOrUpdateAccessDialog } from './create-or-update-access-dialog/create-or-update-access-dialog.component';
import { Subject, takeUntil } from 'rxjs';
@Component({
host: { class: 'page' },
host: { class: 'page has-tabs' },
selector: 'gf-user-account-page',
styleUrls: ['./user-account-page.scss'],
templateUrl: './user-account-page.html'
})
export class UserAccountPageComponent implements OnDestroy, OnInit {
@ViewChild('toggleSignInWithFingerprintEnabledElement')
signInWithFingerprintElement: MatCheckbox;
public accesses: Access[];
public appearancePlaceholder = $localize`Auto`;
public baseCurrency: string;
public coupon: number;
public couponId: string;
public currencies: string[] = [];
public defaultDateFormat: string;
public deviceType: string;
public hasPermissionForSubscription: boolean;
public hasPermissionToCreateAccess: boolean;
public hasPermissionToDeleteAccess: boolean;
public hasPermissionToUpdateViewMode: boolean;
public hasPermissionToUpdateUserSettings: boolean;
public language = document.documentElement.lang;
public locales = [
'de',
'de-CH',
'en-GB',
'en-US',
'es',
'fr',
'it',
'nl',
'pt',
'tr'
];
public price: number;
public priceId: string;
public snackBarRef: MatSnackBarRef<TextOnlySnackBar>;
public trySubscriptionMail =
'mailto:hi@ghostfol.io?Subject=Ghostfolio Premium Trial&body=Hello%0D%0DI am interested in Ghostfolio Premium. Can you please send me a coupon code to try it for some time?%0D%0DKind regards';
public tabs: TabConfiguration[] = [];
public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private snackBar: MatSnackBar,
private route: ActivatedRoute,
private router: Router,
private settingsStorageService: SettingsStorageService,
private stripeService: StripeService,
private userService: UserService,
public webAuthnService: WebAuthnService
private userService: UserService
) {
const { baseCurrency, currencies, globalPermissions, subscriptions } =
this.dataService.fetchInfo();
this.baseCurrency = baseCurrency;
this.currencies = currencies;
this.hasPermissionForSubscription = hasPermission(
globalPermissions,
permissions.enableSubscription
);
this.hasPermissionToDeleteAccess = hasPermission(
globalPermissions,
permissions.deleteAccess
);
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.defaultDateFormat = getDateFormatString(
this.user.settings.locale
);
this.hasPermissionToCreateAccess = hasPermission(
this.user.permissions,
permissions.createAccess
);
this.hasPermissionToDeleteAccess = hasPermission(
this.user.permissions,
permissions.deleteAccess
);
this.hasPermissionToUpdateUserSettings = hasPermission(
this.user.permissions,
permissions.updateUserSettings
);
this.hasPermissionToUpdateViewMode = hasPermission(
this.user.permissions,
permissions.updateViewMode
);
this.locales.push(this.user.settings.locale);
this.locales = uniq(this.locales.sort());
this.coupon = subscriptions?.[this.user.subscription.offer]?.coupon;
this.couponId =
subscriptions?.[this.user.subscription.offer]?.couponId;
this.price = subscriptions?.[this.user.subscription.offer]?.price;
this.priceId = subscriptions?.[this.user.subscription.offer]?.priceId;
this.changeDetectorRef.markForCheck();
}
});
this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (params['createDialog']) {
this.openCreateAccessDialog();
}
});
}
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.update();
}
public onChangeUserSetting(aKey: string, aValue: string) {
this.dataService
.putUserSetting({ [aKey]: aValue })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService.remove();
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
this.changeDetectorRef.markForCheck();
if (aKey === 'language') {
if (aValue) {
window.location.href = `../${aValue}/account`;
} else {
window.location.href = `../`;
}
}
});
});
}
public onCheckout() {
this.dataService
.createCheckoutSession({ couponId: this.couponId, priceId: this.priceId })
.pipe(
switchMap(({ sessionId }: { sessionId: string }) => {
return this.stripeService.redirectToCheckout({ sessionId });
}),
catchError((error) => {
alert(error.message);
throw error;
})
)
.subscribe((result) => {
if (result.error) {
alert(result.error.message);
}
});
}
public onDeleteAccess(aId: string) {
this.dataService
.deleteAccess(aId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.update();
}
});
}
public onExperimentalFeaturesChange(aEvent: MatCheckboxChange) {
this.dataService
.putUserSetting({ isExperimentalFeatures: aEvent.checked })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService.remove();
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
this.changeDetectorRef.markForCheck();
});
});
}
public onExport() {
this.dataService
.fetchExport()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
for (const activity of data.activities) {
delete activity.id;
}
downloadAsFile({
content: data,
fileName: `ghostfolio-export-${format(
parseISO(data.meta.date),
'yyyyMMddHHmm'
)}.json`,
format: 'json'
});
});
}
public onRedeemCoupon() {
let couponCode = prompt($localize`Please enter your coupon code:`);
couponCode = couponCode?.trim();
if (couponCode) {
this.dataService
.redeemCoupon(couponCode)
.pipe(
takeUntil(this.unsubscribeSubject),
catchError(() => {
this.snackBar.open(
'😞 ' + $localize`Could not redeem coupon code`,
undefined,
this.tabs = [
{
duration: 3000
}
);
return EMPTY;
})
)
.subscribe(() => {
this.snackBarRef = this.snackBar.open(
'✅ ' + $localize`Coupon code has been redeemed`,
$localize`Reload`,
iconName: 'settings-outline',
label: $localize`Settings`,
path: ['/account']
},
{
duration: 3000
}
);
this.snackBarRef
.afterDismissed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
window.location.reload();
});
this.snackBarRef
.onAction()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
window.location.reload();
});
});
}
iconName: 'diamond-outline',
label: $localize`Membership`,
path: ['/account/membership'],
showCondition: !!this.user?.subscription
},
{
iconName: 'share-social-outline',
label: $localize`Access`,
path: ['/account', 'access']
}
public onRestrictedViewChange(aEvent: MatCheckboxChange) {
this.dataService
.putUserSetting({ isRestrictedView: aEvent.checked })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService.remove();
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
];
this.changeDetectorRef.markForCheck();
});
});
}
public onSignInWithFingerprintChange(aEvent: MatCheckboxChange) {
if (aEvent.checked) {
this.registerDevice();
} else {
const confirmation = confirm(
$localize`Do you really want to remove this sign in method?`
);
if (confirmation) {
this.deregisterDevice();
} else {
this.update();
}
}
});
}
public onViewModeChange(aEvent: MatCheckboxChange) {
this.dataService
.putUserSetting({ viewMode: aEvent.checked === true ? 'ZEN' : 'DEFAULT' })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService.remove();
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
this.changeDetectorRef.markForCheck();
});
});
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private openCreateAccessDialog(): void {
const dialogRef = this.dialog.open(CreateOrUpdateAccessDialog, {
data: {
access: {
alias: '',
type: 'PUBLIC'
}
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data: any) => {
const access: CreateAccessDto = data?.access;
if (access) {
this.dataService
.postAccess({ alias: access.alias })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.update();
}
});
}
this.router.navigate(['.'], { relativeTo: this.route });
});
}
private deregisterDevice() {
this.webAuthnService
.deregister()
.pipe(
takeUntil(this.unsubscribeSubject),
catchError(() => {
this.update();
return EMPTY;
})
)
.subscribe(() => {
this.update();
});
}
private registerDevice() {
this.webAuthnService
.register()
.pipe(
takeUntil(this.unsubscribeSubject),
catchError(() => {
this.update();
return EMPTY;
})
)
.subscribe(() => {
this.settingsStorageService.removeSetting(STAY_SIGNED_IN);
this.update();
});
}
private update() {
this.dataService
.fetchAccesses()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => {
this.accesses = response;
if (this.signInWithFingerprintElement) {
this.signInWithFingerprintElement.checked =
this.webAuthnService.isEnabled() ?? false;
}
this.changeDetectorRef.markForCheck();
});
}
}

336
apps/client/src/app/pages/user-account/user-account-page.html

@ -1,309 +1,29 @@
<div class="container">
<div class="row">
<div class="col">
<h2 class="h3 mb-3 text-center" i18n>Account</h2>
</div>
</div>
<div *ngIf="user?.settings" class="mb-5 row">
<div class="col">
<mat-card appearance="outlined" class="mb-3">
<mat-card-content>
<div *ngIf="user?.subscription" class="d-flex py-1">
<div class="pr-1 w-50" i18n>Membership</div>
<div class="pl-1 w-50">
<div class="align-items-center d-flex mb-1">
<a [routerLink]="routerLinkPricing"
>{{ user?.subscription?.type }}</a
>
<gf-premium-indicator
*ngIf="user?.subscription?.type === 'Premium'"
class="ml-1"
></gf-premium-indicator>
</div>
<div *ngIf="user?.subscription?.type === 'Premium'">
<ng-container i18n>Valid until</ng-container> {{
user?.subscription?.expiresAt | date: defaultDateFormat }}
</div>
<div *ngIf="user?.subscription?.type === 'Basic'">
<ng-container
*ngIf="hasPermissionForSubscription && hasPermissionToUpdateUserSettings"
>
<button
color="primary"
mat-flat-button
(click)="onCheckout()"
>
<ng-container
*ngIf="user.subscription.offer === 'default'"
i18n
>Upgrade</ng-container
>
<ng-container
*ngIf="user.subscription.offer === 'renewal'"
i18n
>Renew</ng-container
>
</button>
<div *ngIf="price" class="mt-1">
<ng-container *ngIf="coupon"
><del class="text-muted"
>{{ baseCurrency }}&nbsp;{{ price }}</del
>&nbsp;{{ baseCurrency }}&nbsp;{{ price - coupon
}}</ng-container
>
<ng-container *ngIf="!coupon"
>{{ baseCurrency }}&nbsp;{{ price }}</ng-container
>&nbsp;<span i18n>per year</span>
</div>
</ng-container>
<mat-tab-nav-panel #tabPanel class="flex-grow-1 overflow-auto">
<router-outlet></router-outlet>
</mat-tab-nav-panel>
<nav
mat-align-tabs="center"
mat-tab-nav-bar
[disablePagination]="true"
[tabPanel]="tabPanel"
>
<ng-container *ngFor="let tab of tabs">
<a
*ngIf="!user?.subscription?.expiresAt"
class="mr-2 my-2"
mat-stroked-button
[href]="trySubscriptionMail"
><span i18n>Try Premium</span>
<gf-premium-indicator
class="d-inline-block ml-1"
[enableLink]="false"
></gf-premium-indicator
></a>
<a
*ngIf="hasPermissionToUpdateUserSettings"
class="mr-2 my-2"
i18n
mat-stroked-button
[routerLink]=""
(click)="onRedeemCoupon()"
>Redeem Coupon</a
>
</div>
</div>
</div>
<div class="align-items-center d-flex mt-4 py-1">
<div class="pr-1 w-50">
<div i18n>Presenter View</div>
<div class="hint-text text-muted" i18n>
Protection for sensitive information like absolute performances
and quantity values
</div>
</div>
<div class="pl-1 w-50">
<mat-checkbox
color="primary"
[checked]="user.settings.isRestrictedView"
[disabled]="!hasPermissionToUpdateUserSettings"
(change)="onRestrictedViewChange($event)"
></mat-checkbox>
</div>
</div>
<div class="d-flex mt-4 py-1">
<form #changeUserSettingsForm="ngForm" class="w-100">
<div class="d-flex mb-2">
<div class="align-items-center d-flex pt-1 pt-1 w-50">
<ng-container i18n>Base Currency</ng-container>
</div>
<div class="pl-1 w-50">
<mat-form-field
appearance="outline"
class="w-100 without-hint"
>
<mat-select
name="baseCurrency"
[disabled]="!hasPermissionToUpdateUserSettings"
[value]="user.settings.baseCurrency"
(selectionChange)="onChangeUserSetting('baseCurrency', $event.value)"
>
<mat-option
*ngFor="let currency of currencies"
[value]="currency"
>{{ currency }}</mat-option
>
</mat-select>
</mat-form-field>
</div>
</div>
<div class="align-items-center d-flex mb-2">
<div class="pr-1 w-50">
<div i18n>Language</div>
</div>
<div class="pl-1 w-50">
<mat-form-field
appearance="outline"
class="w-100 without-hint"
>
<mat-select
name="language"
[disabled]="!hasPermissionToUpdateUserSettings"
[value]="language"
(selectionChange)="onChangeUserSetting('language', $event.value)"
>
<mat-option [value]="null"></mat-option>
<mat-option value="de">Deutsch</mat-option>
<mat-option value="en">English</mat-option>
<mat-option value="es"
>Español (<ng-container i18n>Community</ng-container
>)</mat-option
>
<mat-option value="fr"
>Français (<ng-container i18n>Community</ng-container
>)</mat-option
>
<mat-option value="it"
>Italiano (<ng-container i18n>Community</ng-container
>)</mat-option
>
<mat-option value="nl"
>Nederlands (<ng-container i18n>Community</ng-container
>)</mat-option
>
<mat-option value="pt"
>Português (<ng-container i18n>Community</ng-container
>)</mat-option
>
<mat-option value="tr"
>Türkçe (<ng-container i18n>Community</ng-container
>)</mat-option
>
</mat-select>
</mat-form-field>
</div>
</div>
<div class="align-items-center d-flex mb-2">
<div class="pr-1 w-50">
<div i18n>Locale</div>
<div class="hint-text text-muted">
<ng-container i18n>Date and number format</ng-container>
</div>
</div>
<div class="pl-1 w-50">
<mat-form-field
appearance="outline"
class="w-100 without-hint"
>
<mat-select
name="locale"
[disabled]="!hasPermissionToUpdateUserSettings"
[value]="user.settings.locale"
(selectionChange)="onChangeUserSetting('locale', $event.value)"
>
<mat-option [value]="null"></mat-option>
<mat-option
*ngFor="let locale of locales"
[value]="locale"
>{{ locale }}</mat-option
>
</mat-select>
</mat-form-field>
</div>
</div>
<div class="d-flex">
<div class="align-items-center d-flex pr-1 pt-1 w-50">
<ng-container i18n>Appearance</ng-container>
</div>
<div class="pl-1 w-50">
<mat-form-field
appearance="outline"
class="w-100 without-hint"
>
<mat-select
class="with-placeholder-as-option"
name="colorScheme"
[disabled]="!hasPermissionToUpdateUserSettings"
[placeholder]="appearancePlaceholder"
[value]="user?.settings?.colorScheme"
(selectionChange)="onChangeUserSetting('colorScheme', $event.value)"
>
<mat-option i18n [value]="null">Auto</mat-option>
<mat-option i18n value="LIGHT">Light</mat-option>
<mat-option i18n value="DARK">Dark</mat-option>
</mat-select>
</mat-form-field>
</div>
</div>
</form>
</div>
<div class="d-flex mt-4 py-1">
<div class="pr-1 w-50">
<div i18n>Zen Mode</div>
<div class="hint-text text-muted" i18n>
Distraction-free experience for turbulent times
</div>
</div>
<div class="pl-1 w-50">
<mat-checkbox
color="primary"
[checked]="user.settings.viewMode === 'ZEN'"
[disabled]="!hasPermissionToUpdateViewMode"
(change)="onViewModeChange($event)"
></mat-checkbox>
</div>
</div>
<div class="align-items-center d-flex mt-4 py-1">
<div class="pr-1 w-50">
<div i18n>Biometric Authentication</div>
<div class="hint-text text-muted" i18n>
Sign in with fingerprint
</div>
</div>
<div class="pl-1 w-50">
<mat-checkbox
#toggleSignInWithFingerprintEnabledElement
color="primary"
[disabled]="!hasPermissionToUpdateUserSettings"
(change)="onSignInWithFingerprintChange($event)"
></mat-checkbox>
</div>
</div>
<div
*ngIf="hasPermissionToUpdateUserSettings"
class="align-items-center d-flex mt-4 py-1"
>
<div class="pr-1 w-50">
<div i18n>Experimental Features</div>
<div class="hint-text text-muted" i18n>
Sneak peek at upcoming functionality
</div>
</div>
<div class="pl-1 w-50">
<mat-checkbox
color="primary"
[checked]="user.settings.isExperimentalFeatures"
[disabled]="!hasPermissionToUpdateUserSettings"
(change)="onExperimentalFeaturesChange($event)"
></mat-checkbox>
</div>
</div>
<div class="align-items-center d-flex mt-4 py-1">
<div class="pr-1 w-50" i18n>User ID</div>
<div class="pl-1 text-monospace w-50">{{ user?.id }}</div>
</div>
<div class="align-items-center d-flex py-1">
<div class="pr-1 w-50"></div>
<div class="pl-1 text-monospace w-50">
<button color="primary" mat-flat-button (click)="onExport()">
<span i18n>Export Data</span>
</button>
</div>
</div>
</mat-card-content>
</mat-card>
</div>
</div>
<div class="row">
<div class="col">
<h2 class="align-items-center d-flex h3 justify-content-center mb-3">
<span i18n>Granted Access</span>
<gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
></gf-premium-indicator>
</h2>
<gf-access-table
[accesses]="accesses"
[hasPermissionToCreateAccess]="hasPermissionToCreateAccess"
[showActions]="hasPermissionToDeleteAccess"
(accessDeleted)="onDeleteAccess($event)"
></gf-access-table>
</div>
</div>
</div>
#rla="routerLinkActive"
*ngIf="tab.showCondition !== false"
class="px-3"
mat-tab-link
routerLinkActive
[active]="rla.isActive"
[routerLink]="tab.path"
[routerLinkActiveOptions]="{ exact: true }"
>
<ion-icon
[name]="tab.iconName"
[size]="deviceType === 'mobile' ? 'large': 'small'"
></ion-icon>
<div class="d-none d-sm-block ml-2">{{ tab.label }}</div>
</a>
</ng-container>
</nav>

38
apps/client/src/app/pages/user-account/user-account-page.module.ts

@ -1,18 +1,10 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { RouterModule } from '@angular/router';
import { GfPortfolioAccessTableModule } from '@ghostfolio/client/components/access-table/access-table.module';
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
import { GfValueModule } from '@ghostfolio/ui/value';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatTabsModule } from '@angular/material/tabs';
import { GfUserAccountAccessModule } from '@ghostfolio/client/components/user-account-access/user-account-access.module';
import { GfUserAccountMembershipModule } from '@ghostfolio/client/components/user-account-membership/user-account-membership.module';
import { GfUserAccountSettingsModule } from '@ghostfolio/client/components/user-account-settings/user-account-settings.module';
import { GfCreateOrUpdateAccessDialogModule } from './create-or-update-access-dialog/create-or-update-access-dialog.module';
import { UserAccountPageRoutingModule } from './user-account-page-routing.module';
import { UserAccountPageComponent } from './user-account-page.component';
@ -20,20 +12,12 @@ import { UserAccountPageComponent } from './user-account-page.component';
declarations: [UserAccountPageComponent],
imports: [
CommonModule,
FormsModule,
GfCreateOrUpdateAccessDialogModule,
GfPortfolioAccessTableModule,
GfPremiumIndicatorModule,
GfValueModule,
MatButtonModule,
MatCardModule,
MatCheckboxModule,
MatDialogModule,
MatFormFieldModule,
MatSelectModule,
ReactiveFormsModule,
RouterModule,
GfUserAccountAccessModule,
GfUserAccountMembershipModule,
GfUserAccountSettingsModule,
MatTabsModule,
UserAccountPageRoutingModule
]
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class UserAccountPageModule {}

12
apps/client/src/app/pages/user-account/user-account-page.scss

@ -1,15 +1,7 @@
@import 'apps/client/src/styles/ghostfolio-style';
:host {
color: rgb(var(--dark-primary-text));
display: block;
gf-access-table {
overflow-x: auto;
}
.hint-text {
font-size: 90%;
line-height: 1.2;
}
}
:host-context(.is-dark-theme) {

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

@ -4,6 +4,8 @@ import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-pr
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';
import { CreateTagDto } from '@ghostfolio/api/app/tag/create-tag.dto';
import { UpdateTagDto } from '@ghostfolio/api/app/tag/update-tag.dto';
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import {
@ -15,7 +17,7 @@ import {
Filter,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { DataSource, MarketData, Platform, Prisma } from '@prisma/client';
import { DataSource, MarketData, Platform, Prisma, Tag } from '@prisma/client';
import { JobStatus } from 'bull';
import { format, parseISO } from 'date-fns';
import { Observable, map } from 'rxjs';
@ -64,6 +66,10 @@ export class AdminService {
);
}
public deleteTag(aId: string) {
return this.http.delete<void>(`/api/v1/tag/${aId}`);
}
public fetchAdminData() {
return this.http.get<AdminData>('/api/v1/admin');
}
@ -139,6 +145,10 @@ export class AdminService {
return this.http.get<Platform[]>('/api/v1/platform');
}
public fetchTags() {
return this.http.get<Tag[]>('/api/v1/tag');
}
public gather7Days() {
return this.http.post<void>('/api/v1/admin/gather', {});
}
@ -208,6 +218,10 @@ export class AdminService {
return this.http.post<Platform>(`/api/v1/platform`, aPlatform);
}
public postTag(aTag: CreateTagDto) {
return this.http.post<Tag>(`/api/v1/tag`, aTag);
}
public putMarketData({
dataSource,
date,
@ -233,4 +247,8 @@ export class AdminService {
aPlatform
);
}
public putTag(aTag: UpdateTagDto) {
return this.http.put<Tag>(`/api/v1/tag/${aTag.id}`, aTag);
}
}

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

@ -36,7 +36,6 @@ import {
} from '@ghostfolio/common/interfaces';
import { filterGlobalPermissions } from '@ghostfolio/common/permissions';
import { AccountWithValue, DateRange, GroupBy } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n';
import { DataSource, Order as OrderModel } from '@prisma/client';
import { format, parseISO } from 'date-fns';
import { cloneDeep, groupBy, isNumber } from 'lodash';
@ -204,6 +203,10 @@ export class DataService {
return this.http.delete<any>(`/api/v1/order/`);
}
public deleteBenchmark({ dataSource, symbol }: UniqueAsset) {
return this.http.delete<any>(`/api/v1/benchmark/${dataSource}/${symbol}`);
}
public deleteOrder(aId: string) {
return this.http.delete<any>(`/api/v1/order/${aId}`);
}
@ -496,4 +499,19 @@ export class DataService {
couponCode
});
}
public updateInfo() {
this.http.get<InfoItem>('/api/v1/info').subscribe((info) => {
const utmSource = <'ios' | 'trusted-web-activity'>(
window.localStorage.getItem('utm_source')
);
info.globalPermissions = filterGlobalPermissions(
info.globalPermissions,
utmSource
);
(window as any).info = info;
});
}
}

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

@ -15,6 +15,7 @@ import { catchError } from 'rxjs/operators';
})
export class ImportActivitiesService {
private static ACCOUNT_KEYS = ['account', 'accountid'];
private static COMMENT_KEYS = ['comment', 'note'];
private static CURRENCY_KEYS = ['ccy', 'currency', 'currencyprimary'];
private static DATA_SOURCE_KEYS = ['datasource'];
private static DATE_KEYS = ['date', 'tradedate'];
@ -52,6 +53,7 @@ export class ImportActivitiesService {
for (const [index, item] of content.entries()) {
activities.push({
accountId: this.parseAccount({ item, userAccounts }),
comment: this.parseComment({ item }),
currency: this.parseCurrency({ content, index, item }),
dataSource: this.parseDataSource({ item }),
date: this.parseDate({ content, index, item }),
@ -122,6 +124,7 @@ export class ImportActivitiesService {
private convertToCreateOrderDto({
accountId,
comment,
date,
fee,
quantity,
@ -132,6 +135,7 @@ export class ImportActivitiesService {
}: Activity): CreateOrderDto {
return {
accountId,
comment,
fee,
quantity,
type,
@ -174,6 +178,18 @@ export class ImportActivitiesService {
return undefined;
}
private parseComment({ item }: { item: any }) {
item = this.lowercaseKeys(item);
for (const key of ImportActivitiesService.COMMENT_KEYS) {
if (item[key]) {
return item[key];
}
}
return undefined;
}
private parseCurrency({
content,
index,

BIN
apps/client/src/assets/fonts/Inter-Black.woff

Binary file not shown.

BIN
apps/client/src/assets/fonts/Inter-Black.woff2

Binary file not shown.

BIN
apps/client/src/assets/fonts/Inter-BlackItalic.woff

Binary file not shown.

BIN
apps/client/src/assets/fonts/Inter-BlackItalic.woff2

Binary file not shown.

BIN
apps/client/src/assets/fonts/Inter-Bold.woff

Binary file not shown.

BIN
apps/client/src/assets/fonts/Inter-Bold.woff2

Binary file not shown.

BIN
apps/client/src/assets/fonts/Inter-BoldItalic.woff

Binary file not shown.

BIN
apps/client/src/assets/fonts/Inter-BoldItalic.woff2

Binary file not shown.

BIN
apps/client/src/assets/fonts/Inter-ExtraBold.woff

Binary file not shown.

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save