Compare commits

...

5 Commits

Author SHA1 Message Date
Thomas Kaul 725dae2a01
Release 3.52.0 (#7636) 3 days ago
Thomas Kaul 4a65e44f5b
Task/restrict creation of tags to unique names in tags selector (#7635) 3 days ago
Thomas Kaul e5273cc91a
Bugfix/incorrect error log output when deleting activities (#7629) 3 days ago
Thomas Kaul aeaec40c8f
Bugfix/missing loading indicator in benchmarks table on markets page (#7634) 3 days ago
Thomas Kaul e14793c698
Bugfix/dividend values in base currency (#7633) 3 days ago
  1. 11
      CHANGELOG.md
  2. 366
      apps/api/src/app/activities/activities.service.ts
  3. 3
      apps/api/src/app/portfolio/portfolio.controller.ts
  4. 12
      apps/api/src/app/portfolio/portfolio.service.ts
  5. 36
      apps/api/src/services/tag/tag.service.ts
  6. 13
      apps/client/src/app/components/home-watchlist/home-watchlist.component.ts
  7. 11
      apps/client/src/app/components/markets/markets.component.ts
  8. 2
      apps/client/src/app/components/markets/markets.html
  9. 2
      libs/ui/src/lib/benchmark/benchmark.component.html
  10. 13
      libs/ui/src/lib/benchmark/benchmark.component.ts
  11. 6
      libs/ui/src/lib/tags-selector/tags-selector.component.html
  12. 36
      libs/ui/src/lib/tags-selector/tags-selector.component.ts
  13. 4
      package-lock.json
  14. 2
      package.json

11
CHANGELOG.md

@ -5,7 +5,7 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased ## 3.52.0 - 2026-08-15
### Added ### Added
@ -15,10 +15,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Restricted the creation of tags to unique names in the tags selector component
- Changed the redaction of the monetary values in impersonation mode to be based on the scopes of the access - Changed the redaction of the monetary values in impersonation mode to be based on the scopes of the access
- Deprecated the `permissions` attribute of the access in favor of the scopes - Deprecated the `permissions` attribute of the access in favor of the scopes
- Extended the `GET api/v1/access` endpoint by the scopes - Extended the `GET api/v1/access` endpoint by the scopes
- Extended the `GET api/v1/user` endpoint by the scopes - Extended the `GET api/v1/user` endpoint by the scopes
- Improved the performance of deleting activities by loading only the required data
### Fixed
- Fixed the missing currency conversion of the dividends on the analysis page
- Fixed the missing error state in the watchlist
- Fixed the missing loading indicator in the benchmarks of the markets overview
- Fixed the incorrect error log output when deleting activities
## 3.51.0 - 2026-08-14 ## 3.51.0 - 2026-08-14

366
apps/api/src/app/activities/activities.service.ts

@ -393,36 +393,32 @@ export class ActivitiesService {
types?: ActivityType[]; types?: ActivityType[];
userId: string; userId: string;
}): Promise<number> { }): Promise<number> {
const { activities } = await this.getActivities({ const where = this.getWhereClause({
endDate, endDate,
filters, filters,
startDate, startDate,
types, types,
userId, userId,
includeDrafts: true, includeDrafts: true,
userCurrency: undefined,
withExcludedAccountsAndActivities: true withExcludedAccountsAndActivities: true
}); });
const { count } = await this.prismaService.order.deleteMany({ const activities = await this.prismaService.order.findMany({
where: { where,
id: { distinct: ['symbolProfileId'],
in: activities.map(({ id }) => { select: { symbolProfileId: true }
return id;
})
}
}
}); });
const symbolProfiles = const { count } = await this.prismaService.order.deleteMany({ where });
await this.symbolProfileService.getSymbolProfilesByIds(
const [benchmarkAssetProfiles, symbolProfiles] = await Promise.all([
this.benchmarkService.getBenchmarkAssetProfiles(),
this.symbolProfileService.getSymbolProfilesByIds(
activities.map(({ symbolProfileId }) => { activities.map(({ symbolProfileId }) => {
return symbolProfileId; return symbolProfileId;
}) })
); )
]);
const benchmarkAssetProfiles =
await this.benchmarkService.getBenchmarkAssetProfiles();
for (const { for (const {
activitiesCount, activitiesCount,
@ -627,164 +623,19 @@ export class ActivitiesService {
{ date: 'asc' } { date: 'asc' }
]; ];
const andConditions: Prisma.OrderWhereInput[] = [];
const where: Prisma.OrderWhereInput = { userId, AND: andConditions };
if (endDate) {
andConditions.push({ date: { lte: endDate } });
}
if (startDate) {
andConditions.push({ date: { gt: startDate } });
}
const {
ACCOUNT: filtersByAccount = [],
ASSET_CLASS: filtersByAssetClass = [],
DATA_SOURCE: [filterByDataSource] = [],
SEARCH_QUERY: [filterBySearchQuery] = [],
SYMBOL: [filterBySymbol] = [],
TAG: filtersByTag = []
} = groupBy(filters, ({ type }) => {
return type;
});
if (filtersByAccount.length > 0) {
where.accountId = {
in: filtersByAccount.map(({ id }) => {
return id;
})
};
}
const isFilteredByDraftTag = filtersByTag.some(({ id }) => {
return id === TAG_ID_DRAFT;
});
if (includeDrafts === false && !isFilteredByDraftTag) {
andConditions.push(WHERE_ACTIVITY_NOT_DRAFT);
}
if (filtersByAssetClass.length > 0) {
where.SymbolProfile = {
OR: [
{
AND: [
{
OR: filtersByAssetClass.map(({ id }) => {
return { assetClass: AssetClass[id] };
})
},
{
OR: [
{ assetProfileOverrides: { is: null } },
{ assetProfileOverrides: { assetClass: null } }
]
}
]
},
{
assetProfileOverrides: {
OR: filtersByAssetClass.map(({ id }) => {
return { assetClass: AssetClass[id] };
})
}
}
]
};
}
if (filterByDataSource && filterBySymbol) {
if (where.SymbolProfile) {
where.SymbolProfile = {
AND: [
where.SymbolProfile,
{
AND: [
{ dataSource: filterByDataSource.id as DataSource },
{ symbol: filterBySymbol.id }
]
}
]
};
} else {
where.SymbolProfile = {
AND: [
{ dataSource: filterByDataSource.id as DataSource },
{ symbol: filterBySymbol.id }
]
};
}
}
if (filterBySearchQuery) {
const searchQueryWhereInput: Prisma.SymbolProfileWhereInput[] = [
{ id: { mode: 'insensitive', startsWith: filterBySearchQuery.id } },
{ isin: { mode: 'insensitive', startsWith: filterBySearchQuery.id } },
{ name: { mode: 'insensitive', startsWith: filterBySearchQuery.id } },
{ symbol: { mode: 'insensitive', startsWith: filterBySearchQuery.id } }
];
if (where.SymbolProfile) {
where.SymbolProfile = {
AND: [
where.SymbolProfile,
{
OR: searchQueryWhereInput
}
]
};
} else {
where.SymbolProfile = {
OR: searchQueryWhereInput
};
}
}
if (filtersByTag.length > 0) {
andConditions.push({
OR: [
{
tags: {
some: {
OR: filtersByTag.map(({ id }) => {
return { id };
})
}
}
},
{
account: {
tags: {
some: {
OR: filtersByTag.map(({ id }) => {
return { tagId: id };
})
}
}
}
}
]
});
}
if (sortColumn) { if (sortColumn) {
orderBy = [{ [sortColumn]: sortDirection }]; orderBy = [{ [sortColumn]: sortDirection }];
} }
if (types?.length > 0) { const where = this.getWhereClause({
where.type = { in: types }; endDate,
} filters,
includeDrafts,
if (withExcludedAccountsAndActivities === false) { startDate,
where.OR = [{ account: null }, { account: WHERE_ACCOUNT_NOT_EXCLUDED }]; types,
userId,
where.tags = { withExcludedAccountsAndActivities
none: { });
id: TAG_ID_EXCLUDE_FROM_ANALYSIS
}
};
}
const [orders, count] = await Promise.all([ const [orders, count] = await Promise.all([
this.orders({ this.orders({
@ -1113,6 +964,181 @@ export class ActivitiesService {
return activity; return activity;
} }
private getWhereClause({
endDate,
filters,
includeDrafts,
startDate,
types,
userId,
withExcludedAccountsAndActivities
}: {
endDate?: Date;
filters?: Filter[];
includeDrafts: boolean;
startDate?: Date;
types?: ActivityType[];
userId: string;
withExcludedAccountsAndActivities: boolean;
}): Prisma.OrderWhereInput {
const andConditions: Prisma.OrderWhereInput[] = [];
const where: Prisma.OrderWhereInput = { userId, AND: andConditions };
if (endDate) {
andConditions.push({ date: { lte: endDate } });
}
if (startDate) {
andConditions.push({ date: { gt: startDate } });
}
const {
ACCOUNT: filtersByAccount = [],
ASSET_CLASS: filtersByAssetClass = [],
DATA_SOURCE: [filterByDataSource] = [],
SEARCH_QUERY: [filterBySearchQuery] = [],
SYMBOL: [filterBySymbol] = [],
TAG: filtersByTag = []
} = groupBy(filters, ({ type }) => {
return type;
});
if (filtersByAccount.length > 0) {
where.accountId = {
in: filtersByAccount.map(({ id }) => {
return id;
})
};
}
const isFilteredByDraftTag = filtersByTag.some(({ id }) => {
return id === TAG_ID_DRAFT;
});
if (includeDrafts === false && !isFilteredByDraftTag) {
andConditions.push(WHERE_ACTIVITY_NOT_DRAFT);
}
if (filtersByAssetClass.length > 0) {
where.SymbolProfile = {
OR: [
{
AND: [
{
OR: filtersByAssetClass.map(({ id }) => {
return { assetClass: AssetClass[id] };
})
},
{
OR: [
{ assetProfileOverrides: { is: null } },
{ assetProfileOverrides: { assetClass: null } }
]
}
]
},
{
assetProfileOverrides: {
OR: filtersByAssetClass.map(({ id }) => {
return { assetClass: AssetClass[id] };
})
}
}
]
};
}
if (filterByDataSource && filterBySymbol) {
if (where.SymbolProfile) {
where.SymbolProfile = {
AND: [
where.SymbolProfile,
{
AND: [
{ dataSource: filterByDataSource.id as DataSource },
{ symbol: filterBySymbol.id }
]
}
]
};
} else {
where.SymbolProfile = {
AND: [
{ dataSource: filterByDataSource.id as DataSource },
{ symbol: filterBySymbol.id }
]
};
}
}
if (filterBySearchQuery) {
const searchQueryWhereInput: Prisma.SymbolProfileWhereInput[] = [
{ id: { mode: 'insensitive', startsWith: filterBySearchQuery.id } },
{ isin: { mode: 'insensitive', startsWith: filterBySearchQuery.id } },
{ name: { mode: 'insensitive', startsWith: filterBySearchQuery.id } },
{ symbol: { mode: 'insensitive', startsWith: filterBySearchQuery.id } }
];
if (where.SymbolProfile) {
where.SymbolProfile = {
AND: [
where.SymbolProfile,
{
OR: searchQueryWhereInput
}
]
};
} else {
where.SymbolProfile = {
OR: searchQueryWhereInput
};
}
}
if (filtersByTag.length > 0) {
andConditions.push({
OR: [
{
tags: {
some: {
OR: filtersByTag.map(({ id }) => {
return { id };
})
}
}
},
{
account: {
tags: {
some: {
OR: filtersByTag.map(({ id }) => {
return { tagId: id };
})
}
}
}
}
]
});
}
if (types?.length > 0) {
where.type = { in: types };
}
if (withExcludedAccountsAndActivities === false) {
where.OR = [{ account: null }, { account: WHERE_ACCOUNT_NOT_EXCLUDED }];
where.tags = {
none: {
id: TAG_ID_EXCLUDE_FROM_ANALYSIS
}
};
}
return where;
}
private async orders(params: { private async orders(params: {
include?: Prisma.OrderInclude; include?: Prisma.OrderInclude;
skip?: number; skip?: number;

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

@ -352,8 +352,7 @@ export class PortfolioController {
let dividends = this.portfolioService.getDividends({ let dividends = this.portfolioService.getDividends({
activities, activities,
groupBy, groupBy
userCurrency
}); });
if ( if (

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

@ -358,21 +358,15 @@ export class PortfolioService {
public getDividends({ public getDividends({
activities, activities,
groupBy, groupBy
userCurrency
}: { }: {
activities: Activity[]; activities: Activity[];
groupBy?: GroupBy; groupBy?: GroupBy;
userCurrency: string;
}): InvestmentItem[] { }): InvestmentItem[] {
let dividends = activities.map(({ currency, date, value }) => { let dividends = activities.map(({ date, valueInBaseCurrency }) => {
return { return {
date: format(date, DATE_FORMAT), date: format(date, DATE_FORMAT),
investment: this.exchangeRateDataService.toCurrency( investment: valueInBaseCurrency
value,
currency,
userCurrency
)
}; };
}); });

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

@ -10,9 +10,13 @@ export class TagService {
public constructor(private readonly prismaService: PrismaService) {} public constructor(private readonly prismaService: PrismaService) {}
public async createTag(data: Prisma.TagCreateInput) { public async createTag(data: Prisma.TagCreateInput) {
return this.prismaService.tag.create({ try {
data return await this.prismaService.tag.create({
}); data
});
} catch (error) {
throw this.getExceptionForError(error);
}
} }
public async deleteTag(where: Prisma.TagWhereUniqueInput): Promise<Tag> { public async deleteTag(where: Prisma.TagWhereUniqueInput): Promise<Tag> {
@ -121,10 +125,14 @@ export class TagService {
data: Prisma.TagUpdateInput; data: Prisma.TagUpdateInput;
where: Prisma.TagWhereUniqueInput; where: Prisma.TagWhereUniqueInput;
}): Promise<Tag> { }): Promise<Tag> {
return this.prismaService.tag.update({ try {
data, return await this.prismaService.tag.update({
where data,
}); where
});
} catch (error) {
throw this.getExceptionForError(error);
}
} }
public async validateTagIds({ public async validateTagIds({
@ -180,4 +188,18 @@ export class TagService {
return this.validateTagIds({ tagIds, userId }); return this.validateTagIds({ tagIds, userId });
} }
private getExceptionForError(error: unknown) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
return new HttpException(
getReasonPhrase(StatusCodes.CONFLICT),
StatusCodes.CONFLICT
);
}
return error;
}
} }

13
apps/client/src/app/components/home-watchlist/home-watchlist.component.ts

@ -128,10 +128,17 @@ export class GfHomeWatchlistComponent implements OnInit {
this.dataService this.dataService
.fetchWatchlist() .fetchWatchlist()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ watchlist }) => { .subscribe({
this.watchlist = watchlist; error: () => {
this.watchlist = [];
this.changeDetectorRef.markForCheck();
},
next: ({ watchlist }) => {
this.watchlist = watchlist ?? [];
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}
}); });
} }

11
apps/client/src/app/components/markets/markets.component.ts

@ -43,7 +43,7 @@ import { DeviceDetectorService } from 'ngx-device-detector';
templateUrl: './markets.html' templateUrl: './markets.html'
}) })
export class GfMarketsComponent implements OnInit { export class GfMarketsComponent implements OnInit {
protected readonly benchmarks = signal<Benchmark[]>([]); protected readonly benchmarks = signal<Benchmark[] | undefined>(undefined);
protected readonly deviceType = computed( protected readonly deviceType = computed(
() => this.deviceDetectorService.deviceInfo().deviceType () => this.deviceDetectorService.deviceInfo().deviceType
@ -117,8 +117,13 @@ export class GfMarketsComponent implements OnInit {
this.dataService this.dataService
.fetchBenchmarks() .fetchBenchmarks()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ benchmarks }) => { .subscribe({
this.benchmarks.set(benchmarks); error: () => {
this.benchmarks.set([]);
},
next: ({ benchmarks }) => {
this.benchmarks.set(benchmarks ?? []);
}
}); });
} }

2
apps/client/src/app/components/markets/markets.html

@ -56,7 +56,7 @@
[showSymbol]="false" [showSymbol]="false"
[user]="user" [user]="user"
/> />
@if (benchmarks()?.length > 0) { @if (benchmarks()?.length) {
<div <div
class="gf-text-wrap-balance line-height-1 mt-3 text-center text-muted" class="gf-text-wrap-balance line-height-1 mt-3 text-center text-muted"
> >

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

@ -216,7 +216,7 @@
</table> </table>
</div> </div>
@if (isLoading) { @if (isLoading()) {
<ngx-skeleton-loader <ngx-skeleton-loader
animation="pulse" animation="pulse"
class="px-4 py-3" class="px-4 py-3"

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

@ -63,7 +63,7 @@ import { BenchmarkDetailDialogParams } from './benchmark-detail-dialog/interface
templateUrl: './benchmark.component.html' templateUrl: './benchmark.component.html'
}) })
export class GfBenchmarkComponent { export class GfBenchmarkComponent {
public readonly benchmarks = input.required<Benchmark[]>(); public readonly benchmarks = input<Benchmark[]>();
public readonly deviceType = input.required<string>(); public readonly deviceType = input.required<string>();
public readonly hasPermissionToDeleteItem = input<boolean>(); public readonly hasPermissionToDeleteItem = input<boolean>();
public readonly locale = input(getLocale()); public readonly locale = input(getLocale());
@ -76,6 +76,7 @@ export class GfBenchmarkComponent {
protected readonly sort = viewChild(MatSort); protected readonly sort = viewChild(MatSort);
protected readonly dataSource = new MatTableDataSource<Benchmark>([]); protected readonly dataSource = new MatTableDataSource<Benchmark>([]);
protected readonly displayedColumns = computed(() => { protected readonly displayedColumns = computed(() => {
return [ return [
...(this.showIcon() ? ['icon'] : []), ...(this.showIcon() ? ['icon'] : []),
@ -89,7 +90,11 @@ export class GfBenchmarkComponent {
'actions' 'actions'
]; ];
}); });
protected isLoading = true;
protected readonly isLoading = computed(() => {
return !this.benchmarks();
});
protected readonly isNumber = isNumber; protected readonly isNumber = isNumber;
protected readonly resolveMarketCondition = resolveMarketCondition; protected readonly resolveMarketCondition = resolveMarketCondition;
protected readonly round = round; protected readonly round = round;
@ -110,8 +115,8 @@ export class GfBenchmarkComponent {
this.dataSource.sortingDataAccessor = getLowercase; this.dataSource.sortingDataAccessor = getLowercase;
this.dataSource.sort = this.sort() ?? null; this.dataSource.sort = this.sort() ?? null;
} else {
this.isLoading = false; this.dataSource.data = [];
} }
}); });

6
libs/ui/src/lib/tags-selector/tags-selector.component.html

@ -55,12 +55,12 @@
</mat-option> </mat-option>
} }
@if (hasPermissionToCreateTag && tagInputControl.value) { @if (hasPermissionToCreateTag && tagNameToCreate()) {
<mat-option [value]="tagInputControl.value.trim()"> <mat-option [value]="tagNameToCreate()">
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="add-circle-outline" /> <ion-icon class="mr-2" name="add-circle-outline" />
<ng-container i18n>Create</ng-container> "{{ <ng-container i18n>Create</ng-container> "{{
tagInputControl.value.trim() tagNameToCreate()
}}" }}"
</span> </span>
</mat-option> </mat-option>

36
libs/ui/src/lib/tags-selector/tags-selector.component.ts

@ -72,6 +72,7 @@ export class GfTagsSelectorComponent
); );
public readonly separatorKeysCodes: number[] = [COMMA, ENTER]; public readonly separatorKeysCodes: number[] = [COMMA, ENTER];
public readonly tagInputControl = new FormControl(''); public readonly tagInputControl = new FormControl('');
public readonly tagNameToCreate = signal<string | null>(null);
public readonly tagsSelected = signal<SelectedTag[]>([]); public readonly tagsSelected = signal<SelectedTag[]>([]);
private readonly tagInput = private readonly tagInput =
@ -80,8 +81,8 @@ export class GfTagsSelectorComponent
public constructor() { public constructor() {
this.tagInputControl.valueChanges this.tagInputControl.valueChanges
.pipe(takeUntilDestroyed()) .pipe(takeUntilDestroyed())
.subscribe((value) => { .subscribe(() => {
this.filteredOptions.next(this.filterTags(value ?? '')); this.updateFilters();
}); });
addIcons({ addCircleOutline, closeOutline }); addIcons({ addCircleOutline, closeOutline });
@ -161,9 +162,8 @@ export class GfTagsSelectorComponent
this.updateFilters(); this.updateFilters();
} }
private filterTags(query: string = ''): SelectedTag[] { private filterTags(query: string): SelectedTag[] {
const tags = this.tagsSelected() ?? []; const tagIds = this.getTagsSelectedAndReadOnly().map(({ id }) => {
const tagIds = [...tags, ...(this.tagsReadOnly ?? [])].map(({ id }) => {
return id; return id;
}); });
@ -179,6 +179,27 @@ export class GfTagsSelectorComponent
}); });
} }
private getTagNameToCreate(query: string): string | null {
const name = query.trim();
if (!name) {
return null;
}
const isExistingTagName = [
...(this.tagsAvailable ?? []),
...this.getTagsSelectedAndReadOnly()
].some((tag) => {
return tag.name.toLowerCase() === name.toLowerCase();
});
return isExistingTagName ? null : name;
}
private getTagsSelectedAndReadOnly(): SelectedTag[] {
return [...this.tagsSelected(), ...(this.tagsReadOnly ?? [])];
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
private onChange = (_value: SelectedTag[]): void => { private onChange = (_value: SelectedTag[]): void => {
// ControlValueAccessor onChange callback // ControlValueAccessor onChange callback
@ -189,6 +210,9 @@ export class GfTagsSelectorComponent
}; };
private updateFilters() { private updateFilters() {
this.filteredOptions.next(this.filterTags()); const query = this.tagInputControl.value ?? '';
this.filteredOptions.next(this.filterTags(query));
this.tagNameToCreate.set(this.getTagNameToCreate(query));
} }
} }

4
package-lock.json

@ -1,12 +1,12 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.51.0", "version": "3.52.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.51.0", "version": "3.52.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.51.0", "version": "3.52.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",

Loading…
Cancel
Save