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. 26
      apps/api/src/services/tag/tag.service.ts
  6. 11
      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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
## 3.52.0 - 2026-08-15
### Added
@ -15,10 +15,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### 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
- 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/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

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

@ -393,36 +393,32 @@ export class ActivitiesService {
types?: ActivityType[];
userId: string;
}): Promise<number> {
const { activities } = await this.getActivities({
const where = this.getWhereClause({
endDate,
filters,
startDate,
types,
userId,
includeDrafts: true,
userCurrency: undefined,
withExcludedAccountsAndActivities: true
});
const { count } = await this.prismaService.order.deleteMany({
where: {
id: {
in: activities.map(({ id }) => {
return id;
})
}
}
const activities = await this.prismaService.order.findMany({
where,
distinct: ['symbolProfileId'],
select: { symbolProfileId: true }
});
const symbolProfiles =
await this.symbolProfileService.getSymbolProfilesByIds(
const { count } = await this.prismaService.order.deleteMany({ where });
const [benchmarkAssetProfiles, symbolProfiles] = await Promise.all([
this.benchmarkService.getBenchmarkAssetProfiles(),
this.symbolProfileService.getSymbolProfilesByIds(
activities.map(({ symbolProfileId }) => {
return symbolProfileId;
})
);
const benchmarkAssetProfiles =
await this.benchmarkService.getBenchmarkAssetProfiles();
)
]);
for (const {
activitiesCount,
@ -627,164 +623,19 @@ export class ActivitiesService {
{ 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) {
orderBy = [{ [sortColumn]: sortDirection }];
}
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
}
};
}
const where = this.getWhereClause({
endDate,
filters,
includeDrafts,
startDate,
types,
userId,
withExcludedAccountsAndActivities
});
const [orders, count] = await Promise.all([
this.orders({
@ -1113,6 +964,181 @@ export class ActivitiesService {
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: {
include?: Prisma.OrderInclude;
skip?: number;

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

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

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

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

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

@ -10,9 +10,13 @@ export class TagService {
public constructor(private readonly prismaService: PrismaService) {}
public async createTag(data: Prisma.TagCreateInput) {
return this.prismaService.tag.create({
try {
return await this.prismaService.tag.create({
data
});
} catch (error) {
throw this.getExceptionForError(error);
}
}
public async deleteTag(where: Prisma.TagWhereUniqueInput): Promise<Tag> {
@ -121,10 +125,14 @@ export class TagService {
data: Prisma.TagUpdateInput;
where: Prisma.TagWhereUniqueInput;
}): Promise<Tag> {
return this.prismaService.tag.update({
try {
return await this.prismaService.tag.update({
data,
where
});
} catch (error) {
throw this.getExceptionForError(error);
}
}
public async validateTagIds({
@ -180,4 +188,18 @@ export class TagService {
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;
}
}

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

@ -128,10 +128,17 @@ export class GfHomeWatchlistComponent implements OnInit {
this.dataService
.fetchWatchlist()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ watchlist }) => {
this.watchlist = watchlist;
.subscribe({
error: () => {
this.watchlist = [];
this.changeDetectorRef.markForCheck();
},
next: ({ watchlist }) => {
this.watchlist = watchlist ?? [];
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'
})
export class GfMarketsComponent implements OnInit {
protected readonly benchmarks = signal<Benchmark[]>([]);
protected readonly benchmarks = signal<Benchmark[] | undefined>(undefined);
protected readonly deviceType = computed(
() => this.deviceDetectorService.deviceInfo().deviceType
@ -117,8 +117,13 @@ export class GfMarketsComponent implements OnInit {
this.dataService
.fetchBenchmarks()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ benchmarks }) => {
this.benchmarks.set(benchmarks);
.subscribe({
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"
[user]="user"
/>
@if (benchmarks()?.length > 0) {
@if (benchmarks()?.length) {
<div
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>
</div>
@if (isLoading) {
@if (isLoading()) {
<ngx-skeleton-loader
animation="pulse"
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'
})
export class GfBenchmarkComponent {
public readonly benchmarks = input.required<Benchmark[]>();
public readonly benchmarks = input<Benchmark[]>();
public readonly deviceType = input.required<string>();
public readonly hasPermissionToDeleteItem = input<boolean>();
public readonly locale = input(getLocale());
@ -76,6 +76,7 @@ export class GfBenchmarkComponent {
protected readonly sort = viewChild(MatSort);
protected readonly dataSource = new MatTableDataSource<Benchmark>([]);
protected readonly displayedColumns = computed(() => {
return [
...(this.showIcon() ? ['icon'] : []),
@ -89,7 +90,11 @@ export class GfBenchmarkComponent {
'actions'
];
});
protected isLoading = true;
protected readonly isLoading = computed(() => {
return !this.benchmarks();
});
protected readonly isNumber = isNumber;
protected readonly resolveMarketCondition = resolveMarketCondition;
protected readonly round = round;
@ -110,8 +115,8 @@ export class GfBenchmarkComponent {
this.dataSource.sortingDataAccessor = getLowercase;
this.dataSource.sort = this.sort() ?? null;
this.isLoading = false;
} else {
this.dataSource.data = [];
}
});

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

@ -55,12 +55,12 @@
</mat-option>
}
@if (hasPermissionToCreateTag && tagInputControl.value) {
<mat-option [value]="tagInputControl.value.trim()">
@if (hasPermissionToCreateTag && tagNameToCreate()) {
<mat-option [value]="tagNameToCreate()">
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="add-circle-outline" />
<ng-container i18n>Create</ng-container> "{{
tagInputControl.value.trim()
tagNameToCreate()
}}"
</span>
</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 tagInputControl = new FormControl('');
public readonly tagNameToCreate = signal<string | null>(null);
public readonly tagsSelected = signal<SelectedTag[]>([]);
private readonly tagInput =
@ -80,8 +81,8 @@ export class GfTagsSelectorComponent
public constructor() {
this.tagInputControl.valueChanges
.pipe(takeUntilDestroyed())
.subscribe((value) => {
this.filteredOptions.next(this.filterTags(value ?? ''));
.subscribe(() => {
this.updateFilters();
});
addIcons({ addCircleOutline, closeOutline });
@ -161,9 +162,8 @@ export class GfTagsSelectorComponent
this.updateFilters();
}
private filterTags(query: string = ''): SelectedTag[] {
const tags = this.tagsSelected() ?? [];
const tagIds = [...tags, ...(this.tagsReadOnly ?? [])].map(({ id }) => {
private filterTags(query: string): SelectedTag[] {
const tagIds = this.getTagsSelectedAndReadOnly().map(({ 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
private onChange = (_value: SelectedTag[]): void => {
// ControlValueAccessor onChange callback
@ -189,6 +210,9 @@ export class GfTagsSelectorComponent
};
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",
"version": "3.51.0",
"version": "3.52.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ghostfolio",
"version": "3.51.0",
"version": "3.52.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {

2
package.json

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "3.51.0",
"version": "3.52.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio",

Loading…
Cancel
Save