Browse Source

Setup draft tag

pull/7551/head
Thomas Kaul 3 weeks ago
parent
commit
9d1ec34777
  1. 15
      apps/api/src/app/account/account.service.ts
  2. 6
      apps/api/src/app/activities/activities.controller.ts
  3. 39
      apps/api/src/app/activities/activities.service.ts
  4. 19
      apps/api/src/app/import/import.service.ts
  5. 19
      apps/api/src/app/portfolio/portfolio.service.ts
  6. 31
      apps/api/src/helper/activity.helper.ts
  7. 4
      libs/ui/src/lib/activities-table/activities-table.component.html
  8. 11
      libs/ui/src/lib/activities-table/activities-table.component.ts
  9. 1
      prisma/schema.prisma

15
apps/api/src/app/account/account.service.ts

@ -89,7 +89,7 @@ export class AccountService {
orderBy?: Prisma.AccountOrderByWithRelationInput;
}): Promise<
(AccountWithBalance & {
activities?: (Order & { SymbolProfile?: SymbolProfile })[];
activities?: (Order & { SymbolProfile?: SymbolProfile; tags?: Tag[] })[];
balances?: AccountBalance[];
platform?: Platform;
tags?: Tag[];
@ -237,15 +237,10 @@ export class AccountService {
});
return accounts.map((account) => {
let activitiesCount = 0;
for (const { isDraft } of account.activities) {
if (!isDraft) {
activitiesCount += 1;
}
}
const result = { ...account, activitiesCount };
const result = {
...account,
activitiesCount: account.activities.length
};
delete result.activities;

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

@ -1,5 +1,6 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { isActivityInFuture } from '@ghostfolio/api/helper/activity.helper';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
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';
@ -287,9 +288,9 @@ export class ActivitiesController {
userId: this.request.user.id
});
if (dataSource && !activity.isDraft) {
if (dataSource && !isActivityInFuture({ date: activity.date })) {
// Gather symbol data in the background, if data source is set
// (not MANUAL) and not draft
// (not MANUAL) and the date is not in the future
this.dataGatheringService.gatherSymbols({
dataGatheringItems: [
{
@ -369,6 +370,7 @@ export class ActivitiesController {
}),
user: { connect: { id: this.request.user.id } }
},
storedDate: originalActivity.date,
userId: this.request.user.id,
where: {
id

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

@ -7,7 +7,11 @@ import {
isAccountBalanceInFuture,
WHERE_ACCOUNT_NOT_EXCLUDED
} from '@ghostfolio/api/helper/account.helper';
import { isDraftTagToBeAssigned } from '@ghostfolio/api/helper/activity.helper';
import {
isActivityInFuture,
isDraftTagToBeAssigned,
WHERE_ACTIVITY_NOT_DRAFT
} from '@ghostfolio/api/helper/activity.helper';
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
@ -28,6 +32,7 @@ import {
import {
canDeleteAssetProfile,
getAssetProfileIdentifier,
isDraftActivity,
isValidCustomAssetProfileSymbol
} from '@ghostfolio/common/helper';
import {
@ -51,7 +56,7 @@ import {
Type as ActivityType
} from '@prisma/client';
import { Big } from 'big.js';
import { endOfToday, isAfter } from 'date-fns';
import { endOfToday } from 'date-fns';
import { groupBy, uniqBy } from 'lodash';
import { randomUUID } from 'node:crypto';
@ -263,10 +268,6 @@ export class ActivitiesService {
const orderData: Prisma.OrderCreateInput = data;
const isDraft = NON_INVESTMENT_ACTIVITY_TYPES.includes(data.type)
? false
: isAfter(data.date as Date, endOfToday());
const tagsToConnect = isDraftTagToBeAssigned({
date: data.date as Date,
type: data.type
@ -278,7 +279,8 @@ export class ActivitiesService {
data: {
...orderData,
account,
isDraft,
// @deprecated Mirrors the "Draft" tag until the attribute is removed
isDraft: isDraftActivity({ tags: tagsToConnect }),
tags: {
connect: tagsToConnect
}
@ -647,7 +649,7 @@ export class ActivitiesService {
}
if (includeDrafts === false) {
where.isDraft = false;
andConditions.push(WHERE_ACTIVITY_NOT_DRAFT);
}
if (filtersByAssetClass.length > 0) {
@ -961,6 +963,7 @@ export class ActivitiesService {
public async updateActivity({
data,
storedDate,
userId,
where
}: {
@ -972,6 +975,7 @@ export class ActivitiesService {
tags?: { id: string }[];
type?: ActivityType;
};
storedDate: Date;
userId: string;
where: Prisma.OrderWhereUniqueInput;
}): Promise<Order> {
@ -988,8 +992,6 @@ export class ActivitiesService {
data.comment = null;
}
let isDraft = false;
if (
NON_INVESTMENT_ACTIVITY_TYPES.includes(data.type) ||
(data.SymbolProfile.connect.dataSource_symbol.dataSource === 'MANUAL' &&
@ -1000,10 +1002,9 @@ export class ActivitiesService {
} else {
delete data.SymbolProfile.update;
isDraft = isAfter(data.date as Date, endOfToday());
if (!isDraft) {
// Gather symbol data of order in the background, if not draft
if (!isActivityInFuture({ date: data.date as Date })) {
// Gather symbol data of order in the background, if the date is not in
// the future
this.dataGatheringService.gatherSymbols({
dataGatheringItems: [
{
@ -1023,14 +1024,9 @@ export class ActivitiesService {
delete data.symbol;
delete data.tags;
const storedActivity = await this.prismaService.order.findUnique({
where,
select: { date: true }
});
const tagsToSet = isDraftTagToBeAssigned({
storedDate,
date: data.date as Date,
storedDate: storedActivity?.date,
type: data.type
})
? uniqBy([...tags, { id: TAG_ID_DRAFT }], 'id')
@ -1040,7 +1036,8 @@ export class ActivitiesService {
where,
data: {
...data,
isDraft,
// @deprecated Mirrors the "Draft" tag until the attribute is removed
isDraft: isDraftActivity({ tags: tagsToSet }),
tags: {
set: tagsToSet
}

19
apps/api/src/app/import/import.service.ts

@ -2,6 +2,7 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { isDraftTagToBeAssigned } from '@ghostfolio/api/helper/activity.helper';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
@ -13,6 +14,7 @@ import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
ghostfolioPrefix,
NON_INVESTMENT_ACTIVITY_TYPES,
TAG_ID_DRAFT,
TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config';
import {
@ -22,6 +24,7 @@ import {
} from '@ghostfolio/common/dtos';
import {
getAssetProfileIdentifier,
isDraftActivity,
isValidCustomAssetProfileSymbol,
parseDate
} from '@ghostfolio/common/helper';
@ -41,7 +44,7 @@ import { Injectable } from '@nestjs/common';
import { Account, DataSource, Prisma } from '@prisma/client';
import { Big } from 'big.js';
import { isISIN } from 'class-validator';
import { endOfToday, isAfter, isSameSecond, parseISO } from 'date-fns';
import { isSameSecond, parseISO } from 'date-fns';
import { omit, uniqBy } from 'lodash';
import { randomUUID } from 'node:crypto';
@ -775,6 +778,16 @@ export class ImportService {
});
if (isDryRun) {
// Preview the "Draft" tag which createActivity() assigns in a real run
const draftTag = tags.find(({ id: tagId }) => {
return tagId === TAG_ID_DRAFT;
});
const previewTags =
draftTag && isDraftTagToBeAssigned({ date, type })
? uniqBy([...validatedTags, draftTag], 'id')
: validatedTags;
order = {
comment,
currency,
@ -788,7 +801,7 @@ export class ImportService {
accountUserId: undefined,
createdAt: new Date(),
id: randomUUID(),
isDraft: isAfter(date, endOfToday()),
isDraft: isDraftActivity({ tags: previewTags }),
SymbolProfile: {
assetClass,
assetSubClass,
@ -817,7 +830,7 @@ export class ImportService {
userId: dataSource === 'MANUAL' ? user.id : undefined
},
symbolProfileId: undefined,
tags: validatedTags,
tags: previewTags,
updatedAt: new Date(),
userId: user.id
};

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

@ -43,6 +43,7 @@ import {
getAssetProfileIdentifier,
getSum,
isAccountExcluded,
isDraftActivity,
parseDate
} from '@ghostfolio/common/helper';
import {
@ -174,7 +175,7 @@ export class PortfolioService {
this.accountService.accounts({
where,
include: {
activities: { include: { SymbolProfile: true } },
activities: { include: { SymbolProfile: true, tags: true } },
platform: true,
tags: true
},
@ -200,12 +201,18 @@ export class PortfolioService {
for (const {
currency,
date,
isDraft,
quantity,
SymbolProfile,
tags,
type,
unitPrice
} of account.activities) {
activitiesCount += 1;
if (isDraftActivity({ tags })) {
continue;
}
switch (type) {
case ActivityType.DIVIDEND:
dividendInBaseCurrency +=
@ -226,10 +233,6 @@ export class PortfolioService {
)) ?? 0;
break;
}
if (!isDraft) {
activitiesCount += 1;
}
}
const valueInBaseCurrency =
@ -2101,8 +2104,8 @@ export class PortfolioService {
}) {
return getSum(
activities
.filter(({ isDraft, type }) => {
return isDraft === false && type === activityType;
.filter((activity) => {
return !isDraftActivity(activity) && activity.type === activityType;
})
.map(({ assetProfile, currency, quantity, unitPrice }) => {
return new Big(

31
apps/api/src/helper/activity.helper.ts

@ -1,8 +1,29 @@
import { NON_INVESTMENT_ACTIVITY_TYPES } from '@ghostfolio/common/config';
import {
NON_INVESTMENT_ACTIVITY_TYPES,
TAG_ID_DRAFT
} from '@ghostfolio/common/config';
import { Type as ActivityType } from '@prisma/client';
import { Prisma, Type as ActivityType } from '@prisma/client';
import { endOfToday, isAfter } from 'date-fns';
export const WHERE_ACTIVITY_NOT_DRAFT: Prisma.OrderWhereInput = {
tags: {
none: {
id: TAG_ID_DRAFT
}
}
};
export function isActivityInFuture({
date,
endOfTodayDate = endOfToday()
}: {
date: Date;
endOfTodayDate?: Date;
}) {
return isAfter(date, endOfTodayDate);
}
export function isDraftTagToBeAssigned({
date,
endOfTodayDate = endOfToday(),
@ -18,11 +39,13 @@ export function isDraftTagToBeAssigned({
return false;
}
if (!isAfter(date, endOfTodayDate)) {
if (!isActivityInFuture({ date, endOfTodayDate })) {
return false;
}
// Assign only when the date newly moves into the future, so that a tag the
// user has removed is not restored by an unrelated change
return storedDate ? !isAfter(storedDate, endOfTodayDate) : true;
return storedDate
? !isActivityInFuture({ endOfTodayDate, date: storedDate })
: true;
}

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

@ -177,7 +177,7 @@
<td *matCellDef="let element" class="line-height-normal px-1" mat-cell>
<div class="align-items-center d-flex text-nowrap">
<span class="text-truncate">{{ element.assetProfile?.name }}</span>
@if (element.isDraft) {
@if (isDraftActivity(element)) {
<span class="badge badge-secondary ml-1" i18n>Draft</span>
}
</div>
@ -513,7 +513,7 @@
</a>
<button
mat-menu-item
[disabled]="!element.isDraft"
[disabled]="!isDraftActivity(element)"
(click)="onExportDraft(element.id)"
>
<span class="align-items-center d-flex">

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

@ -3,7 +3,11 @@ import {
TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config';
import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { getLocale, isAccountExcluded } from '@ghostfolio/common/helper';
import {
getLocale,
isAccountExcluded,
isDraftActivity
} from '@ghostfolio/common/helper';
import {
Activity,
AssetProfileIdentifier
@ -138,6 +142,7 @@ export class GfActivitiesTableComponent implements AfterViewInit, OnInit {
public activityTypesTranslationMap = new Map<ActivityType, string>();
public hasDrafts = false;
public hasErrors = false;
public isDraftActivity = isDraftActivity;
public isUUID = isUUID;
public selectedRows = new SelectionModel<Activity>(true, []);
public typesFilter = new FormControl<string[]>([]);
@ -277,7 +282,7 @@ export class GfActivitiesTableComponent implements AfterViewInit, OnInit {
return (
this.hasPermissionToOpenDetails &&
this.isExcludedFromAnalysis(activity) === false &&
activity.isDraft === false &&
isDraftActivity(activity) === false &&
['BUY', 'DIVIDEND', 'SELL'].includes(activity.type)
);
}
@ -357,7 +362,7 @@ export class GfActivitiesTableComponent implements AfterViewInit, OnInit {
this.exportDrafts.emit(
this.dataSource()
?.filteredData.filter((activity) => {
return activity.isDraft;
return isDraftActivity(activity);
})
.map((activity) => {
return activity.id;

1
prisma/schema.prisma

@ -183,6 +183,7 @@ model Order {
date DateTime
fee Float
id String @id @default(uuid())
/// @deprecated Use the "Draft" tag (`TAG_ID_DRAFT`) instead
isDraft Boolean @default(false)
quantity Float
symbolProfileId String

Loading…
Cancel
Save