Browse Source

Bugfix/deletion of activities to respect activity type and date range filter (#7492)

* Respect activity type and date range filter

* Update changelog
pull/7494/head
Thomas Kaul 1 week ago
committed by GitHub
parent
commit
031b3b68c7
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 9
      CHANGELOG.md
  2. 43
      apps/api/src/app/activities/activities-filter.dto.ts
  3. 83
      apps/api/src/app/activities/activities.controller.ts
  4. 9
      apps/api/src/app/activities/activities.service.ts
  5. 27
      apps/api/src/app/activities/get-activities.dto.ts
  6. 39
      apps/api/src/app/export/export.controller.ts
  7. 6
      apps/api/src/app/export/export.service.ts
  8. 14
      apps/api/src/app/export/get-export.dto.ts
  9. 5
      apps/client/src/app/app.component.ts
  10. 33
      apps/client/src/app/pages/portfolio/activities/activities-page.component.ts
  11. 24
      libs/common/src/lib/calculation-helper.spec.ts
  12. 13
      libs/common/src/lib/calculation-helper.ts
  13. 14
      libs/common/src/lib/config.ts
  14. 12
      libs/common/src/lib/types/date-range.type.ts
  15. 9
      libs/ui/src/lib/activities-table/activities-table.component.html
  16. 7
      libs/ui/src/lib/activities-table/activities-table.component.ts
  17. 34
      libs/ui/src/lib/services/data.service.ts

9
CHANGELOG.md

@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
### Added
- Added support for the date range filter in the export functionality
- Added support for the date range filter on the portfolio activities page
### Changed
- Improved the style of the tabs in the account detail dialog on mobile
@ -19,10 +24,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Improved the search functionality by trimming the query
- Improved the log output in the search functionality of the _Yahoo Finance_ service for unsupported queries
- Improved the performance of the property service by caching the properties in memory
- Improved the validation of the query parameters in the activities endpoints
- Improved the language localization for German (`de`)
### Fixed
- Fixed the calendar year date range in time zones with a negative _UTC_ offset
- Fixed the deletion of activities to respect the activity type filter on the activities page (experimental)
- Fixed the deletion of activities to respect the date range filter on the activities page
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Asset Class Cluster Risks_ (Equity)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Asset Class Cluster Risks_ (Fixed Income)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Currency Cluster Risks_ (Investment)

43
apps/api/src/app/activities/activities-filter.dto.ts

@ -0,0 +1,43 @@
import { DATE_RANGES } from '@ghostfolio/common/config';
import { DateRange } from '@ghostfolio/common/types';
import { Type as ActivityType } from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer';
import { IsEnum, IsOptional, IsString, Matches } from 'class-validator';
import { isString } from 'lodash';
// A named date range or a calendar year like '2024', '2023', '2022', etc.
const DATE_RANGE_PATTERN = new RegExp(`^(${DATE_RANGES.join('|')}|\\d{4})$`);
export class ActivitiesFilterDto {
@IsOptional()
@IsString()
accounts?: string;
@IsEnum(ActivityType, { each: true })
@IsOptional()
@Transform(({ value }: TransformFnParams) => {
return isString(value) ? value.split(',') : value;
})
activityTypes?: ActivityType[];
@IsOptional()
@IsString()
assetClasses?: string;
@IsOptional()
@IsString()
dataSource?: string;
@IsOptional()
@Matches(DATE_RANGE_PATTERN)
range?: DateRange;
@IsOptional()
@IsString()
symbol?: string;
@IsOptional()
@IsString()
tags?: string;
}

83
apps/api/src/app/activities/activities.controller.ts

@ -18,7 +18,7 @@ import {
ActivityResponse
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
@ -29,7 +29,6 @@ import {
HttpException,
Inject,
Param,
ParseIntPipe,
Post,
Put,
Query,
@ -38,11 +37,13 @@ import {
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Order, Prisma, Type as ActivityType } from '@prisma/client';
import { Order } from '@prisma/client';
import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { ActivitiesFilterDto } from './activities-filter.dto';
import { ActivitiesService } from './activities.service';
import { GetActivitiesDto } from './get-activities.dto';
@Controller('activities')
export class ActivitiesController {
@ -60,22 +61,38 @@ export class ActivitiesController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async deleteActivities(
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query() query: ActivitiesFilterDto
): Promise<number> {
if (impersonationId) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
let endDate: Date;
let startDate: Date;
if (query.range) {
({ endDate, startDate } = getIntervalFromDateRange({
dateRange: query.range
}));
}
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags
filterByAccounts: query.accounts,
filterByAssetClasses: query.assetClasses,
filterByDataSource: query.dataSource,
filterBySymbol: query.symbol,
filterByTags: query.tags
});
return this.activitiesService.deleteActivities({
endDate,
filters,
startDate,
types: query.activityTypes,
userId: this.request.user.id
});
}
@ -108,51 +125,41 @@ export class ActivitiesController {
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAllActivities(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('activityTypes') filterByTypes?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('range') dateRange?: DateRange,
@Query('skip', new ParseIntPipe({ optional: true })) skip?: number,
@Query('sortColumn') sortColumn?: string,
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string,
@Query('take', new ParseIntPipe({ optional: true })) take?: number
@Query() query: GetActivitiesDto
): Promise<ActivitiesResponse> {
let endDate: Date;
let startDate: Date;
if (dateRange) {
({ endDate, startDate } = getIntervalFromDateRange({ dateRange }));
if (query.range) {
({ endDate, startDate } = getIntervalFromDateRange({
dateRange: query.range
}));
}
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags
filterByAccounts: query.accounts,
filterByAssetClasses: query.assetClasses,
filterByDataSource: query.dataSource,
filterBySymbol: query.symbol,
filterByTags: query.tags
});
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
const types = (filterByTypes?.split(',') as ActivityType[]) ?? [];
const userCurrency = this.request.user.settings.settings.baseCurrency;
const { activities, count } = await this.activitiesService.getActivities({
endDate,
filters,
skip,
sortColumn,
sortDirection,
startDate,
take,
types,
userCurrency,
includeDrafts: true,
skip: query.skip,
sortColumn: query.sortColumn,
sortDirection: query.sortDirection,
take: query.take,
types: query.activityTypes,
userId: impersonationUserId || this.request.user.id,
withExcludedAccountsAndActivities: true
});

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

@ -361,14 +361,23 @@ export class ActivitiesService {
}
public async deleteActivities({
endDate,
filters,
startDate,
types,
userId
}: {
endDate?: Date;
filters?: Filter[];
startDate?: Date;
types?: ActivityType[];
userId: string;
}): Promise<number> {
const { activities } = await this.getActivities({
endDate,
filters,
startDate,
types,
userId,
includeDrafts: true,
userCurrency: undefined,

27
apps/api/src/app/activities/get-activities.dto.ts

@ -0,0 +1,27 @@
import { Prisma } from '@prisma/client';
import { Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, Min } from 'class-validator';
import { ActivitiesFilterDto } from './activities-filter.dto';
export class GetActivitiesDto extends ActivitiesFilterDto {
@IsInt()
@IsOptional()
@Min(0)
@Type(() => Number)
skip?: number;
@IsIn(Object.values(Prisma.OrderScalarFieldEnum))
@IsOptional()
sortColumn?: keyof typeof Prisma.OrderScalarFieldEnum;
@IsIn(['asc', 'desc'] as Prisma.SortOrder[])
@IsOptional()
sortDirection?: Prisma.SortOrder;
@IsInt()
@IsOptional()
@Min(0)
@Type(() => Number)
take?: number;
}

39
apps/api/src/app/export/export.controller.ts

@ -2,6 +2,7 @@ import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { ExportResponse } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
@ -15,9 +16,9 @@ import {
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Type as ActivityType } from '@prisma/client';
import { ExportService } from './export.service';
import { GetExportDto } from './get-export.dto';
@Controller('export')
export class ExportController {
@ -31,30 +32,30 @@ export class ExportController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async export(
@Query('accounts') filterByAccounts?: string,
@Query('activityIds') filterByActivityIds?: string,
@Query('activityTypes') filterByTypes?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string
): Promise<ExportResponse> {
const activityIds = filterByActivityIds?.split(',') ?? [];
const activityTypes = (filterByTypes?.split(',') as ActivityType[]) ?? [];
public async export(@Query() query: GetExportDto): Promise<ExportResponse> {
let endDate: Date;
let startDate: Date;
if (query.range) {
({ endDate, startDate } = getIntervalFromDateRange({
dateRange: query.range
}));
}
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags
filterByAccounts: query.accounts,
filterByAssetClasses: query.assetClasses,
filterByDataSource: query.dataSource,
filterBySymbol: query.symbol,
filterByTags: query.tags
});
return this.exportService.export({
activityIds,
activityTypes,
endDate,
filters,
startDate,
activityIds: query.activityIds,
activityTypes: query.activityTypes,
userId: this.request.user.id,
userSettings: this.request.user.settings.settings
});

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

@ -25,13 +25,17 @@ export class ExportService {
public async export({
activityIds,
activityTypes,
endDate,
filters,
startDate,
userId,
userSettings
}: {
activityIds?: string[];
activityTypes?: ActivityType[];
endDate?: Date;
filters?: Filter[];
startDate?: Date;
userId: string;
userSettings: UserSettings;
}): Promise<ExportResponse> {
@ -41,7 +45,9 @@ export class ExportService {
const platformsMap: { [platformId: string]: Platform } = {};
let { activities } = await this.activitiesService.getActivities({
endDate,
filters,
startDate,
userId,
includeDrafts: true,
sortColumn: 'date',

14
apps/api/src/app/export/get-export.dto.ts

@ -0,0 +1,14 @@
import { ActivitiesFilterDto } from '@ghostfolio/api/app/activities/activities-filter.dto';
import { Transform, TransformFnParams } from 'class-transformer';
import { IsOptional, IsUUID } from 'class-validator';
import { isString } from 'lodash';
export class GetExportDto extends ActivitiesFilterDto {
@IsOptional()
@IsUUID(undefined, { each: true })
@Transform(({ value }: TransformFnParams) => {
return isString(value) ? value.split(',') : value;
})
activityIds?: string[];
}

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

@ -135,7 +135,10 @@ export class GfAppComponent implements OnInit {
this.currentSubRoute ===
internalRoutes.home.subRoutes?.holdings.path) ||
(this.currentRoute === internalRoutes.portfolio.path &&
!this.currentSubRoute)) &&
!this.currentSubRoute) ||
(this.currentRoute === internalRoutes.portfolio.path &&
this.currentSubRoute ===
internalRoutes.portfolio.subRoutes?.activities.path)) &&
this.user?.settings?.viewMode !== 'ZEN'
) {
this.hasPermissionToChangeDateRange = true;

33
apps/client/src/app/pages/portfolio/activities/activities-page.component.ts

@ -10,7 +10,6 @@ import {
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { DateRange } from '@ghostfolio/common/types';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfFabComponent } from '@ghostfolio/ui/fab';
import { DataService } from '@ghostfolio/ui/services';
@ -92,8 +91,14 @@ export class GfActivitiesPageComponent implements OnInit {
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => {
if (state?.user) {
const previousDateRange = this.getDateRange();
this.updateUser(state.user);
if (previousDateRange !== this.getDateRange()) {
this.pageIndex = 0;
}
this.fetchActivities();
this.changeDetectorRef.markForCheck();
@ -120,7 +125,11 @@ export class GfActivitiesPageComponent implements OnInit {
protected onDeleteActivities() {
this.dataService
.deleteActivities({
filters: this.userService.getFilters()
activityTypes: this.activityTypesFilter.length
? this.activityTypesFilter
: undefined,
filters: this.userService.getFilters(),
range: this.getDateRange()
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
@ -159,7 +168,8 @@ export class GfActivitiesPageComponent implements OnInit {
activityTypes: this.activityTypesFilter.length
? this.activityTypesFilter
: undefined,
filters: this.userService.getFilters()
filters: this.userService.getFilters(),
range: this.getDateRange()
};
}
@ -277,16 +287,13 @@ export class GfActivitiesPageComponent implements OnInit {
this.dataSource = undefined;
this.totalItems = undefined;
const dateRange = this.user?.settings?.dateRange;
const range = this.isCalendarYear(dateRange) ? dateRange : undefined;
this.dataService
.fetchActivities({
range,
activityTypes: this.activityTypesFilter.length
? this.activityTypesFilter
: undefined,
filters: this.userService.getFilters(),
range: this.getDateRange(),
skip: this.pageIndex * this.pageSize,
sortColumn: this.sortColumn,
sortDirection: this.sortDirection,
@ -311,12 +318,16 @@ export class GfActivitiesPageComponent implements OnInit {
});
}
private isCalendarYear(dateRange?: DateRange) {
if (!dateRange) {
return false;
private getDateRange() {
const dateRange = this.user?.settings?.dateRange;
// Omit the date ranges which do not apply to activities: '1d' spans today
// only, while 'max' would exclude drafts dated in the future
if (!dateRange || ['1d', 'max'].includes(dateRange)) {
return undefined;
}
return /^\d{4}$/.test(dateRange);
return dateRange;
}
private updateUser(aUser: User) {

24
libs/common/src/lib/calculation-helper.spec.ts

@ -1,8 +1,30 @@
import { Big } from 'big.js';
import { format } from 'date-fns';
import { getAnnualizedPerformancePercent } from './calculation-helper';
import {
getAnnualizedPerformancePercent,
getIntervalFromDateRange
} from './calculation-helper';
import { DATE_FORMAT } from './helper';
describe('CalculationHelper', () => {
describe('interval from date range', () => {
it('Get interval of a calendar year', async () => {
const { endDate, startDate } = getIntervalFromDateRange({
dateRange: '2024'
});
// The boundaries are expressed in the local time zone, therefore the
// calendar days must hold independently of the time zone the tests run in
expect(format(startDate, DATE_FORMAT)).toEqual('2023-12-31');
expect(format(endDate, DATE_FORMAT)).toEqual('2024-12-31');
// The start date is exclusive, hence the first instant of the year is
// part of the interval
expect(startDate.getTime()).toEqual(new Date(2024, 0, 1).getTime() - 1);
});
});
describe('annualized performance percentage', () => {
it('Get annualized performance', async () => {
expect(

13
libs/common/src/lib/calculation-helper.ts

@ -7,6 +7,7 @@ import {
startOfWeek,
startOfYear,
subDays,
subMilliseconds,
subYears
} from 'date-fns';
import { isFinite, isNumber } from 'lodash';
@ -75,10 +76,16 @@ export function getIntervalFromDateRange(params: {
break;
case 'max':
break;
default:
default: {
// '2024', '2023', '2022', etc.
endDate = endOfYear(new Date(dateRange));
startDate = max([startDate, new Date(dateRange)]);
const yearStartDate = new Date(Number(dateRange), 0, 1);
// Derive the boundaries of the calendar year in the local time zone, as
// the consumers apply local time zone semantics. As the start date is
// exclusive, the last millisecond of the preceding year is used.
endDate = endOfYear(yearStartDate);
startDate = max([startDate, subMilliseconds(yearStartDate, 1)]);
}
}
return { endDate, startDate };

14
libs/common/src/lib/config.ts

@ -67,6 +67,20 @@ export const DATA_GATHERING_QUEUE_PRIORITY_MEDIUM = Math.round(
DATA_GATHERING_QUEUE_PRIORITY_LOW / 2
);
/**
* The named date ranges, complemented by the calendar years like '2024',
* '2023', '2022', etc.
*/
export const DATE_RANGES = [
'1d',
'1y',
'5y',
'max',
'mtd',
'wtd',
'ytd'
] as const;
export const PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE =
'PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE';
export const PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_HIGH = 1;

12
libs/common/src/lib/types/date-range.type.ts

@ -1,9 +1,3 @@
export type DateRange =
| '1d'
| '1y'
| '5y'
| 'max'
| 'mtd'
| 'wtd'
| 'ytd'
| string; // '2024', '2023', '2022', etc.
import type { DATE_RANGES } from '../config';
export type DateRange = (typeof DATE_RANGES)[number] | string; // '2024', '2023', '2022', etc.

9
libs/ui/src/lib/activities-table/activities-table.component.html

@ -58,12 +58,19 @@
<button
class="align-items-center d-flex"
mat-menu-item
[disabled]="dataSource()?.data.length === 0"
[disabled]="!canExportActivities()"
(click)="onExport()"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="cloud-download-outline" />
@if (canExportActivities()) {
<span i18n
>Export {{ totalItems > 1 ? totalItems : '' }}
{totalItems, plural, =1 {Activity} other {Activities}}</span
>
} @else {
<span i18n>Export Activities</span>
}
</span>
</button>
}

7
libs/ui/src/lib/activities-table/activities-table.component.ts

@ -289,6 +289,13 @@ export class GfActivitiesTableComponent implements AfterViewInit, OnInit {
);
}
public canExportActivities() {
return (
(this.dataSource()?.data.length ?? 0) > 0 &&
this.hasPermissionToExportActivities
);
}
public isExcludedFromAnalysis(activity: Activity) {
return (
(activity.account && isAccountExcluded(activity.account)) ??

34
libs/ui/src/lib/services/data.service.ts

@ -329,8 +329,24 @@ export class DataService {
return this.http.delete<AccountBalance>(`/api/v1/account-balance/${aId}`);
}
public deleteActivities({ filters }: { filters?: Filter[] }) {
const params = this.buildFiltersAsQueryParams({ filters });
public deleteActivities({
activityTypes,
filters,
range
}: {
activityTypes?: string[];
filters?: Filter[];
range?: DateRange;
}) {
let params = this.buildFiltersAsQueryParams({ filters });
if (activityTypes?.length) {
params = params.append('activityTypes', activityTypes.join(','));
}
if (range) {
params = params.append('range', range);
}
return this.http.delete<number>('/api/v1/activities', { params });
}
@ -459,11 +475,13 @@ export class DataService {
public fetchExport({
activityIds,
activityTypes,
filters
filters,
range
}: {
activityIds?: string[];
activityTypes?: string[];
filters?: Filter[];
range?: DateRange;
} = {}) {
let params = this.buildFiltersAsQueryParams({ filters });
@ -475,6 +493,10 @@ export class DataService {
params = params.append('activityTypes', activityTypes.join(','));
}
if (range) {
params = params.append('range', range);
}
return this.http.get<ExportResponse>('/api/v1/export', {
params
});
@ -509,8 +531,7 @@ export class DataService {
public fetchInfo(): InfoItem {
const info = cloneDeep((window as any).info);
const utmSource = window.localStorage.getItem('utm_source') as
| 'ios'
| 'trusted-web-activity';
'ios' | 'trusted-web-activity';
info.globalPermissions = filterGlobalPermissions(
info.globalPermissions,
@ -949,8 +970,7 @@ export class DataService {
public updateInfo() {
this.http.get<InfoItem>('/api/v1/info').subscribe((info) => {
const utmSource = window.localStorage.getItem('utm_source') as
| 'ios'
| 'trusted-web-activity';
'ios' | 'trusted-web-activity';
info.globalPermissions = filterGlobalPermissions(
info.globalPermissions,

Loading…
Cancel
Save