Browse Source

Merge remote-tracking branch 'origin/main' into task/upgrade-to-prisma-7

pull/6027/head
KenTandrian 1 week ago
parent
commit
2d7c59014f
  1. 19
      CHANGELOG.md
  2. 1
      apps/api/src/app/info/info.service.ts
  3. 2
      apps/api/src/app/subscription/subscription.service.ts
  4. 22
      apps/api/src/helper/object.helper.spec.ts
  5. 11
      apps/api/src/helper/object.helper.ts
  6. 1
      apps/api/src/services/configuration/configuration.service.ts
  7. 11
      apps/api/src/services/data-provider/manual/manual.service.ts
  8. 1
      apps/api/src/services/interfaces/environment.interface.ts
  9. 16
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.scss
  10. 70
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  11. 233
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  12. 44
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts
  13. 5
      apps/client/src/app/services/data.service.ts
  14. 4
      libs/common/src/lib/interfaces/info-item.interface.ts
  15. 4
      libs/ui/src/lib/fire-calculator/fire-calculator.component.stories.ts
  16. 8
      libs/ui/src/lib/fire-calculator/fire-calculator.component.ts
  17. 24
      package-lock.json
  18. 5
      package.json

19
CHANGELOG.md

@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Changed
- Integrated the endpoint to get all platforms (`GET api/v1/platforms`) into the create or update account dialog
- Extracted the scraper configuration to a dedicated tab in the asset profile details dialog of the admin control panel
## 2.227.0 - 2026-01-02
### Changed
- Initialized the input properties in the _FIRE_ calculator
- Removed the deprecated public _Stripe_ key
- Upgraded `stripe` from version `18.5.0` to `20.1.0`
### Fixed
- Fixed the import of `jsonpath` to support REST APIs (`JSON`) via the scraper configuration
## 2.226.0 - 2026-01-01
### Added

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

@ -93,7 +93,6 @@ export class InfoService {
(await this.propertyService.getByKey<string[]>(
PROPERTY_COUNTRIES_OF_SUBSCRIBERS
)) ?? [];
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
}
if (this.configurationService.get('ENABLE_FEATURE_SYSTEM_MESSAGE')) {

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

@ -35,7 +35,7 @@ export class SubscriptionService {
this.stripe = new Stripe(
this.configurationService.get('STRIPE_SECRET_KEY'),
{
apiVersion: '2025-08-27.basil'
apiVersion: '2025-12-15.clover'
}
);
}

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

@ -1,4 +1,24 @@
import { redactAttributes } from './object.helper';
import { query, redactAttributes } from './object.helper';
describe('query', () => {
it('should get market price from stock API response', () => {
const object = {
currency: 'USD',
market: {
previousClose: 273.04,
price: 271.86
},
symbol: 'AAPL'
};
const result = query({
object,
pathExpression: '$.market.price'
})[0];
expect(result).toBe(271.86);
});
});
describe('redactAttributes', () => {
it('should redact provided attributes', () => {

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

@ -1,4 +1,5 @@
import { Big } from 'big.js';
import jsonpath from 'jsonpath';
import { cloneDeep, isArray, isObject } from 'lodash';
export function hasNotDefinedValuesInObject(aObject: Object): boolean {
@ -31,6 +32,16 @@ export function nullifyValuesInObjects<T>(aObjects: T[], keys: string[]): T[] {
});
}
export function query({
object,
pathExpression
}: {
object: object;
pathExpression: string;
}) {
return jsonpath.query(object, pathExpression);
}
export function redactAttributes({
isFirstRun = true,
object,

1
apps/api/src/services/configuration/configuration.service.ts

@ -102,7 +102,6 @@ export class ConfigurationService {
ROOT_URL: url({
default: environment.rootUrl
}),
STRIPE_PUBLIC_KEY: str({ default: '' }),
STRIPE_SECRET_KEY: str({ default: '' }),
TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }),
TWITTER_ACCESS_TOKEN_SECRET: str({ default: 'dummyAccessTokenSecret' }),

11
apps/api/src/services/data-provider/manual/manual.service.ts

@ -1,3 +1,4 @@
import { query } from '@ghostfolio/api/helper/object.helper';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import {
DataProviderInterface,
@ -26,7 +27,6 @@ import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
import * as cheerio from 'cheerio';
import { addDays, format, isBefore } from 'date-fns';
import * as jsonpath from 'jsonpath';
@Injectable()
export class ManualService implements DataProviderInterface {
@ -286,9 +286,14 @@ export class ManualService implements DataProviderInterface {
let value: string;
if (response.headers.get('content-type')?.includes('application/json')) {
const data = await response.json();
const object = await response.json();
value = String(jsonpath.query(data, scraperConfiguration.selector)[0]);
value = String(
query({
object,
pathExpression: scraperConfiguration.selector
})[0]
);
} else {
const $ = cheerio.load(await response.text());

1
apps/api/src/services/interfaces/environment.interface.ts

@ -52,7 +52,6 @@ export interface Environment extends CleanedEnvAccessors {
REDIS_PORT: number;
REQUEST_TIMEOUT: number;
ROOT_URL: string;
STRIPE_PUBLIC_KEY: string;
STRIPE_SECRET_KEY: string;
TWITTER_ACCESS_TOKEN: string;
TWITTER_ACCESS_TOKEN_SECRET: string;

16
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.scss

@ -13,21 +13,5 @@
right: 1rem;
top: 0;
}
.mat-expansion-panel {
--mat-expansion-container-background-color: transparent;
::ng-deep {
.mat-expansion-panel-body {
padding: 0;
}
}
.mat-expansion-panel-header {
&:hover {
--mat-expansion-header-hover-state-layer-color: transparent;
}
}
}
}
}

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

@ -38,8 +38,7 @@ import {
Inject,
OnDestroy,
OnInit,
ViewChild,
signal
ViewChild
} from '@angular/core';
import {
AbstractControl,
@ -60,7 +59,6 @@ import {
MatDialogModule,
MatDialogRef
} from '@angular/material/dialog';
import { MatExpansionModule } from '@angular/material/expansion';
import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu';
import { MatSelectModule } from '@angular/material/select';
@ -79,6 +77,7 @@ import { format } from 'date-fns';
import { StatusCodes } from 'http-status-codes';
import { addIcons } from 'ionicons';
import {
codeSlashOutline,
createOutline,
ellipsisVertical,
readerOutline,
@ -106,7 +105,6 @@ import { AssetProfileDialogParams } from './interfaces/interfaces';
MatButtonModule,
MatCheckboxModule,
MatDialogModule,
MatExpansionModule,
MatInputModule,
MatMenuModule,
MatSelectModule,
@ -233,8 +231,6 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
}
];
public scraperConfiguationIsExpanded = signal(false);
public sectors: {
[name: string]: { name: string; value: number };
};
@ -255,7 +251,13 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
private snackBar: MatSnackBar,
private userService: UserService
) {
addIcons({ createOutline, ellipsisVertical, readerOutline, serverOutline });
addIcons({
codeSlashOutline,
createOutline,
ellipsisVertical,
readerOutline,
serverOutline
});
}
public get canSaveAssetProfileIdentifier() {
@ -504,7 +506,19 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
if (!scraperConfiguration.selector || !scraperConfiguration.url) {
scraperConfiguration = undefined;
}
} catch {}
} catch (error) {
console.error($localize`Could not parse scraper configuration`, error);
this.snackBar.open(
'😞 ' + $localize`Could not parse scraper configuration`,
undefined,
{
duration: ms('3 seconds')
}
);
return;
}
try {
sectors = JSON.parse(this.assetProfileForm.get('sectors').value);
@ -538,7 +552,16 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
object: assetProfile
});
} catch (error) {
console.error(error);
console.error($localize`Could not validate form`, error);
this.snackBar.open(
'😞 ' + $localize`Could not validate form`,
undefined,
{
duration: ms('3 seconds')
}
);
return;
}
@ -550,8 +573,29 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
},
assetProfile
)
.subscribe(() => {
this.initialize();
.subscribe({
next: () => {
this.snackBar.open(
'✅ ' + $localize`Asset profile has been saved`,
undefined,
{
duration: ms('3 seconds')
}
);
this.initialize();
},
error: (error) => {
console.error($localize`Could not save asset profile`, error);
this.snackBar.open(
'😞 ' + $localize`Could not save asset profile`,
undefined,
{
duration: ms('3 seconds')
}
);
}
});
}
@ -702,8 +746,8 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
}
public onTriggerSubmitAssetProfileForm() {
if (this.assetProfileForm) {
this.assetProfileFormElement.nativeElement.requestSubmit();
if (this.assetProfileForm.valid) {
this.onSubmitAssetProfileForm();
}
}

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

@ -390,130 +390,6 @@
></textarea>
</mat-form-field>
</div>
@if (assetProfile?.dataSource === 'MANUAL') {
<div class="mb-3">
<mat-accordion class="my-3">
<mat-expansion-panel
class="shadow-none"
[expanded]="
assetProfileForm.controls.scraperConfiguration.controls
.selector.value !== '' &&
assetProfileForm.controls.scraperConfiguration.controls
.url.value !== ''
"
(closed)="scraperConfiguationIsExpanded.set(false)"
(opened)="scraperConfiguationIsExpanded.set(true)"
>
<mat-expansion-panel-header class="p-0 pr-3">
<mat-panel-title class="font-weight-bold" i18n
>Scraper Configuration</mat-panel-title
>
</mat-expansion-panel-header>
<div formGroupName="scraperConfiguration">
<div class="mt-3">
<mat-form-field
appearance="outline"
class="w-100 without-hint"
>
<mat-label i18n>Default Market Price</mat-label>
<input
formControlName="defaultMarketPrice"
matInput
type="number"
/>
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field
appearance="outline"
class="w-100 without-hint"
>
<mat-label i18n>HTTP Request Headers</mat-label>
<textarea
cdkTextareaAutosize
formControlName="headers"
matInput
type="text"
[matAutocomplete]="auto"
></textarea>
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field
appearance="outline"
class="w-100 without-hint"
>
<mat-label i18n>Locale</mat-label>
<input
formControlName="locale"
matInput
type="text"
/>
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field
appearance="outline"
class="w-100 without-hint"
>
<mat-label i18n>Mode</mat-label>
<mat-select formControlName="mode">
@for (modeValue of modeValues; track modeValue) {
<mat-option [value]="modeValue.value">{{
modeValue.viewValue
}}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field
appearance="outline"
class="w-100 without-hint"
>
<mat-label>
<ng-container i18n>Selector</ng-container>*
</mat-label>
<textarea
cdkTextareaAutosize
formControlName="selector"
matInput
type="text"
></textarea>
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field
appearance="outline"
class="w-100 without-hint"
>
<mat-label>
<ng-container i18n>Url</ng-container>*
</mat-label>
<input formControlName="url" matInput type="text" />
</mat-form-field>
</div>
<div class="my-3 text-right">
<button
color="accent"
mat-flat-button
type="button"
[disabled]="
assetProfileForm.controls.scraperConfiguration
.controls.selector.value === '' ||
assetProfileForm.controls.scraperConfiguration
.controls.url.value === ''
"
(click)="onTestMarketData()"
>
<ng-container i18n>Test</ng-container>
</button>
</div>
</div>
</mat-expansion-panel>
</mat-accordion>
</div>
}
@if (assetProfile?.dataSource === 'MANUAL') {
<div>
<mat-form-field appearance="outline" class="w-100">
@ -588,6 +464,115 @@
</div>
</div>
</mat-tab>
@if (assetProfile?.dataSource === 'MANUAL') {
<mat-tab>
<ng-template mat-tab-label>
<ion-icon name="code-slash-outline" />
<div class="d-none d-sm-block ml-2" i18n>Scraper Configuration</div>
</ng-template>
<div class="container mt-3 p-0">
<div [formGroup]="assetProfileForm">
<div formGroupName="scraperConfiguration">
<div class="mt-3">
<mat-form-field
appearance="outline"
class="w-100 without-hint"
>
<mat-label i18n>Default Market Price</mat-label>
<input
formControlName="defaultMarketPrice"
matInput
type="number"
/>
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field
appearance="outline"
class="w-100 without-hint"
>
<mat-label i18n>HTTP Request Headers</mat-label>
<textarea
cdkTextareaAutosize
formControlName="headers"
matInput
type="text"
[matAutocomplete]="auto"
></textarea>
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field
appearance="outline"
class="w-100 without-hint"
>
<mat-label i18n>Locale</mat-label>
<input formControlName="locale" matInput type="text" />
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field
appearance="outline"
class="w-100 without-hint"
>
<mat-label i18n>Mode</mat-label>
<mat-select formControlName="mode">
@for (modeValue of modeValues; track modeValue) {
<mat-option [value]="modeValue.value">{{
modeValue.viewValue
}}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field
appearance="outline"
class="w-100 without-hint"
>
<mat-label>
<ng-container i18n>Selector</ng-container>*
</mat-label>
<textarea
cdkTextareaAutosize
formControlName="selector"
matInput
type="text"
></textarea>
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field
appearance="outline"
class="w-100 without-hint"
>
<mat-label>
<ng-container i18n>Url</ng-container>*
</mat-label>
<input formControlName="url" matInput type="text" />
</mat-form-field>
</div>
<div class="my-3 text-right">
<button
color="accent"
mat-flat-button
type="button"
[disabled]="
assetProfileForm.controls.scraperConfiguration.controls
.selector.value === '' ||
assetProfileForm.controls.scraperConfiguration.controls
.url.value === ''
"
(click)="onTestMarketData()"
>
<ng-container i18n>Test</ng-container>
</button>
</div>
</div>
</div>
</div>
</mat-tab>
}
</mat-tab-group>
</div>

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

@ -59,7 +59,7 @@ export class GfCreateOrUpdateAccountDialogComponent implements OnDestroy {
public accountForm: FormGroup;
public currencies: string[] = [];
public filteredPlatforms: Observable<Platform[]>;
public platforms: Platform[];
public platforms: Platform[] = [];
private unsubscribeSubject = new Subject<void>();
@ -71,10 +71,8 @@ export class GfCreateOrUpdateAccountDialogComponent implements OnDestroy {
) {}
public ngOnInit() {
const { currencies, platforms } = this.dataService.fetchInfo();
const { currencies } = this.dataService.fetchInfo();
this.currencies = currencies;
this.platforms = platforms;
this.accountForm = this.formBuilder.group({
accountId: [{ disabled: true, value: this.data.account.id }],
@ -83,23 +81,33 @@ export class GfCreateOrUpdateAccountDialogComponent implements OnDestroy {
currency: [this.data.account.currency, Validators.required],
isExcluded: [this.data.account.isExcluded],
name: [this.data.account.name, Validators.required],
platformId: [
this.platforms.find(({ id }) => {
return id === this.data.account.platformId;
}),
this.autocompleteObjectValidator()
]
platformId: [null, this.autocompleteObjectValidator()]
});
this.filteredPlatforms = this.accountForm
.get('platformId')
.valueChanges.pipe(
startWith(''),
map((value) => {
const name = typeof value === 'string' ? value : value?.name;
return name ? this.filter(name as string) : this.platforms.slice();
})
this.dataService.fetchPlatforms().subscribe(({ platforms }) => {
this.platforms = platforms;
const selectedPlatform = this.platforms.find(({ id }) => {
return id === this.data.account.platformId;
});
this.accountForm.patchValue(
{
platformId: selectedPlatform
},
{ emitEvent: false }
);
this.filteredPlatforms = this.accountForm
.get('platformId')
.valueChanges.pipe(
startWith(''),
map((value) => {
const name = typeof value === 'string' ? value : value?.name;
return name ? this.filter(name as string) : this.platforms.slice();
})
);
});
}
public autoCompleteCheck() {

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

@ -42,6 +42,7 @@ import {
MarketDataDetailsResponse,
MarketDataOfMarketsResponse,
OAuthResponse,
PlatformsResponse,
PortfolioDetails,
PortfolioDividendsResponse,
PortfolioHoldingResponse,
@ -521,6 +522,10 @@ export class DataService {
);
}
public fetchPlatforms() {
return this.http.get<PlatformsResponse>('/api/v1/platforms');
}
public fetchPortfolioDetails({
filters,
withMarkets = false

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

@ -18,9 +18,5 @@ export interface InfoItem {
platforms: Platform[];
statistics: Statistics;
/** @deprecated */
stripePublicKey?: string;
subscriptionOffer?: SubscriptionOffer;
}

4
libs/ui/src/lib/fire-calculator/fire-calculator.component.stories.ts

@ -44,8 +44,10 @@ type Story = StoryObj<GfFireCalculatorComponent>;
export const Simple: Story = {
args: {
annualInterestRate: 5,
currency: 'USD',
fireWealth: 50000,
locale: locale
locale: locale,
savingsRate: 1000
}
};

8
libs/ui/src/lib/fire-calculator/fire-calculator.component.ts

@ -77,16 +77,16 @@ import { FireCalculatorService } from './fire-calculator.service';
templateUrl: './fire-calculator.component.html'
})
export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
@Input() annualInterestRate: number;
@Input() annualInterestRate = 0;
@Input() colorScheme: ColorScheme;
@Input() currency: string;
@Input() deviceType: string;
@Input() fireWealth: number;
@Input() fireWealth = 0;
@Input() hasPermissionToUpdateUserSettings: boolean;
@Input() locale = getLocale();
@Input() projectedTotalAmount: number;
@Input() projectedTotalAmount = 0;
@Input() retirementDate: Date;
@Input() savingsRate: number;
@Input() savingsRate = 0;
@Output() annualInterestRateChanged = new EventEmitter<number>();
@Output() calculationCompleted =

24
package-lock.json

@ -1,12 +1,12 @@
{
"name": "ghostfolio",
"version": "2.226.0",
"version": "2.227.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ghostfolio",
"version": "2.226.0",
"version": "2.227.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
@ -85,7 +85,7 @@
"passport-openidconnect": "0.1.2",
"reflect-metadata": "0.2.2",
"rxjs": "7.8.1",
"stripe": "18.5.0",
"stripe": "20.1.0",
"svgmap": "2.14.0",
"tablemark": "4.1.0",
"twitter-api-v2": "1.27.0",
@ -125,6 +125,7 @@
"@types/big.js": "6.2.2",
"@types/google-spreadsheet": "3.1.5",
"@types/jest": "30.0.0",
"@types/jsonpath": "0.2.4",
"@types/lodash": "4.17.20",
"@types/node": "22.15.17",
"@types/papaparse": "5.3.7",
@ -12655,6 +12656,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/jsonpath": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/@types/jsonpath/-/jsonpath-0.2.4.tgz",
"integrity": "sha512-K3hxB8Blw0qgW6ExKgMbXQv2UPZBoE2GqLpVY+yr7nMD2Pq86lsuIzyAaiQ7eMqFL5B6di6pxSkogLJEyEHoGA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
@ -32050,18 +32058,18 @@
}
},
"node_modules/stripe": {
"version": "18.5.0",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-18.5.0.tgz",
"integrity": "sha512-Hp+wFiEQtCB0LlNgcFh5uVyKznpDjzyUZ+CNVEf+I3fhlYvh7rZruIg+jOwzJRCpy0ZTPMjlzm7J2/M2N6d+DA==",
"version": "20.1.0",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-20.1.0.tgz",
"integrity": "sha512-o1VNRuMkY76ZCq92U3EH3/XHm/WHp7AerpzDs4Zyo8uE5mFL4QUcv/2SudWsSnhBSp4moO2+ZoGCZ7mT8crPmQ==",
"license": "MIT",
"dependencies": {
"qs": "^6.11.0"
},
"engines": {
"node": ">=12.*"
"node": ">=16"
},
"peerDependencies": {
"@types/node": ">=12.x.x"
"@types/node": ">=16"
},
"peerDependenciesMeta": {
"@types/node": {

5
package.json

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "2.226.0",
"version": "2.227.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio",
@ -129,7 +129,7 @@
"passport-openidconnect": "0.1.2",
"reflect-metadata": "0.2.2",
"rxjs": "7.8.1",
"stripe": "18.5.0",
"stripe": "20.1.0",
"svgmap": "2.14.0",
"tablemark": "4.1.0",
"twitter-api-v2": "1.27.0",
@ -169,6 +169,7 @@
"@types/big.js": "6.2.2",
"@types/google-spreadsheet": "3.1.5",
"@types/jest": "30.0.0",
"@types/jsonpath": "0.2.4",
"@types/lodash": "4.17.20",
"@types/node": "22.15.17",
"@types/papaparse": "5.3.7",

Loading…
Cancel
Save