Browse Source

Merge pull request #125 from dandevaud/feature/MR-Upstream-2024-10-02

Feature/mr upstream 2024 10 02
pull/5027/head
dandevaud 9 months ago
committed by GitHub
parent
commit
be916341e9
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 27
      CHANGELOG.md
  2. 2
      apps/api/src/app/info/info.service.ts
  3. 1
      apps/api/src/app/platform/platform.controller.ts
  4. 6
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts
  5. 1
      apps/api/src/app/tag/tag.controller.ts
  6. 3
      apps/api/src/app/tag/tag.service.ts
  7. 1
      apps/api/src/app/user/update-user-setting.dto.ts
  8. 2
      apps/api/src/app/user/user.service.ts
  9. 3054
      apps/api/src/helper/object.helper.spec.ts
  10. 8
      apps/api/src/models/rules/account-cluster-risk/current-investment.ts
  11. 4
      apps/api/src/models/rules/account-cluster-risk/single-account.ts
  12. 6
      apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts
  13. 8
      apps/api/src/models/rules/currency-cluster-risk/current-investment.ts
  14. 10
      apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts
  15. 8
      apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts
  16. 7
      apps/api/src/services/tag/tag.service.ts
  17. 2
      apps/client/src/app/app.component.html
  18. 34
      apps/client/src/app/app.component.ts
  19. 7
      apps/client/src/app/components/admin-market-data/admin-market-data.service.ts
  20. 6
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  21. 85
      apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.component.ts
  22. 13
      apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.html
  23. 2
      apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/interfaces/interfaces.ts
  24. 5
      apps/client/src/app/components/admin-overview/admin-overview.component.ts
  25. 4
      apps/client/src/app/components/admin-platform/admin-platform.component.ts
  26. 14
      apps/client/src/app/components/admin-tag/admin-tag.component.html
  27. 12
      apps/client/src/app/components/admin-tag/admin-tag.component.ts
  28. 4
      apps/client/src/app/components/header/header.component.html
  29. 4
      apps/client/src/app/components/header/header.component.ts
  30. 12
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  31. 2
      apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts
  32. 32
      apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html
  33. 2
      apps/client/src/app/components/rule/rule.component.html
  34. 13
      apps/client/src/app/components/rule/rule.component.ts
  35. 6
      apps/client/src/app/pages/faq/self-hosting/self-hosting-page.html
  36. 6
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  37. 2
      apps/client/src/app/pages/portfolio/fire/fire-page.component.ts
  38. 117
      apps/client/src/app/pages/resources/personal-finance-tools/product-page.html
  39. 6
      apps/client/src/app/pages/resources/personal-finance-tools/product-page.scss
  40. 548
      apps/client/src/locales/messages.ca.xlf
  41. 556
      apps/client/src/locales/messages.de.xlf
  42. 548
      apps/client/src/locales/messages.es.xlf
  43. 548
      apps/client/src/locales/messages.fr.xlf
  44. 548
      apps/client/src/locales/messages.it.xlf
  45. 548
      apps/client/src/locales/messages.nl.xlf
  46. 548
      apps/client/src/locales/messages.pl.xlf
  47. 548
      apps/client/src/locales/messages.pt.xlf
  48. 548
      apps/client/src/locales/messages.tr.xlf
  49. 529
      apps/client/src/locales/messages.xlf
  50. 548
      apps/client/src/locales/messages.zh.xlf
  51. 1
      libs/common/src/lib/interfaces/product.ts
  52. 4
      libs/common/src/lib/permissions.ts
  53. 4
      libs/common/src/lib/personal-finance-tools.ts
  54. 4
      libs/common/src/lib/types/x-ray-rules-settings.type.ts
  55. 14
      libs/ui/src/lib/assistant/assistant.component.ts
  56. 6
      libs/ui/src/lib/assistant/assistant.html
  57. 68
      package-lock.json
  58. 9
      package.json
  59. 11
      prisma/migrations/20240928171744_added_user_to_tag/migration.sql
  60. 6
      prisma/schema.prisma

27
CHANGELOG.md

@ -7,10 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Added
- Added support to customize the rule thresholds in the _X-ray_ section (experimental)
### Changed ### Changed
- Improved the language localization for German (`de`)
## 2.111.0 - 2024-09-28
### Added
- Added read `permissions` to the `Platform` model
- Added read `permissions` to the `Tag` model
- Added `userId` to the `Tag` database schema
### Changed
- Considered the availability of the date range selector in the assistant per view
- Considered the availability of the filters in the assistant per view
- Optimized the portfolio calculations with smarter cloning of activities
- Integrated the add currency functionality into the market data section of the admin control panel
- Improved the language localization for German (`de`)
- Upgraded `prisma` from version `5.19.1` to `5.20.0`
- Upgraded `webpack-bundle-analyzer` from version `4.10.1` to `4.10.2` - Upgraded `webpack-bundle-analyzer` from version `4.10.1` to `4.10.2`
### Fixed
- Fixed the content height of the create or update platform dialog in the admin control
- Fixed the content height of the create or update tag dialog in the admin control
## 2.110.0 - 2024-09-24 ## 2.110.0 - 2024-09-24
### Changed ### Changed

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

@ -114,7 +114,7 @@ export class InfoService {
}), }),
this.getStatistics(), this.getStatistics(),
this.getSubscriptions(), this.getSubscriptions(),
this.tagService.get() this.tagService.getPublic()
]); ]);
if (isUserSignupEnabled) { if (isUserSignupEnabled) {

1
apps/api/src/app/platform/platform.controller.ts

@ -26,6 +26,7 @@ export class PlatformController {
public constructor(private readonly platformService: PlatformService) {} public constructor(private readonly platformService: PlatformService) {}
@Get() @Get()
@HasPermission(permissions.readPlatforms)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getPlatforms() { public async getPlatforms() {
return this.platformService.getPlatformsWithAccountCount(); return this.platformService.getPlatformsWithAccountCount();

6
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts

@ -180,10 +180,10 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
let valueAtStartDateWithCurrencyEffect: Big; let valueAtStartDateWithCurrencyEffect: Big;
// Clone orders to keep the original values in this.orders // Clone orders to keep the original values in this.orders
let orders: PortfolioOrderItem[] = cloneDeep(this.activities).filter( let orders: PortfolioOrderItem[] = cloneDeep(
({ SymbolProfile }) => { this.activities.filter(({ SymbolProfile }) => {
return SymbolProfile.symbol === symbol; return SymbolProfile.symbol === symbol;
} })
); );
if (orders.length <= 0) { if (orders.length <= 0) {

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

@ -26,6 +26,7 @@ export class TagController {
public constructor(private readonly tagService: TagService) {} public constructor(private readonly tagService: TagService) {}
@Get() @Get()
@HasPermission(permissions.readTags)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getTags() { public async getTags() {
return this.tagService.getTagsWithActivityCount(); return this.tagService.getTagsWithActivityCount();

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

@ -56,10 +56,11 @@ export class TagService {
} }
}); });
return tagsWithOrderCount.map(({ _count, id, name }) => { return tagsWithOrderCount.map(({ _count, id, name, userId }) => {
return { return {
id, id,
name, name,
userId,
activityCount: _count.orders, activityCount: _count.orders,
holdingCount: _count.symbolProfile holdingCount: _count.symbolProfile
}; };

1
apps/api/src/app/user/update-user-setting.dto.ts

@ -1,4 +1,5 @@
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code'; import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
import { PortfolioReportRule } from '@ghostfolio/common/interfaces';
import type { import type {
ColorScheme, ColorScheme,
DateRange, DateRange,

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

@ -70,7 +70,7 @@ export class UserService {
}, },
where: { userId: id } where: { userId: id }
}), }),
this.tagService.getByUser(id) this.tagService.getInUseByUser(id)
]); ]);
let systemMessage: SystemMessage; let systemMessage: SystemMessage;

3054
apps/api/src/helper/object.helper.spec.ts

File diff suppressed because it is too large

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

@ -76,11 +76,11 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
}; };
} }
public getSettings(aUserSettings: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency: aUserSettings.baseCurrency, baseCurrency,
isActive: aUserSettings.xRayRules[this.getKey()].isActive, isActive: xRayRules[this.getKey()].isActive,
thresholdMax: 0.5 thresholdMax: xRayRules[this.getKey()]?.thresholdMax ?? 0.5
}; };
} }
} }

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

@ -34,9 +34,9 @@ export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
}; };
} }
public getSettings(aUserSettings: UserSettings): RuleSettings { public getSettings({ xRayRules }: UserSettings): RuleSettings {
return { return {
isActive: aUserSettings.xRayRules[this.getKey()].isActive isActive: xRayRules[this.getKey()].isActive
}; };
} }
} }

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

@ -62,10 +62,10 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
}; };
} }
public getSettings(aUserSettings: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency: aUserSettings.baseCurrency, baseCurrency,
isActive: aUserSettings.xRayRules[this.getKey()].isActive isActive: xRayRules[this.getKey()].isActive
}; };
} }
} }

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

@ -62,11 +62,11 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
}; };
} }
public getSettings(aUserSettings: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency: aUserSettings.baseCurrency, baseCurrency,
isActive: aUserSettings.xRayRules[this.getKey()].isActive, isActive: xRayRules[this.getKey()].isActive,
thresholdMax: 0.5 thresholdMax: xRayRules[this.getKey()]?.thresholdMax ?? 0.5
}; };
} }
} }

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

@ -19,7 +19,7 @@ export class EmergencyFundSetup extends Rule<Settings> {
} }
public evaluate(ruleSettings: Settings) { public evaluate(ruleSettings: Settings) {
if (this.emergencyFund < ruleSettings.thresholdMin) { if (!this.emergencyFund) {
return { return {
evaluation: 'No emergency fund has been set up', evaluation: 'No emergency fund has been set up',
value: false value: false
@ -32,16 +32,14 @@ export class EmergencyFundSetup extends Rule<Settings> {
}; };
} }
public getSettings(aUserSettings: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency: aUserSettings.baseCurrency, baseCurrency,
isActive: aUserSettings.xRayRules[this.getKey()].isActive, isActive: xRayRules[this.getKey()].isActive
thresholdMin: 0
}; };
} }
} }
interface Settings extends RuleSettings { interface Settings extends RuleSettings {
baseCurrency: string; baseCurrency: string;
thresholdMin: number;
} }

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

@ -43,11 +43,11 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
}; };
} }
public getSettings(aUserSettings: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency: aUserSettings.baseCurrency, baseCurrency,
isActive: aUserSettings.xRayRules[this.getKey()].isActive, isActive: xRayRules[this.getKey()].isActive,
thresholdMax: 0.01 thresholdMax: xRayRules[this.getKey()]?.thresholdMax ?? 0.01
}; };
} }
} }

7
apps/api/src/services/tag/tag.service.ts

@ -6,15 +6,18 @@ import { Injectable } from '@nestjs/common';
export class TagService { export class TagService {
public constructor(private readonly prismaService: PrismaService) {} public constructor(private readonly prismaService: PrismaService) {}
public async get() { public async getPublic() {
return this.prismaService.tag.findMany({ return this.prismaService.tag.findMany({
orderBy: { orderBy: {
name: 'asc' name: 'asc'
},
where: {
userId: null
} }
}); });
} }
public async getByUser(userId: string) { public async getInUseByUser(userId: string) {
return this.prismaService.tag.findMany({ return this.prismaService.tag.findMany({
orderBy: { orderBy: {
name: 'asc' name: 'asc'

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

@ -31,6 +31,8 @@
class="position-fixed w-100" class="position-fixed w-100"
[currentRoute]="currentRoute" [currentRoute]="currentRoute"
[deviceType]="deviceType" [deviceType]="deviceType"
[hasPermissionToChangeDateRange]="hasPermissionToChangeDateRange"
[hasPermissionToChangeFilters]="hasPermissionToChangeFilters"
[hasTabs]="hasTabs" [hasTabs]="hasTabs"
[info]="info" [info]="info"
[pageTitle]="pageTitle" [pageTitle]="pageTitle"

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

@ -47,6 +47,7 @@ export class AppComponent implements OnDestroy, OnInit {
public canCreateAccount: boolean; public canCreateAccount: boolean;
public currentRoute: string; public currentRoute: string;
public currentSubRoute: string;
public currentYear = new Date().getFullYear(); public currentYear = new Date().getFullYear();
public deviceType: string; public deviceType: string;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
@ -54,6 +55,8 @@ export class AppComponent implements OnDestroy, OnInit {
public hasPermissionForStatistics: boolean; public hasPermissionForStatistics: boolean;
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
public hasPermissionToAccessFearAndGreedIndex: boolean; public hasPermissionToAccessFearAndGreedIndex: boolean;
public hasPermissionToChangeDateRange: boolean;
public hasPermissionToChangeFilters: boolean;
public hasTabs = false; public hasTabs = false;
public info: InfoItem; public info: InfoItem;
public pageTitle: string; public pageTitle: string;
@ -147,6 +150,35 @@ export class AppComponent implements OnDestroy, OnInit {
const urlSegmentGroup = urlTree.root.children[PRIMARY_OUTLET]; const urlSegmentGroup = urlTree.root.children[PRIMARY_OUTLET];
const urlSegments = urlSegmentGroup.segments; const urlSegments = urlSegmentGroup.segments;
this.currentRoute = urlSegments[0].path; this.currentRoute = urlSegments[0].path;
this.currentSubRoute = urlSegments[1]?.path;
if (
(this.currentRoute === 'home' && !this.currentSubRoute) ||
(this.currentRoute === 'home' &&
this.currentSubRoute === 'holdings') ||
(this.currentRoute === 'portfolio' && !this.currentSubRoute) ||
(this.currentRoute === 'zen' && !this.currentSubRoute) ||
(this.currentRoute === 'zen' && this.currentSubRoute === 'holdings')
) {
this.hasPermissionToChangeDateRange = true;
} else {
this.hasPermissionToChangeDateRange = false;
}
if (
(this.currentRoute === 'home' &&
this.currentSubRoute === 'holdings') ||
(this.currentRoute === 'portfolio' && !this.currentSubRoute) ||
(this.currentRoute === 'portfolio' &&
this.currentSubRoute === 'activities') ||
(this.currentRoute === 'portfolio' &&
this.currentSubRoute === 'allocations') ||
(this.currentRoute === 'zen' && this.currentSubRoute === 'holdings')
) {
this.hasPermissionToChangeFilters = true;
} else {
this.hasPermissionToChangeFilters = false;
}
this.hasTabs = this.hasTabs =
(this.currentRoute === this.routerLinkAbout[0].slice(1) || (this.currentRoute === this.routerLinkAbout[0].slice(1) ||
@ -182,6 +214,8 @@ export class AppComponent implements OnDestroy, OnInit {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
} }
this.changeDetectorRef.markForCheck();
}); });
this.userService.stateChanged this.userService.stateChanged

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

@ -2,7 +2,10 @@ import { ConfirmationDialogType } from '@ghostfolio/client/core/notification/con
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service'; import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config'; import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config';
import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper'; import {
getCurrencyFromSymbol,
isDerivedCurrency
} from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
AdminMarketDataItem AdminMarketDataItem
@ -74,7 +77,7 @@ export class AdminMarketDataService {
return ( return (
activitiesCount === 0 && activitiesCount === 0 &&
!isBenchmark && !isBenchmark &&
!isCurrency(getCurrencyFromSymbol(symbol)) && !isDerivedCurrency(getCurrencyFromSymbol(symbol)) &&
!symbol.startsWith(ghostfolioScraperApiSymbolPrefix) !symbol.startsWith(ghostfolioScraperApiSymbolPrefix)
); );
} }

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

@ -89,7 +89,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
[name: string]: { name: string; value: number }; [name: string]: { name: string; value: number };
}; };
public HoldingTags: { id: string; name: string }[]; public HoldingTags: { id: string; name: string; userId: string }[];
private static readonly HISTORICAL_DATA_TEMPLATE = `date;marketPrice\n${format( private static readonly HISTORICAL_DATA_TEMPLATE = `date;marketPrice\n${format(
new Date(), new Date(),
@ -123,8 +123,8 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
.fetchTags() .fetchTags()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((tags) => { .subscribe((tags) => {
this.HoldingTags = tags.map(({ id, name }) => { this.HoldingTags = tags.map(({ id, name, userId }) => {
return { id, name }; return { id, name, userId };
}); });
this.dataService.updateInfo(); this.dataService.updateInfo();

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

@ -1,7 +1,10 @@
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef,
Component, Component,
OnDestroy, OnDestroy,
OnInit OnInit
@ -15,6 +18,10 @@ import {
Validators Validators
} from '@angular/forms'; } from '@angular/forms';
import { MatDialogRef } from '@angular/material/dialog'; import { MatDialogRef } from '@angular/material/dialog';
import { uniq } from 'lodash';
import { Subject, takeUntil } from 'rxjs';
import { CreateAssetProfileDialogMode } from './interfaces/interfaces';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -25,17 +32,29 @@ import { MatDialogRef } from '@angular/material/dialog';
}) })
export class CreateAssetProfileDialog implements OnInit, OnDestroy { export class CreateAssetProfileDialog implements OnInit, OnDestroy {
public createAssetProfileForm: FormGroup; public createAssetProfileForm: FormGroup;
public mode: 'auto' | 'manual'; public mode: CreateAssetProfileDialogMode;
private customCurrencies: string[];
private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
public readonly adminService: AdminService, public readonly adminService: AdminService,
private readonly changeDetectorRef: ChangeDetectorRef,
private readonly dataService: DataService,
public readonly dialogRef: MatDialogRef<CreateAssetProfileDialog>, public readonly dialogRef: MatDialogRef<CreateAssetProfileDialog>,
public readonly formBuilder: FormBuilder public readonly formBuilder: FormBuilder
) {} ) {}
public ngOnInit() { public ngOnInit() {
this.initializeCustomCurrencies();
this.createAssetProfileForm = this.formBuilder.group( this.createAssetProfileForm = this.formBuilder.group(
{ {
addCurrency: new FormControl(null, [
Validators.maxLength(3),
Validators.minLength(3),
Validators.required
]),
addSymbol: new FormControl(null, [Validators.required]), addSymbol: new FormControl(null, [Validators.required]),
searchSymbol: new FormControl(null, [Validators.required]) searchSymbol: new FormControl(null, [Validators.required])
}, },
@ -51,34 +70,75 @@ export class CreateAssetProfileDialog implements OnInit, OnDestroy {
this.dialogRef.close(); this.dialogRef.close();
} }
public onRadioChange(mode: 'auto' | 'manual') { public onRadioChange(mode: CreateAssetProfileDialogMode) {
this.mode = mode; this.mode = mode;
} }
public onSubmit() { public onSubmit() {
this.mode === 'auto' if (this.mode === 'auto') {
? this.dialogRef.close({ this.dialogRef.close({
dataSource: dataSource:
this.createAssetProfileForm.get('searchSymbol').value.dataSource, this.createAssetProfileForm.get('searchSymbol').value.dataSource,
symbol: this.createAssetProfileForm.get('searchSymbol').value.symbol symbol: this.createAssetProfileForm.get('searchSymbol').value.symbol
});
} else if (this.mode === 'currency') {
const currency = this.createAssetProfileForm
.get('addCurrency')
.value.toUpperCase();
const currencies = uniq([...this.customCurrencies, currency]);
this.dataService
.putAdminSetting(PROPERTY_CURRENCIES, {
value: JSON.stringify(currencies)
}) })
: this.dialogRef.close({ .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.dialogRef.close();
});
} else if (this.mode === 'manual') {
this.dialogRef.close({
dataSource: 'MANUAL', dataSource: 'MANUAL',
symbol: this.createAssetProfileForm.get('addSymbol').value symbol: this.createAssetProfileForm.get('addSymbol').value
}); });
} }
}
public ngOnDestroy() {} public get showCurrencyErrorMessage() {
const addCurrencyFormControl =
this.createAssetProfileForm.get('addCurrency');
if (
addCurrencyFormControl.hasError('maxlength') ||
addCurrencyFormControl.hasError('minlength')
) {
return true;
}
return false;
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private atLeastOneValid(control: AbstractControl): ValidationErrors { private atLeastOneValid(control: AbstractControl): ValidationErrors {
const addCurrencyControl = control.get('addCurrency');
const addSymbolControl = control.get('addSymbol'); const addSymbolControl = control.get('addSymbol');
const searchSymbolControl = control.get('searchSymbol'); const searchSymbolControl = control.get('searchSymbol');
if (addSymbolControl.valid && searchSymbolControl.valid) { if (
addCurrencyControl.valid &&
addSymbolControl.valid &&
searchSymbolControl.valid
) {
return { atLeastOneValid: true }; return { atLeastOneValid: true };
} }
if ( if (
addCurrencyControl.valid ||
!addCurrencyControl ||
addSymbolControl.valid || addSymbolControl.valid ||
!addSymbolControl || !addSymbolControl ||
searchSymbolControl.valid || searchSymbolControl.valid ||
@ -89,4 +149,15 @@ export class CreateAssetProfileDialog implements OnInit, OnDestroy {
return { atLeastOneValid: true }; return { atLeastOneValid: true };
} }
private initializeCustomCurrencies() {
this.adminService
.fetchAdminData()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ settings }) => {
this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[];
this.changeDetectorRef.markForCheck();
});
}
} }

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

@ -17,6 +17,9 @@
<mat-radio-button class="ml-3" name="manual" value="manual"> <mat-radio-button class="ml-3" name="manual" value="manual">
</mat-radio-button> </mat-radio-button>
<label class="m-0" for="manual" i18n>Add Manually</label> <label class="m-0" for="manual" i18n>Add Manually</label>
<mat-radio-button class="ml-3" name="currency" value="currency">
</mat-radio-button>
<label class="m-0" for="currency" i18n>Add Currency</label>
</mat-radio-group> </mat-radio-group>
</div> </div>
@ -37,6 +40,16 @@
<input formControlName="addSymbol" matInput /> <input formControlName="addSymbol" matInput />
</mat-form-field> </mat-form-field>
</div> </div>
} @else if (mode === 'currency') {
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Currency</mat-label>
<input formControlName="addCurrency" matInput />
@if (showCurrencyErrorMessage) {
<mat-error i18n>Oops! Invalid currency.</mat-error>
}
</mat-form-field>
</div>
} }
</div> </div>
<div class="d-flex justify-content-end" mat-dialog-actions> <div class="d-flex justify-content-end" mat-dialog-actions>

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

@ -2,3 +2,5 @@ export interface CreateAssetProfileDialogParams {
deviceType: string; deviceType: string;
locale: string; locale: string;
} }
export type CreateAssetProfileDialogMode = 'auto' | 'currency' | 'manual';

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

@ -126,7 +126,10 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
if (currency) { if (currency) {
if (currency.length === 3) { if (currency.length === 3) {
const currencies = uniq([...this.customCurrencies, currency]); const currencies = uniq([
...this.customCurrencies,
currency.toUpperCase()
]);
this.putAdminSetting({ key: PROPERTY_CURRENCIES, value: currencies }); this.putAdminSetting({ key: PROPERTY_CURRENCIES, value: currencies });
} else { } else {
this.notificationService.alert({ this.notificationService.alert({

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

@ -139,7 +139,7 @@ export class AdminPlatformComponent implements OnInit, OnDestroy {
url: null url: null
} }
}, },
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', height: this.deviceType === 'mobile' ? '97.5vh' : undefined,
width: this.deviceType === 'mobile' ? '100vw' : '50rem' width: this.deviceType === 'mobile' ? '100vw' : '50rem'
}); });
@ -176,7 +176,7 @@ export class AdminPlatformComponent implements OnInit, OnDestroy {
url url
} }
}, },
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', height: this.deviceType === 'mobile' ? '97.5vh' : undefined,
width: this.deviceType === 'mobile' ? '100vw' : '50rem' width: this.deviceType === 'mobile' ? '100vw' : '50rem'
}); });

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

@ -34,6 +34,20 @@
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="userId">
<th
*matHeaderCellDef
class="px-1"
mat-header-cell
mat-sort-header="userId"
>
<ng-container i18n>User</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<span class="text-monospace">{{ element.userId }}</span>
</td>
</ng-container>
<ng-container matColumnDef="activities"> <ng-container matColumnDef="activities">
<th <th
*matHeaderCellDef *matHeaderCellDef

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

@ -36,7 +36,13 @@ export class AdminTagComponent implements OnInit, OnDestroy {
public dataSource: MatTableDataSource<Tag> = new MatTableDataSource(); public dataSource: MatTableDataSource<Tag> = new MatTableDataSource();
public deviceType: string; public deviceType: string;
public displayedColumns = ['name', 'activities', 'holdings', 'actions']; public displayedColumns = [
'name',
'userId',
'activities',
'holdings',
'actions'
];
public tags: Tag[]; public tags: Tag[];
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -138,7 +144,7 @@ export class AdminTagComponent implements OnInit, OnDestroy {
name: null name: null
} }
}, },
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', height: this.deviceType === 'mobile' ? '97.5vh' : undefined,
width: this.deviceType === 'mobile' ? '100vw' : '50rem' width: this.deviceType === 'mobile' ? '100vw' : '50rem'
}); });
@ -174,7 +180,7 @@ export class AdminTagComponent implements OnInit, OnDestroy {
name name
} }
}, },
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', height: this.deviceType === 'mobile' ? '97.5vh' : undefined,
width: this.deviceType === 'mobile' ? '100vw' : '50rem' width: this.deviceType === 'mobile' ? '100vw' : '50rem'
}); });

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

@ -121,7 +121,7 @@
matBadge="✓" matBadge="✓"
matBadgeSize="small" matBadgeSize="small"
[mat-menu-trigger-for]="assistantMenu" [mat-menu-trigger-for]="assistantMenu"
[matBadgeHidden]="!hasFilters" [matBadgeHidden]="!hasFilters || !hasPermissionToChangeFilters"
[matMenuTriggerRestoreFocus]="false" [matMenuTriggerRestoreFocus]="false"
(menuOpened)="onOpenAssistant()" (menuOpened)="onOpenAssistant()"
> >
@ -140,6 +140,8 @@
[hasPermissionToAccessAdminControl]=" [hasPermissionToAccessAdminControl]="
hasPermissionToAccessAdminControl hasPermissionToAccessAdminControl
" "
[hasPermissionToChangeDateRange]="hasPermissionToChangeDateRange"
[hasPermissionToChangeFilters]="hasPermissionToChangeFilters"
[user]="user" [user]="user"
(closed)="closeAssistant()" (closed)="closeAssistant()"
(dateRangeChanged)="onDateRangeChange($event)" (dateRangeChanged)="onDateRangeChange($event)"

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

@ -56,6 +56,8 @@ export class HeaderComponent implements OnChanges {
@Input() currentRoute: string; @Input() currentRoute: string;
@Input() deviceType: string; @Input() deviceType: string;
@Input() hasPermissionToChangeDateRange: boolean;
@Input() hasPermissionToChangeFilters: boolean;
@Input() hasTabs: boolean; @Input() hasTabs: boolean;
@Input() info: InfoItem; @Input() info: InfoItem;
@Input() pageTitle: string; @Input() pageTitle: string;
@ -203,7 +205,7 @@ export class HeaderComponent implements OnChanges {
} }
public onLogoClick() { public onLogoClick() {
if (this.currentRoute === 'home' || this.currentRoute === 'zen') { if (['home', 'zen'].includes(this.currentRoute)) {
this.layoutService.getShouldReloadSubject().next(); this.layoutService.getShouldReloadSubject().next();
} }
} }

12
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts

@ -160,10 +160,10 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
{ id: this.data.symbol, type: 'SYMBOL' } { id: this.data.symbol, type: 'SYMBOL' }
]; ];
this.tagsAvailable = tags.map(({ id, name }) => { this.tagsAvailable = tags.map((tag) => {
return { return {
id, ...tag,
name: translate(name) name: translate(tag.name)
}; };
}); });
@ -324,10 +324,10 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
this.sectors = {}; this.sectors = {};
this.SymbolProfile = SymbolProfile; this.SymbolProfile = SymbolProfile;
this.tags = tags.map(({ id, name }) => { this.tags = tags.map((tag) => {
return { return {
id, ...tag,
name: translate(name) name: translate(tag.name)
}; };
}); });

2
apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts

@ -2,6 +2,7 @@ import { PortfolioReportRule } from '@ghostfolio/common/interfaces';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component, Inject } from '@angular/core'; import { Component, Inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { import {
MAT_DIALOG_DATA, MAT_DIALOG_DATA,
@ -16,6 +17,7 @@ import { IRuleSettingsDialogParams } from './interfaces/interfaces';
@Component({ @Component({
imports: [ imports: [
CommonModule, CommonModule,
FormsModule,
MatButtonModule, MatButtonModule,
MatDialogModule, MatDialogModule,
MatFormFieldModule, MatFormFieldModule,

32
apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html

@ -1,23 +1,37 @@
<div mat-dialog-title>{{ data.rule.name }}</div> <div mat-dialog-title>{{ data.rule.name }}</div>
<div class="py-3" mat-dialog-content> <div class="py-3" mat-dialog-content>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field
appearance="outline"
class="w-100"
[ngClass]="{ 'd-none': settings.thresholdMin === undefined }"
>
<mat-label i18n>Threshold Min</mat-label> <mat-label i18n>Threshold Min</mat-label>
<input matInput name="thresholdMin" type="number" /> <input
matInput
name="thresholdMin"
type="number"
[(ngModel)]="settings.thresholdMin"
/>
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field
appearance="outline"
class="w-100"
[ngClass]="{ 'd-none': settings.thresholdMax === undefined }"
>
<mat-label i18n>Threshold Max</mat-label> <mat-label i18n>Threshold Max</mat-label>
<input matInput name="thresholdMax" type="number" /> <input
matInput
name="thresholdMax"
type="number"
[(ngModel)]="settings.thresholdMax"
/>
</mat-form-field> </mat-form-field>
</div> </div>
<div align="end" mat-dialog-actions> <div align="end" mat-dialog-actions>
<button i18n mat-button (click)="dialogRef.close()">Close</button> <button i18n mat-button (click)="dialogRef.close()">Close</button>
<button <button color="primary" mat-flat-button (click)="dialogRef.close(settings)">
color="primary"
mat-flat-button
(click)="dialogRef.close({ settings })"
>
<ng-container i18n>Save</ng-container> <ng-container i18n>Save</ng-container>
</button> </button>
</div> </div>

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

@ -62,7 +62,7 @@
<ion-icon name="ellipsis-horizontal" /> <ion-icon name="ellipsis-horizontal" />
</button> </button>
<mat-menu #rulesMenu="matMenu" xPosition="before"> <mat-menu #rulesMenu="matMenu" xPosition="before">
@if (rule?.isActive && !isEmpty(rule.settings) && false) { @if (rule?.isActive && !isEmpty(rule.settings)) {
<button mat-menu-item (click)="onCustomizeRule(rule)"> <button mat-menu-item (click)="onCustomizeRule(rule)">
<ng-container i18n>Customize</ng-container>... <ng-container i18n>Customize</ng-container>...
</button> </button>

13
apps/client/src/app/components/rule/rule.component.ts

@ -55,16 +55,15 @@ export class RuleComponent implements OnInit {
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe( .subscribe((settings: PortfolioReportRule['settings']) => {
({ settings }: { settings: PortfolioReportRule['settings'] }) => {
if (settings) { if (settings) {
console.log(settings); this.ruleUpdated.emit({
xRayRules: {
// TODO [rule.key]: settings
// this.ruleUpdated.emit(settings);
} }
});
} }
); });
} }
public onUpdateRule(rule: PortfolioReportRule) { public onUpdateRule(rule: PortfolioReportRule) {

6
apps/client/src/app/pages/faq/self-hosting/self-hosting-page.html

@ -52,8 +52,10 @@
</p> </p>
<ol> <ol>
<li>Go to the <i>Admin Control</i> panel</li> <li>Go to the <i>Admin Control</i> panel</li>
<li>Click on the <i>Add Currency</i> button</li> <li>Go to the <i>Market Data</i> section</li>
<li>Insert e.g. <code>EUR</code> in the prompt</li> <li>Click on the <i>+</i> button</li>
<li>Switch to <i>Add Currency</i></li>
<li>Insert e.g. <code>EUR</code> for Euro</li>
</ol> </ol>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>

6
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts

@ -81,10 +81,10 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.currencies = currencies; this.currencies = currencies;
this.defaultDateFormat = getDateFormatString(this.locale); this.defaultDateFormat = getDateFormatString(this.locale);
this.platforms = platforms; this.platforms = platforms;
this.tagsAvailable = tags.map(({ id, name }) => { this.tagsAvailable = tags.map((tag) => {
return { return {
id, ...tag,
name: translate(name) name: translate(tag.name)
}; };
}); });

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

@ -134,8 +134,6 @@ export class FirePageComponent implements OnDestroy, OnInit {
} }
public onRulesUpdated(event: UpdateUserSettingDto) { public onRulesUpdated(event: UpdateUserSettingDto) {
this.isLoading = true;
this.dataService this.dataService
.putUserSetting(event) .putUserSetting(event)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))

117
apps/client/src/app/pages/resources/personal-finance-tools/product-page.html

@ -9,6 +9,11 @@
>&nbsp;<strong>{{ product2.name }}</strong> >&nbsp;<strong>{{ product2.name }}</strong>
</h1> </h1>
</div> </div>
@if (product2.isArchived) {
<div class="info-container my-4 p-2 rounded text-center text-muted">
<ng-container i18n>This page has been archived.</ng-container>
</div>
}
<section class="mb-4"> <section class="mb-4">
<p i18n> <p i18n>
Are you looking for an open source alternative to Are you looking for an open source alternative to
@ -128,16 +133,36 @@
</td> </td>
<td class="mat-mdc-cell px-1 py-2"> <td class="mat-mdc-cell px-1 py-2">
@if (product1.isOpenSource) { @if (product1.isOpenSource) {
<ng-container i18n>✅ Yes</ng-container> <span
i18n
i18n-title
title="{{ product1.name }} is Open Source Software"
>✅ Yes</span
>
} @else { } @else {
<ng-container i18n>❌ No</ng-container> <span
i18n
i18n-title
title="{{ product1.name }} is not Open Source Software"
>❌ No</span
>
} }
</td> </td>
<td class="mat-mdc-cell px-1 py-2"> <td class="mat-mdc-cell px-1 py-2">
@if (product2.isOpenSource) { @if (product2.isOpenSource) {
<ng-container i18n>✅ Yes</ng-container> <span
i18n
i18n-title
title="{{ product2.name }} is Open Source Software"
>✅ Yes</span
>
} @else { } @else {
<ng-container i18n>❌ No </ng-container> <span
i18n
i18n-title
title="{{ product2.name }} is not Open Source Software"
>❌ No</span
>
} }
</td> </td>
</tr> </tr>
@ -147,16 +172,36 @@
</td> </td>
<td class="mat-mdc-cell px-1 py-2"> <td class="mat-mdc-cell px-1 py-2">
@if (product1.hasSelfHostingAbility === true) { @if (product1.hasSelfHostingAbility === true) {
<ng-container i18n>✅ Yes</ng-container> <span
i18n
i18n-title
title="{{ product1.name }} can be self-hosted"
>✅ Yes</span
>
} @else if (product1.hasSelfHostingAbility === false) { } @else if (product1.hasSelfHostingAbility === false) {
<ng-container i18n>❌ No</ng-container> <span
i18n
i18n-title
title="{{ product1.name }} cannot be self-hosted"
>❌ No</span
>
} }
</td> </td>
<td class="mat-mdc-cell px-1 py-2"> <td class="mat-mdc-cell px-1 py-2">
@if (product2.hasSelfHostingAbility === true) { @if (product2.hasSelfHostingAbility === true) {
<ng-container i18n>✅ Yes</ng-container> <span
i18n
i18n-title
title="{{ product2.name }} can be self-hosted"
>✅ Yes</span
>
} @else if (product2.hasSelfHostingAbility === false) { } @else if (product2.hasSelfHostingAbility === false) {
<ng-container i18n>❌ No</ng-container> <span
i18n
i18n-title
title="{{ product2.name }} cannot be self-hosted"
>❌ No</span
>
} }
</td> </td>
</tr> </tr>
@ -166,16 +211,36 @@
</td> </td>
<td class="mat-mdc-cell px-1 py-2"> <td class="mat-mdc-cell px-1 py-2">
@if (product1.useAnonymously === true) { @if (product1.useAnonymously === true) {
<ng-container i18n>✅ Yes</ng-container> <span
i18n
i18n-title
title="{{ product1.name }} can be used anonymously"
>✅ Yes</span
>
} @else if (product1.useAnonymously === false) { } @else if (product1.useAnonymously === false) {
<ng-container i18n>❌ No</ng-container> <span
i18n
i18n-title
title="{{ product1.name }} cannot be used anonymously"
>❌ No</span
>
} }
</td> </td>
<td class="mat-mdc-cell px-1 py-2"> <td class="mat-mdc-cell px-1 py-2">
@if (product2.useAnonymously === true) { @if (product2.useAnonymously === true) {
<ng-container i18n>✅ Yes</ng-container> <span
i18n
i18n-title
title="{{ product2.name }} can be used anonymously"
>✅ Yes</span
>
} @else if (product2.useAnonymously === false) { } @else if (product2.useAnonymously === false) {
<ng-container i18n>❌ No</ng-container> <span
i18n
i18n-title
title="{{ product2.name }} cannot be used anonymously"
>❌ No</span
>
} }
</td> </td>
</tr> </tr>
@ -185,16 +250,36 @@
</td> </td>
<td class="mat-mdc-cell px-1 py-2"> <td class="mat-mdc-cell px-1 py-2">
@if (product1.hasFreePlan === true) { @if (product1.hasFreePlan === true) {
<ng-container i18n>✅ Yes</ng-container> <span
i18n
i18n-title
title="{{ product1.name }} offers a free plan"
>✅ Yes</span
>
} @else if (product1.hasFreePlan === false) { } @else if (product1.hasFreePlan === false) {
<ng-container i18n>❌ No</ng-container> <span
i18n
i18n-title
title="{{ product1.name }} does not offer a free plan"
>❌ No</span
>
} }
</td> </td>
<td class="mat-mdc-cell px-1 py-2"> <td class="mat-mdc-cell px-1 py-2">
@if (product2.hasFreePlan === true) { @if (product2.hasFreePlan === true) {
<ng-container i18n>✅ Yes</ng-container> <span
i18n
i18n-title
title="{{ product2.name }} offers a free plan"
>✅ Yes</span
>
} @else if (product2.hasFreePlan === false) { } @else if (product2.hasFreePlan === false) {
<ng-container i18n>❌ No</ng-container> <span
i18n
i18n-title
title="{{ product2.name }} does not offer a free plan"
>❌ No</span
>
} }
</td> </td>
</tr> </tr>

6
apps/client/src/app/pages/resources/personal-finance-tools/product-page.scss

@ -11,7 +11,8 @@
} }
} }
.call-to-action { .call-to-action,
.info-container {
background-color: rgba(var(--palette-foreground-text), 0.02); background-color: rgba(var(--palette-foreground-text), 0.02);
} }
} }
@ -19,7 +20,8 @@
:host-context(.theme-dark) { :host-context(.theme-dark) {
color: rgb(var(--light-primary-text)); color: rgb(var(--light-primary-text));
.call-to-action { .call-to-action,
.info-container {
background-color: rgba(var(--palette-foreground-text-dark), 0.02); background-color: rgba(var(--palette-foreground-text-dark), 0.02);
} }
} }

548
apps/client/src/locales/messages.ca.xlf

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

548
apps/client/src/locales/messages.pl.xlf

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

548
apps/client/src/locales/messages.zh.xlf

File diff suppressed because it is too large

1
libs/common/src/lib/interfaces/product.ts

@ -3,6 +3,7 @@ export interface Product {
founded?: number; founded?: number;
hasFreePlan?: boolean; hasFreePlan?: boolean;
hasSelfHostingAbility?: boolean; hasSelfHostingAbility?: boolean;
isArchived?: boolean;
isOpenSource?: boolean; isOpenSource?: boolean;
key: string; key: string;
languages?: string[]; languages?: string[];

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

@ -31,6 +31,8 @@ export const permissions = {
enableSubscriptionInterstitial: 'enableSubscriptionInterstitial', enableSubscriptionInterstitial: 'enableSubscriptionInterstitial',
enableSystemMessage: 'enableSystemMessage', enableSystemMessage: 'enableSystemMessage',
impersonateAllUsers: 'impersonateAllUsers', impersonateAllUsers: 'impersonateAllUsers',
readPlatforms: 'readPlatforms',
readTags: 'readTags',
reportDataGlitch: 'reportDataGlitch', reportDataGlitch: 'reportDataGlitch',
toggleReadOnlyMode: 'toggleReadOnlyMode', toggleReadOnlyMode: 'toggleReadOnlyMode',
updateAccount: 'updateAccount', updateAccount: 'updateAccount',
@ -64,6 +66,8 @@ export function getPermissions(aRole: Role): string[] {
permissions.deletePlatform, permissions.deletePlatform,
permissions.deleteTag, permissions.deleteTag,
permissions.deleteUser, permissions.deleteUser,
permissions.readPlatforms,
permissions.readTags,
permissions.updateAccount, permissions.updateAccount,
permissions.updateAuthDevice, permissions.updateAuthDevice,
permissions.updateOrder, permissions.updateOrder,

4
libs/common/src/lib/personal-finance-tools.ts

@ -369,6 +369,7 @@ export const personalFinanceTools: Product[] = [
{ {
founded: 2021, founded: 2021,
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
isArchived: true,
key: 'maybe-finance', key: 'maybe-finance',
languages: ['English'], languages: ['English'],
name: 'Maybe Finance', name: 'Maybe Finance',
@ -678,10 +679,13 @@ export const personalFinanceTools: Product[] = [
slogan: 'Make Smarter Investments' slogan: 'Make Smarter Investments'
}, },
{ {
founded: 2024,
hasSelfHostingAbility: true, hasSelfHostingAbility: true,
isArchived: true,
key: 'wealthfolio', key: 'wealthfolio',
languages: ['English'], languages: ['English'],
name: 'Wealthfolio', name: 'Wealthfolio',
origin: 'Canada',
slogan: 'Desktop Investment Tracker' slogan: 'Desktop Investment Tracker'
}, },
{ {

4
libs/common/src/lib/types/x-ray-rules-settings.type.ts

@ -1,3 +1,5 @@
import { PortfolioReportRule } from '@ghostfolio/common/interfaces';
export type XRayRulesSettings = { export type XRayRulesSettings = {
AccountClusterRiskCurrentInvestment?: RuleSettings; AccountClusterRiskCurrentInvestment?: RuleSettings;
AccountClusterRiskSingleAccount?: RuleSettings; AccountClusterRiskSingleAccount?: RuleSettings;
@ -7,6 +9,6 @@ export type XRayRulesSettings = {
FeeRatioInitialInvestment?: RuleSettings; FeeRatioInitialInvestment?: RuleSettings;
}; };
interface RuleSettings { interface RuleSettings extends Pick<PortfolioReportRule, 'settings'> {
isActive: boolean; isActive: boolean;
} }

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

@ -110,6 +110,8 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
@Input() deviceType: string; @Input() deviceType: string;
@Input() hasPermissionToAccessAdminControl: boolean; @Input() hasPermissionToAccessAdminControl: boolean;
@Input() hasPermissionToChangeDateRange: boolean;
@Input() hasPermissionToChangeFilters: boolean;
@Input() user: User; @Input() user: User;
@Output() closed = new EventEmitter<void>(); @Output() closed = new EventEmitter<void>();
@ -254,8 +256,20 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
{ label: $localize`Max`, value: 'max' } { label: $localize`Max`, value: 'max' }
]); ]);
this.dateRangeFormControl.disable({ emitEvent: false });
if (this.hasPermissionToChangeDateRange) {
this.dateRangeFormControl.enable({ emitEvent: false });
}
this.dateRangeFormControl.setValue(this.user?.settings?.dateRange ?? null); this.dateRangeFormControl.setValue(this.user?.settings?.dateRange ?? null);
this.filterForm.disable({ emitEvent: false });
if (this.hasPermissionToChangeFilters) {
this.filterForm.enable({ emitEvent: false });
}
this.filterForm.setValue( this.filterForm.setValue(
{ {
account: this.user?.settings?.['filters.accounts'] ?? null, account: this.user?.settings?.['filters.accounts'] ?? null,

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

@ -150,7 +150,9 @@
<button <button
i18n i18n
mat-button mat-button
[disabled]="!hasFilter(filterForm.value)" [disabled]="
!hasFilter(filterForm.value) || !hasPermissionToChangeFilters
"
(click)="onResetFilters()" (click)="onResetFilters()"
> >
Reset Filters Reset Filters
@ -160,7 +162,7 @@
color="primary" color="primary"
i18n i18n
mat-flat-button mat-flat-button
[disabled]="!filterForm.dirty" [disabled]="!filterForm.dirty || !hasPermissionToChangeFilters"
(click)="onApplyFilters()" (click)="onApplyFilters()"
> >
Apply Filters Apply Filters

68
package-lock.json

@ -40,7 +40,7 @@
"@nestjs/platform-express": "10.1.3", "@nestjs/platform-express": "10.1.3",
"@nestjs/schedule": "3.0.2", "@nestjs/schedule": "3.0.2",
"@nestjs/serve-static": "4.0.0", "@nestjs/serve-static": "4.0.0",
"@prisma/client": "5.19.1", "@prisma/client": "5.20.0",
"@simplewebauthn/browser": "9.0.1", "@simplewebauthn/browser": "9.0.1",
"@simplewebauthn/server": "9.0.3", "@simplewebauthn/server": "9.0.3",
"@stripe/stripe-js": "3.5.0", "@stripe/stripe-js": "3.5.0",
@ -85,7 +85,7 @@
"passport": "0.7.0", "passport": "0.7.0",
"passport-google-oauth20": "2.0.0", "passport-google-oauth20": "2.0.0",
"passport-jwt": "4.0.1", "passport-jwt": "4.0.1",
"prisma": "5.19.1", "prisma": "5.20.0",
"reflect-metadata": "0.1.13", "reflect-metadata": "0.1.13",
"rxjs": "7.5.6", "rxjs": "7.5.6",
"stripe": "15.11.0", "stripe": "15.11.0",
@ -9770,9 +9770,9 @@
"dev": true "dev": true
}, },
"node_modules/@prisma/client": { "node_modules/@prisma/client": {
"version": "5.19.1", "version": "5.20.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.19.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.20.0.tgz",
"integrity": "sha512-x30GFguInsgt+4z5I4WbkZP2CGpotJMUXy+Gl/aaUjHn2o1DnLYNTA+q9XdYmAQZM8fIIkvUiA2NpgosM3fneg==", "integrity": "sha512-CLv55ZuMuUawMsxoqxGtLT3bEZoa2W8L3Qnp6rDIFWy+ZBrUcOFKdoeGPSnbBqxc3SkdxJrF+D1veN/WNynZYA==",
"hasInstallScript": true, "hasInstallScript": true,
"engines": { "engines": {
"node": ">=16.13" "node": ">=16.13"
@ -9787,43 +9787,47 @@
} }
}, },
"node_modules/@prisma/debug": { "node_modules/@prisma/debug": {
"version": "5.19.1", "version": "5.20.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.19.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.20.0.tgz",
"integrity": "sha512-lAG6A6QnG2AskAukIEucYJZxxcSqKsMK74ZFVfCTOM/7UiyJQi48v6TQ47d6qKG3LbMslqOvnTX25dj/qvclGg==" "integrity": "sha512-oCx79MJ4HSujokA8S1g0xgZUGybD4SyIOydoHMngFYiwEwYDQ5tBQkK5XoEHuwOYDKUOKRn/J0MEymckc4IgsQ==",
"license": "Apache-2.0"
}, },
"node_modules/@prisma/engines": { "node_modules/@prisma/engines": {
"version": "5.19.1", "version": "5.20.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.19.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.20.0.tgz",
"integrity": "sha512-kR/PoxZDrfUmbbXqqb8SlBBgCjvGaJYMCOe189PEYzq9rKqitQ2fvT/VJ8PDSe8tTNxhc2KzsCfCAL+Iwm/7Cg==", "integrity": "sha512-DtqkP+hcZvPEbj8t8dK5df2b7d3B8GNauKqaddRRqQBBlgkbdhJkxhoJTrOowlS3vaRt2iMCkU0+CSNn0KhqAQ==",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@prisma/debug": "5.19.1", "@prisma/debug": "5.20.0",
"@prisma/engines-version": "5.19.1-2.69d742ee20b815d88e17e54db4a2a7a3b30324e3", "@prisma/engines-version": "5.20.0-12.06fc58a368dc7be9fbbbe894adf8d445d208c284",
"@prisma/fetch-engine": "5.19.1", "@prisma/fetch-engine": "5.20.0",
"@prisma/get-platform": "5.19.1" "@prisma/get-platform": "5.20.0"
} }
}, },
"node_modules/@prisma/engines-version": { "node_modules/@prisma/engines-version": {
"version": "5.19.1-2.69d742ee20b815d88e17e54db4a2a7a3b30324e3", "version": "5.20.0-12.06fc58a368dc7be9fbbbe894adf8d445d208c284",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.19.1-2.69d742ee20b815d88e17e54db4a2a7a3b30324e3.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.20.0-12.06fc58a368dc7be9fbbbe894adf8d445d208c284.tgz",
"integrity": "sha512-xR6rt+z5LnNqTP5BBc+8+ySgf4WNMimOKXRn6xfNRDSpHvbOEmd7+qAOmzCrddEc4Cp8nFC0txU14dstjH7FXA==" "integrity": "sha512-Lg8AS5lpi0auZe2Mn4gjuCg081UZf88k3cn0RCwHgR+6cyHHpttPZBElJTHf83ZGsRNAmVCZCfUGA57WB4u4JA==",
"license": "Apache-2.0"
}, },
"node_modules/@prisma/fetch-engine": { "node_modules/@prisma/fetch-engine": {
"version": "5.19.1", "version": "5.20.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.19.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.20.0.tgz",
"integrity": "sha512-pCq74rtlOVJfn4pLmdJj+eI4P7w2dugOnnTXpRilP/6n5b2aZiA4ulJlE0ddCbTPkfHmOL9BfaRgA8o+1rfdHw==", "integrity": "sha512-JVcaPXC940wOGpCOwuqQRTz6I9SaBK0c1BAyC1pcz9xBi+dzFgUu3G/p9GV1FhFs9OKpfSpIhQfUJE9y00zhqw==",
"license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "5.19.1", "@prisma/debug": "5.20.0",
"@prisma/engines-version": "5.19.1-2.69d742ee20b815d88e17e54db4a2a7a3b30324e3", "@prisma/engines-version": "5.20.0-12.06fc58a368dc7be9fbbbe894adf8d445d208c284",
"@prisma/get-platform": "5.19.1" "@prisma/get-platform": "5.20.0"
} }
}, },
"node_modules/@prisma/get-platform": { "node_modules/@prisma/get-platform": {
"version": "5.19.1", "version": "5.20.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.19.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.20.0.tgz",
"integrity": "sha512-sCeoJ+7yt0UjnR+AXZL7vXlg5eNxaFOwC23h0KvW1YIXUoa7+W2ZcAUhoEQBmJTW4GrFqCuZ8YSP0mkDa4k3Zg==", "integrity": "sha512-8/+CehTZZNzJlvuryRgc77hZCWrUDYd/PmlZ7p2yNXtmf2Una4BWnTbak3us6WVdqoz5wmptk6IhsXdG2v5fmA==",
"license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "5.19.1" "@prisma/debug": "5.20.0"
} }
}, },
"node_modules/@redis/bloom": { "node_modules/@redis/bloom": {
@ -29429,12 +29433,12 @@
} }
}, },
"node_modules/prisma": { "node_modules/prisma": {
"version": "5.19.1", "version": "5.20.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.19.1.tgz", "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.20.0.tgz",
"integrity": "sha512-c5K9MiDaa+VAAyh1OiYk76PXOme9s3E992D7kvvIOhCrNsBQfy2mP2QAQtX0WNj140IgG++12kwZpYB9iIydNQ==", "integrity": "sha512-6obb3ucKgAnsGS9x9gLOe8qa51XxvJ3vLQtmyf52CTey1Qcez3A6W6ROH5HIz5Q5bW+0VpmZb8WBohieMFGpig==",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@prisma/engines": "5.19.1" "@prisma/engines": "5.20.0"
}, },
"bin": { "bin": {
"prisma": "build/index.js" "prisma": "build/index.js"

9
package.json

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.110.0", "version": "2.111.0",
"homepage": "https://ghostfol.io", "homepage": "https://ghostfol.io",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio", "repository": "https://github.com/ghostfolio/ghostfolio",
@ -37,6 +37,7 @@
"ng": "nx", "ng": "nx",
"nx": "nx", "nx": "nx",
"postinstall": "prisma generate", "postinstall": "prisma generate",
"prisma": "prisma",
"replace-placeholders-in-build": "node ./replace.build.js", "replace-placeholders-in-build": "node ./replace.build.js",
"start": "node dist/apps/api/main", "start": "node dist/apps/api/main",
"start:client": "nx run client:copy-assets && nx run client:serve --configuration=development-en --hmr -o", "start:client": "nx run client:copy-assets && nx run client:serve --configuration=development-en --hmr -o",
@ -46,7 +47,7 @@
"test": "npm run test:api && npm run test:common", "test": "npm run test:api && npm run test:common",
"test:api": "npx dotenv-cli -e .env.example -- nx test api", "test:api": "npx dotenv-cli -e .env.example -- nx test api",
"test:common": "npx dotenv-cli -e .env.example -- nx test common", "test:common": "npx dotenv-cli -e .env.example -- nx test common",
"test:single": "nx run api:test --test-file portfolio-calculator-novn-buy-and-sell.spec.ts", "test:single": "nx run api:test --test-file object.helper.spec.ts",
"ts-node": "ts-node", "ts-node": "ts-node",
"update": "nx migrate latest", "update": "nx migrate latest",
"watch:server": "nx run api:copy-assets && nx run api:build --watch", "watch:server": "nx run api:copy-assets && nx run api:build --watch",
@ -85,7 +86,7 @@
"@nestjs/platform-express": "10.1.3", "@nestjs/platform-express": "10.1.3",
"@nestjs/schedule": "3.0.2", "@nestjs/schedule": "3.0.2",
"@nestjs/serve-static": "4.0.0", "@nestjs/serve-static": "4.0.0",
"@prisma/client": "5.19.1", "@prisma/client": "5.20.0",
"@simplewebauthn/browser": "9.0.1", "@simplewebauthn/browser": "9.0.1",
"@simplewebauthn/server": "9.0.3", "@simplewebauthn/server": "9.0.3",
"@stripe/stripe-js": "3.5.0", "@stripe/stripe-js": "3.5.0",
@ -130,7 +131,7 @@
"passport": "0.7.0", "passport": "0.7.0",
"passport-google-oauth20": "2.0.0", "passport-google-oauth20": "2.0.0",
"passport-jwt": "4.0.1", "passport-jwt": "4.0.1",
"prisma": "5.19.1", "prisma": "5.20.0",
"reflect-metadata": "0.1.13", "reflect-metadata": "0.1.13",
"rxjs": "7.5.6", "rxjs": "7.5.6",
"stripe": "15.11.0", "stripe": "15.11.0",

11
prisma/migrations/20240928171744_added_user_to_tag/migration.sql

@ -0,0 +1,11 @@
-- DropIndex
DROP INDEX "Tag_name_key";
-- AlterTable
ALTER TABLE "Tag" ADD COLUMN "userId" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "Tag_name_userId_key" ON "Tag"("name", "userId");
-- AddForeignKey
ALTER TABLE "Tag" ADD CONSTRAINT "Tag_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

6
prisma/schema.prisma

@ -214,10 +214,13 @@ model Subscription {
model Tag { model Tag {
id String @id @default(uuid()) id String @id @default(uuid())
name String @unique name String
orders Order[] orders Order[]
userId String?
User User? @relation(fields: [userId], onDelete: Cascade, references: [id])
symbolProfile SymbolProfile[] symbolProfile SymbolProfile[]
@@unique([name, userId])
@@index([name]) @@index([name])
} }
@ -238,6 +241,7 @@ model User {
Order Order[] Order Order[]
Settings Settings? Settings Settings?
Subscription Subscription[] Subscription Subscription[]
Tag Tag[]
@@index([accessToken]) @@index([accessToken])
@@index([createdAt]) @@index([createdAt])

Loading…
Cancel
Save