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; orderBy?: Prisma.AccountOrderByWithRelationInput;
}): Promise< }): Promise<
(AccountWithBalance & { (AccountWithBalance & {
activities?: (Order & { SymbolProfile?: SymbolProfile })[]; activities?: (Order & { SymbolProfile?: SymbolProfile; tags?: Tag[] })[];
balances?: AccountBalance[]; balances?: AccountBalance[];
platform?: Platform; platform?: Platform;
tags?: Tag[]; tags?: Tag[];
@ -237,15 +237,10 @@ export class AccountService {
}); });
return accounts.map((account) => { return accounts.map((account) => {
let activitiesCount = 0; const result = {
...account,
for (const { isDraft } of account.activities) { activitiesCount: account.activities.length
if (!isDraft) { };
activitiesCount += 1;
}
}
const result = { ...account, activitiesCount };
delete result.activities; 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 { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; 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 { 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 { 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 { 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 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 // 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({ this.dataGatheringService.gatherSymbols({
dataGatheringItems: [ dataGatheringItems: [
{ {
@ -369,6 +370,7 @@ export class ActivitiesController {
}), }),
user: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}, },
storedDate: originalActivity.date,
userId: this.request.user.id, userId: this.request.user.id,
where: { where: {
id id

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

@ -7,7 +7,11 @@ import {
isAccountBalanceInFuture, isAccountBalanceInFuture,
WHERE_ACCOUNT_NOT_EXCLUDED WHERE_ACCOUNT_NOT_EXCLUDED
} from '@ghostfolio/api/helper/account.helper'; } 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 { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
@ -28,6 +32,7 @@ import {
import { import {
canDeleteAssetProfile, canDeleteAssetProfile,
getAssetProfileIdentifier, getAssetProfileIdentifier,
isDraftActivity,
isValidCustomAssetProfileSymbol isValidCustomAssetProfileSymbol
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
@ -51,7 +56,7 @@ import {
Type as ActivityType Type as ActivityType
} from '@prisma/client'; } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { endOfToday, isAfter } from 'date-fns'; import { endOfToday } from 'date-fns';
import { groupBy, uniqBy } from 'lodash'; import { groupBy, uniqBy } from 'lodash';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
@ -263,10 +268,6 @@ export class ActivitiesService {
const orderData: Prisma.OrderCreateInput = data; const orderData: Prisma.OrderCreateInput = data;
const isDraft = NON_INVESTMENT_ACTIVITY_TYPES.includes(data.type)
? false
: isAfter(data.date as Date, endOfToday());
const tagsToConnect = isDraftTagToBeAssigned({ const tagsToConnect = isDraftTagToBeAssigned({
date: data.date as Date, date: data.date as Date,
type: data.type type: data.type
@ -278,7 +279,8 @@ export class ActivitiesService {
data: { data: {
...orderData, ...orderData,
account, account,
isDraft, // @deprecated Mirrors the "Draft" tag until the attribute is removed
isDraft: isDraftActivity({ tags: tagsToConnect }),
tags: { tags: {
connect: tagsToConnect connect: tagsToConnect
} }
@ -647,7 +649,7 @@ export class ActivitiesService {
} }
if (includeDrafts === false) { if (includeDrafts === false) {
where.isDraft = false; andConditions.push(WHERE_ACTIVITY_NOT_DRAFT);
} }
if (filtersByAssetClass.length > 0) { if (filtersByAssetClass.length > 0) {
@ -961,6 +963,7 @@ export class ActivitiesService {
public async updateActivity({ public async updateActivity({
data, data,
storedDate,
userId, userId,
where where
}: { }: {
@ -972,6 +975,7 @@ export class ActivitiesService {
tags?: { id: string }[]; tags?: { id: string }[];
type?: ActivityType; type?: ActivityType;
}; };
storedDate: Date;
userId: string; userId: string;
where: Prisma.OrderWhereUniqueInput; where: Prisma.OrderWhereUniqueInput;
}): Promise<Order> { }): Promise<Order> {
@ -988,8 +992,6 @@ export class ActivitiesService {
data.comment = null; data.comment = null;
} }
let isDraft = false;
if ( if (
NON_INVESTMENT_ACTIVITY_TYPES.includes(data.type) || NON_INVESTMENT_ACTIVITY_TYPES.includes(data.type) ||
(data.SymbolProfile.connect.dataSource_symbol.dataSource === 'MANUAL' && (data.SymbolProfile.connect.dataSource_symbol.dataSource === 'MANUAL' &&
@ -1000,10 +1002,9 @@ export class ActivitiesService {
} else { } else {
delete data.SymbolProfile.update; delete data.SymbolProfile.update;
isDraft = isAfter(data.date as Date, endOfToday()); if (!isActivityInFuture({ date: data.date as Date })) {
// Gather symbol data of order in the background, if the date is not in
if (!isDraft) { // the future
// Gather symbol data of order in the background, if not draft
this.dataGatheringService.gatherSymbols({ this.dataGatheringService.gatherSymbols({
dataGatheringItems: [ dataGatheringItems: [
{ {
@ -1023,14 +1024,9 @@ export class ActivitiesService {
delete data.symbol; delete data.symbol;
delete data.tags; delete data.tags;
const storedActivity = await this.prismaService.order.findUnique({
where,
select: { date: true }
});
const tagsToSet = isDraftTagToBeAssigned({ const tagsToSet = isDraftTagToBeAssigned({
storedDate,
date: data.date as Date, date: data.date as Date,
storedDate: storedActivity?.date,
type: data.type type: data.type
}) })
? uniqBy([...tags, { id: TAG_ID_DRAFT }], 'id') ? uniqBy([...tags, { id: TAG_ID_DRAFT }], 'id')
@ -1040,7 +1036,8 @@ export class ActivitiesService {
where, where,
data: { data: {
...data, ...data,
isDraft, // @deprecated Mirrors the "Draft" tag until the attribute is removed
isDraft: isDraftActivity({ tags: tagsToSet }),
tags: { tags: {
set: tagsToSet 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 { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service'; import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.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 { ApiService } from '@ghostfolio/api/services/api/api.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.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'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
@ -13,6 +14,7 @@ import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH, DATA_GATHERING_QUEUE_PRIORITY_HIGH,
ghostfolioPrefix, ghostfolioPrefix,
NON_INVESTMENT_ACTIVITY_TYPES, NON_INVESTMENT_ACTIVITY_TYPES,
TAG_ID_DRAFT,
TAG_ID_EXCLUDE_FROM_ANALYSIS TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
@ -22,6 +24,7 @@ import {
} from '@ghostfolio/common/dtos'; } from '@ghostfolio/common/dtos';
import { import {
getAssetProfileIdentifier, getAssetProfileIdentifier,
isDraftActivity,
isValidCustomAssetProfileSymbol, isValidCustomAssetProfileSymbol,
parseDate parseDate
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
@ -41,7 +44,7 @@ import { Injectable } from '@nestjs/common';
import { Account, DataSource, Prisma } from '@prisma/client'; import { Account, DataSource, Prisma } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { isISIN } from 'class-validator'; 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 { omit, uniqBy } from 'lodash';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
@ -775,6 +778,16 @@ export class ImportService {
}); });
if (isDryRun) { 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 = { order = {
comment, comment,
currency, currency,
@ -788,7 +801,7 @@ export class ImportService {
accountUserId: undefined, accountUserId: undefined,
createdAt: new Date(), createdAt: new Date(),
id: randomUUID(), id: randomUUID(),
isDraft: isAfter(date, endOfToday()), isDraft: isDraftActivity({ tags: previewTags }),
SymbolProfile: { SymbolProfile: {
assetClass, assetClass,
assetSubClass, assetSubClass,
@ -817,7 +830,7 @@ export class ImportService {
userId: dataSource === 'MANUAL' ? user.id : undefined userId: dataSource === 'MANUAL' ? user.id : undefined
}, },
symbolProfileId: undefined, symbolProfileId: undefined,
tags: validatedTags, tags: previewTags,
updatedAt: new Date(), updatedAt: new Date(),
userId: user.id userId: user.id
}; };

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

@ -43,6 +43,7 @@ import {
getAssetProfileIdentifier, getAssetProfileIdentifier,
getSum, getSum,
isAccountExcluded, isAccountExcluded,
isDraftActivity,
parseDate parseDate
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
@ -174,7 +175,7 @@ export class PortfolioService {
this.accountService.accounts({ this.accountService.accounts({
where, where,
include: { include: {
activities: { include: { SymbolProfile: true } }, activities: { include: { SymbolProfile: true, tags: true } },
platform: true, platform: true,
tags: true tags: true
}, },
@ -200,12 +201,18 @@ export class PortfolioService {
for (const { for (const {
currency, currency,
date, date,
isDraft,
quantity, quantity,
SymbolProfile, SymbolProfile,
tags,
type, type,
unitPrice unitPrice
} of account.activities) { } of account.activities) {
activitiesCount += 1;
if (isDraftActivity({ tags })) {
continue;
}
switch (type) { switch (type) {
case ActivityType.DIVIDEND: case ActivityType.DIVIDEND:
dividendInBaseCurrency += dividendInBaseCurrency +=
@ -226,10 +233,6 @@ export class PortfolioService {
)) ?? 0; )) ?? 0;
break; break;
} }
if (!isDraft) {
activitiesCount += 1;
}
} }
const valueInBaseCurrency = const valueInBaseCurrency =
@ -2101,8 +2104,8 @@ export class PortfolioService {
}) { }) {
return getSum( return getSum(
activities activities
.filter(({ isDraft, type }) => { .filter((activity) => {
return isDraft === false && type === activityType; return !isDraftActivity(activity) && activity.type === activityType;
}) })
.map(({ assetProfile, currency, quantity, unitPrice }) => { .map(({ assetProfile, currency, quantity, unitPrice }) => {
return new Big( 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'; 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({ export function isDraftTagToBeAssigned({
date, date,
endOfTodayDate = endOfToday(), endOfTodayDate = endOfToday(),
@ -18,11 +39,13 @@ export function isDraftTagToBeAssigned({
return false; return false;
} }
if (!isAfter(date, endOfTodayDate)) { if (!isActivityInFuture({ date, endOfTodayDate })) {
return false; return false;
} }
// Assign only when the date newly moves into the future, so that a tag the // 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 // 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> <td *matCellDef="let element" class="line-height-normal px-1" mat-cell>
<div class="align-items-center d-flex text-nowrap"> <div class="align-items-center d-flex text-nowrap">
<span class="text-truncate">{{ element.assetProfile?.name }}</span> <span class="text-truncate">{{ element.assetProfile?.name }}</span>
@if (element.isDraft) { @if (isDraftActivity(element)) {
<span class="badge badge-secondary ml-1" i18n>Draft</span> <span class="badge badge-secondary ml-1" i18n>Draft</span>
} }
</div> </div>
@ -513,7 +513,7 @@
</a> </a>
<button <button
mat-menu-item mat-menu-item
[disabled]="!element.isDraft" [disabled]="!isDraftActivity(element)"
(click)="onExportDraft(element.id)" (click)="onExportDraft(element.id)"
> >
<span class="align-items-center d-flex"> <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 TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { getLocale, isAccountExcluded } from '@ghostfolio/common/helper'; import {
getLocale,
isAccountExcluded,
isDraftActivity
} from '@ghostfolio/common/helper';
import { import {
Activity, Activity,
AssetProfileIdentifier AssetProfileIdentifier
@ -138,6 +142,7 @@ export class GfActivitiesTableComponent implements AfterViewInit, OnInit {
public activityTypesTranslationMap = new Map<ActivityType, string>(); public activityTypesTranslationMap = new Map<ActivityType, string>();
public hasDrafts = false; public hasDrafts = false;
public hasErrors = false; public hasErrors = false;
public isDraftActivity = isDraftActivity;
public isUUID = isUUID; public isUUID = isUUID;
public selectedRows = new SelectionModel<Activity>(true, []); public selectedRows = new SelectionModel<Activity>(true, []);
public typesFilter = new FormControl<string[]>([]); public typesFilter = new FormControl<string[]>([]);
@ -277,7 +282,7 @@ export class GfActivitiesTableComponent implements AfterViewInit, OnInit {
return ( return (
this.hasPermissionToOpenDetails && this.hasPermissionToOpenDetails &&
this.isExcludedFromAnalysis(activity) === false && this.isExcludedFromAnalysis(activity) === false &&
activity.isDraft === false && isDraftActivity(activity) === false &&
['BUY', 'DIVIDEND', 'SELL'].includes(activity.type) ['BUY', 'DIVIDEND', 'SELL'].includes(activity.type)
); );
} }
@ -357,7 +362,7 @@ export class GfActivitiesTableComponent implements AfterViewInit, OnInit {
this.exportDrafts.emit( this.exportDrafts.emit(
this.dataSource() this.dataSource()
?.filteredData.filter((activity) => { ?.filteredData.filter((activity) => {
return activity.isDraft; return isDraftActivity(activity);
}) })
.map((activity) => { .map((activity) => {
return activity.id; return activity.id;

1
prisma/schema.prisma

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

Loading…
Cancel
Save