Browse Source

Task/migrate isDraft of activity to draft tag (#7551)

* Migrate isDraft of activity to draft tag

* Update changelog
pull/7580/head
Thomas Kaul 1 week ago
committed by GitHub
parent
commit
cb6da5c24f
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 8
      CHANGELOG.md
  2. 22
      apps/api/src/app/account/account.service.ts
  3. 6
      apps/api/src/app/activities/activities.controller.ts
  4. 106
      apps/api/src/app/activities/activities.service.ts
  5. 21
      apps/api/src/app/import/import.service.ts
  6. 32
      apps/api/src/app/portfolio/portfolio.service.ts
  7. 3
      apps/api/src/app/user/user.service.ts
  8. 74
      apps/api/src/helper/activity.helper.ts
  9. 20
      apps/api/src/services/tag/tag.service.ts
  10. 11
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  11. 9
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts
  12. 2
      libs/common/src/lib/config.ts
  13. 9
      libs/common/src/lib/helper.ts
  14. 4
      libs/ui/src/lib/activities-table/activities-table.component.html
  15. 11
      libs/ui/src/lib/activities-table/activities-table.component.ts
  16. 1
      libs/ui/src/lib/i18n.ts
  17. 13
      prisma/migrations/20260808120000_added_draft_tag_to_order/migration.sql
  18. 1
      prisma/schema.prisma
  19. 4
      prisma/seed.mts

8
CHANGELOG.md

@ -7,12 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Added
- Added the _Draft_ tag, assigned automatically to activities dated in the future
### Changed ### Changed
- Deprecated the `isDraft` attribute of the activity in favor of the _Draft_ tag
- Changed the activities count of an account to include draft activities
- Extended the _Draft_ tag to activities with a custom asset profile of type `BUY`
- Improved the language localization for German (`de`) - Improved the language localization for German (`de`)
### Fixed ### Fixed
- Fixed the dividend and interest of an account by excluding draft activities
- Resolved an issue with unknown country names in the country weightings of the _Financial Modeling Prep_ service - Resolved an issue with unknown country names in the country weightings of the _Financial Modeling Prep_ service
- Resolved an issue with unknown country names in the data enhancer for asset profile data via _Trackinsight_ - Resolved an issue with unknown country names in the data enhancer for asset profile data via _Trackinsight_

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

@ -89,7 +89,10 @@ export class AccountService {
orderBy?: Prisma.AccountOrderByWithRelationInput; orderBy?: Prisma.AccountOrderByWithRelationInput;
}): Promise< }): Promise<
(AccountWithBalance & { (AccountWithBalance & {
activities?: (Order & { SymbolProfile?: SymbolProfile })[]; activities?: (Order & {
SymbolProfile?: SymbolProfile;
tags?: Pick<Tag, 'id'>[];
})[];
balances?: AccountBalance[]; balances?: AccountBalance[];
platform?: Platform; platform?: Platform;
tags?: Tag[]; tags?: Tag[];
@ -172,7 +175,7 @@ export class AccountService {
tagIds?: string[]; tagIds?: string[];
userId: string; userId: string;
}): Promise<Account> { }): Promise<Account> {
await this.tagService.validateTagIds({ tagIds, userId }); await this.tagService.validateTagIdsWithoutDraftTag({ tagIds, userId });
const account = await this.prismaService.account.create({ const account = await this.prismaService.account.create({
data: { data: {
@ -237,15 +240,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;
@ -317,7 +315,7 @@ export class AccountService {
userId: string; userId: string;
where: Prisma.AccountWhereUniqueInput; where: Prisma.AccountWhereUniqueInput;
}): Promise<Account> { }): Promise<Account> {
await this.tagService.validateTagIds({ tagIds, userId }); await this.tagService.validateTagIdsWithoutDraftTag({ tagIds, userId });
const account = await this.prismaService.account.update({ const account = await this.prismaService.account.update({
data: { data: {

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 } }
}, },
originalDate: originalActivity.date,
userId: this.request.user.id, userId: this.request.user.id,
where: { where: {
id id

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

@ -7,6 +7,12 @@ import {
isAccountBalanceInFuture, isAccountBalanceInFuture,
WHERE_ACCOUNT_NOT_EXCLUDED WHERE_ACCOUNT_NOT_EXCLUDED
} from '@ghostfolio/api/helper/account.helper'; } from '@ghostfolio/api/helper/account.helper';
import {
getTagsWithDraftTag,
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';
@ -21,11 +27,13 @@ import {
GATHER_ASSET_PROFILE_PROCESS_JOB_NAME, GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS, GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
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 {
canDeleteAssetProfile, canDeleteAssetProfile,
getAssetProfileIdentifier, getAssetProfileIdentifier,
isDraftActivity,
isValidCustomAssetProfileSymbol isValidCustomAssetProfileSymbol
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
@ -49,7 +57,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';
@ -112,7 +120,7 @@ export class ActivitiesService {
tags, tags,
userId userId
}: { tags: Tag[]; userId: string } & AssetProfileIdentifier) { }: { tags: Tag[]; userId: string } & AssetProfileIdentifier) {
await this.tagService.validateTagIds({ await this.tagService.validateTagIdsWithoutDraftTag({
userId, userId,
tagIds: tags.map(({ id }) => { tagIds: tags.map(({ id }) => {
return id; return id;
@ -120,6 +128,7 @@ export class ActivitiesService {
}); });
const activities = await this.prismaService.order.findMany({ const activities = await this.prismaService.order.findMany({
include: { tags: { select: { id: true } } },
where: { where: {
userId, userId,
SymbolProfile: { SymbolProfile: {
@ -129,20 +138,31 @@ export class ActivitiesService {
} }
}); });
const tagsToAssign = tags.map(({ id }) => {
return { id };
});
await Promise.all( await Promise.all(
activities.map(({ id }) => activities.map((activity) => {
this.prismaService.order.update({ // The set operation replaces all existing connections with the provided
// ones, hence the "Draft" tag of an individual activity is carried over
const isDraft = isDraftActivity(activity);
const tagsToSet = isDraft
? [...tagsToAssign, { id: TAG_ID_DRAFT }]
: tagsToAssign;
return this.prismaService.order.update({
data: { data: {
// @deprecated Mirrors the "Draft" tag until the attribute is removed
isDraft,
tags: { tags: {
// The set operation replaces all existing connections with the provided ones set: tagsToSet
set: tags.map((tag) => {
return { id: tag.id };
})
} }
}, },
where: { id } where: { id: activity.id }
});
}) })
)
); );
this.eventEmitter.emit( this.eventEmitter.emit(
@ -261,17 +281,21 @@ export class ActivitiesService {
const orderData: Prisma.OrderCreateInput = data; const orderData: Prisma.OrderCreateInput = data;
const isDraft = NON_INVESTMENT_ACTIVITY_TYPES.includes(data.type) const tagsToConnect = getTagsWithDraftTag({
? false tags,
: isAfter(data.date as Date, endOfToday()); date: data.date as Date,
draftTag: { id: TAG_ID_DRAFT },
type: data.type
});
const activity = await this.prismaService.order.create({ const activity = await this.prismaService.order.create({
data: { data: {
...orderData, ...orderData,
account, account,
isDraft, // @deprecated Mirrors the "Draft" tag until the attribute is removed
isDraft: isDraftActivity({ tags: tagsToConnect }),
tags: { tags: {
connect: tags connect: tagsToConnect
} }
}, },
include: { SymbolProfile: true } include: { SymbolProfile: true }
@ -637,8 +661,12 @@ export class ActivitiesService {
}; };
} }
if (includeDrafts === false) { const isFilteredByDraftTag = filtersByTag.some(({ id }) => {
where.isDraft = false; return id === TAG_ID_DRAFT;
});
if (includeDrafts === false && !isFilteredByDraftTag) {
andConditions.push(WHERE_ACTIVITY_NOT_DRAFT);
} }
if (filtersByAssetClass.length > 0) { if (filtersByAssetClass.length > 0) {
@ -952,6 +980,7 @@ export class ActivitiesService {
public async updateActivity({ public async updateActivity({
data, data,
originalDate,
userId, userId,
where where
}: { }: {
@ -963,9 +992,11 @@ export class ActivitiesService {
tags?: { id: string }[]; tags?: { id: string }[];
type?: ActivityType; type?: ActivityType;
}; };
originalDate: Date;
userId: string; userId: string;
where: Prisma.OrderWhereUniqueInput; where: Prisma.OrderWhereUniqueInput;
}): Promise<Order> { }): Promise<Order> {
const areTagsProvided = data.tags !== undefined;
const tags = data.tags ?? []; const tags = data.tags ?? [];
await this.tagService.validateTagIds({ await this.tagService.validateTagIds({
@ -979,8 +1010,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' &&
@ -991,10 +1020,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: [
{ {
@ -1014,14 +1042,40 @@ export class ActivitiesService {
delete data.symbol; delete data.symbol;
delete data.tags; delete data.tags;
// Leave the tags untouched if the request does not provide them, so that a
// partial update cannot drop the "Draft" tag
let isDraft: boolean;
let tagsToUpdate: Prisma.OrderUpdateInput['tags'];
if (areTagsProvided) {
const tagsToSet = getTagsWithDraftTag({
originalDate,
tags,
date: data.date as Date,
draftTag: { id: TAG_ID_DRAFT },
type: data.type
});
isDraft = isDraftActivity({ tags: tagsToSet });
tagsToUpdate = { set: tagsToSet };
} else if (
isDraftTagToBeAssigned({
originalDate,
date: data.date as Date,
type: data.type
})
) {
isDraft = true;
tagsToUpdate = { connect: { id: TAG_ID_DRAFT } };
}
const activity = await this.prismaService.order.update({ const activity = await this.prismaService.order.update({
where, where,
data: { data: {
...data, ...data,
// @deprecated Mirrors the "Draft" tag until the attribute is removed
isDraft, isDraft,
tags: { tags: tagsToUpdate
set: tags
}
} }
}); });

21
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 { getTagsWithDraftTag } 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';
@ -713,6 +716,11 @@ export class ImportService {
}); });
} }
// Preview the "Draft" tag which createActivity() assigns in a real run
const draftTag = tags.find(({ id }) => {
return id === TAG_ID_DRAFT;
}) ?? { id: TAG_ID_DRAFT, name: 'DRAFT' };
const activities: Activity[] = []; const activities: Activity[] = [];
for (const activity of activitiesExtendedWithErrors) { for (const activity of activitiesExtendedWithErrors) {
@ -775,6 +783,13 @@ export class ImportService {
}); });
if (isDryRun) { if (isDryRun) {
const previewTags = getTagsWithDraftTag({
date,
draftTag,
type,
tags: validatedTags
});
order = { order = {
comment, comment,
currency, currency,
@ -788,7 +803,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 +832,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
}; };

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

@ -34,6 +34,7 @@ import {
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
DEFAULT_DATE_RANGE, DEFAULT_DATE_RANGE,
TAG_ID_DRAFT,
TAG_ID_EMERGENCY_FUND, TAG_ID_EMERGENCY_FUND,
TAG_ID_EXCLUDE_FROM_ANALYSIS, TAG_ID_EXCLUDE_FROM_ANALYSIS,
UNKNOWN_KEY UNKNOWN_KEY
@ -43,6 +44,7 @@ import {
getAssetProfileIdentifier, getAssetProfileIdentifier,
getSum, getSum,
isAccountExcluded, isAccountExcluded,
isDraftActivity,
parseDate parseDate
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
@ -174,7 +176,19 @@ export class PortfolioService {
this.accountService.accounts({ this.accountService.accounts({
where, where,
include: { include: {
activities: { include: { SymbolProfile: true } }, activities: {
include: {
SymbolProfile: true,
tags: {
select: {
id: true
},
where: {
id: TAG_ID_DRAFT
}
}
}
},
platform: true, platform: true,
tags: true tags: true
}, },
@ -200,12 +214,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 +246,6 @@ export class PortfolioService {
)) ?? 0; )) ?? 0;
break; break;
} }
if (!isDraft) {
activitiesCount += 1;
}
} }
const valueInBaseCurrency = const valueInBaseCurrency =
@ -2101,8 +2117,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(

3
apps/api/src/app/user/user.service.ts

@ -35,6 +35,7 @@ import {
PROPERTY_MAX_DAILY_REQUESTS, PROPERTY_MAX_DAILY_REQUESTS,
PROPERTY_REFERRAL_PARTNERS, PROPERTY_REFERRAL_PARTNERS,
PROPERTY_SYSTEM_MESSAGE, PROPERTY_SYSTEM_MESSAGE,
TAG_ID_DRAFT,
TAG_ID_EXCLUDE_FROM_ANALYSIS, TAG_ID_EXCLUDE_FROM_ANALYSIS,
THROTTLE_DAILY_KEY, THROTTLE_DAILY_KEY,
THROTTLE_DAILY_TTL THROTTLE_DAILY_TTL
@ -195,7 +196,7 @@ export class UserService {
subscription.type === SubscriptionType.Basic subscription.type === SubscriptionType.Basic
) { ) {
tags = tags.filter(({ id }) => { tags = tags.filter(({ id }) => {
return id === TAG_ID_EXCLUDE_FROM_ANALYSIS; return [TAG_ID_DRAFT, TAG_ID_EXCLUDE_FROM_ANALYSIS].includes(id);
}); });
} }

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

@ -0,0 +1,74 @@
import {
NON_INVESTMENT_ACTIVITY_TYPES,
TAG_ID_DRAFT
} from '@ghostfolio/common/config';
import { Prisma, Type as ActivityType } from '@prisma/client';
import { endOfToday, isAfter } from 'date-fns';
import { uniqBy } from 'lodash';
export const WHERE_ACTIVITY_NOT_DRAFT: Prisma.OrderWhereInput = {
tags: {
none: {
id: TAG_ID_DRAFT
}
}
};
export function getTagsWithDraftTag<T extends { id: string }>({
date,
draftTag,
endOfTodayDate = endOfToday(),
originalDate,
tags,
type
}: {
date: Date;
draftTag: T;
endOfTodayDate?: Date;
originalDate?: Date;
tags: T[];
type: ActivityType;
}) {
if (!isDraftTagToBeAssigned({ date, endOfTodayDate, originalDate, type })) {
return tags;
}
return uniqBy([...tags, draftTag], 'id');
}
export function isActivityInFuture({
date,
endOfTodayDate = endOfToday()
}: {
date: Date;
endOfTodayDate?: Date;
}) {
return isAfter(date, endOfTodayDate);
}
export function isDraftTagToBeAssigned({
date,
endOfTodayDate = endOfToday(),
originalDate,
type
}: {
date: Date;
endOfTodayDate?: Date;
originalDate?: Date;
type: ActivityType;
}) {
if (NON_INVESTMENT_ACTIVITY_TYPES.includes(type)) {
return false;
}
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 originalDate
? !isActivityInFuture({ endOfTodayDate, date: originalDate })
: true;
}

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

@ -1,4 +1,5 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { TAG_ID_DRAFT } from '@ghostfolio/common/config';
import { HttpException, Injectable } from '@nestjs/common'; import { HttpException, Injectable } from '@nestjs/common';
import { Prisma, Tag } from '@prisma/client'; import { Prisma, Tag } from '@prisma/client';
@ -160,4 +161,23 @@ export class TagService {
); );
} }
} }
public async validateTagIdsWithoutDraftTag({
tagIds,
userId
}: {
tagIds: string[];
userId: string;
}) {
// The "DRAFT" tag qualifies an individual activity and can therefore
// neither be assigned to an account nor to all activities of a holding
if (tagIds?.includes(TAG_ID_DRAFT)) {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
return this.validateTagIds({ tagIds, userId });
}
} }

11
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts

@ -3,7 +3,8 @@ import {
DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZE,
E_MAIL_LINE_BREAK, E_MAIL_LINE_BREAK,
NUMERICAL_PRECISION_THRESHOLD_3_FIGURES, NUMERICAL_PRECISION_THRESHOLD_3_FIGURES,
NUMERICAL_PRECISION_THRESHOLD_4_FIGURES NUMERICAL_PRECISION_THRESHOLD_4_FIGURES,
TAG_ID_DRAFT
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { CreateOrderDto } from '@ghostfolio/common/dtos'; import { CreateOrderDto } from '@ghostfolio/common/dtos';
import { import {
@ -591,7 +592,13 @@ export class GfHoldingDetailDialogComponent implements OnInit {
); );
this.tagsAvailable = this.tagsAvailable =
this.user?.tags?.map((tag) => { this.user?.tags
?.filter(({ id }) => {
// The "DRAFT" tag qualifies an individual activity and cannot
// be assigned to all activities of a holding
return id !== TAG_ID_DRAFT;
})
.map((tag) => {
return { return {
...tag, ...tag,
name: translate(tag.name) name: translate(tag.name)

9
apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts

@ -1,4 +1,5 @@
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { TAG_ID_DRAFT } from '@ghostfolio/common/config';
import { CreateAccountDto, UpdateAccountDto } from '@ghostfolio/common/dtos'; import { CreateAccountDto, UpdateAccountDto } from '@ghostfolio/common/dtos';
import { getStringOrNull } from '@ghostfolio/common/helper'; import { getStringOrNull } from '@ghostfolio/common/helper';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -92,7 +93,13 @@ export class GfCreateOrUpdateAccountDialogComponent {
); );
this.tagsAvailable = this.tagsAvailable =
this.data.user?.tags?.map((tag) => { this.data.user?.tags
?.filter(({ id }) => {
// The "DRAFT" tag qualifies an individual activity and cannot be
// assigned to an account
return id !== TAG_ID_DRAFT;
})
.map((tag) => {
return { return {
...tag, ...tag,
name: translate(tag.name) name: translate(tag.name)

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

@ -343,12 +343,14 @@ export const SUPPORTED_LANGUAGE_CODES = [
] as const; ] as const;
export const TAG_ID_DEMO = 'efa08cb3-9b9d-4974-ac68-db13a19c4874'; export const TAG_ID_DEMO = 'efa08cb3-9b9d-4974-ac68-db13a19c4874';
export const TAG_ID_DRAFT = '0c077abd-eca2-4cbb-818c-6cefbf2d169a';
export const TAG_ID_EMERGENCY_FUND = '4452656d-9fa4-4bd0-ba38-70492e31d180'; export const TAG_ID_EMERGENCY_FUND = '4452656d-9fa4-4bd0-ba38-70492e31d180';
export const TAG_ID_EXCLUDE_FROM_ANALYSIS = export const TAG_ID_EXCLUDE_FROM_ANALYSIS =
'f2e868af-8333-459f-b161-cbc6544c24bd'; 'f2e868af-8333-459f-b161-cbc6544c24bd';
export const TAG_IDS_SYSTEM = [ export const TAG_IDS_SYSTEM = [
TAG_ID_DEMO, TAG_ID_DEMO,
TAG_ID_DRAFT,
TAG_ID_EMERGENCY_FUND, TAG_ID_EMERGENCY_FUND,
TAG_ID_EXCLUDE_FROM_ANALYSIS TAG_ID_EXCLUDE_FROM_ANALYSIS
]; ];

9
libs/common/src/lib/helper.ts

@ -43,6 +43,7 @@ import {
ghostfolioFearAndGreedIndexSymbolStocks, ghostfolioFearAndGreedIndexSymbolStocks,
ghostfolioPrefix, ghostfolioPrefix,
SEARCH_QUERY_MINIMUM_LENGTH, SEARCH_QUERY_MINIMUM_LENGTH,
TAG_ID_DRAFT,
TAG_ID_EXCLUDE_FROM_ANALYSIS, TAG_ID_EXCLUDE_FROM_ANALYSIS,
TAG_IDS_SYSTEM TAG_IDS_SYSTEM
} from './config'; } from './config';
@ -537,6 +538,14 @@ export function isDerivedCurrency(aCurrency: string) {
}); });
} }
export function isDraftActivity(activity?: { tags?: { id: string }[] }) {
return (
activity?.tags?.some(({ id }) => {
return id === TAG_ID_DRAFT;
}) === true
);
}
export function isRootCurrency(aCurrency: string) { export function isRootCurrency(aCurrency: string) {
if (aCurrency === 'USD') { if (aCurrency === 'USD') {
return true; return 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
libs/ui/src/lib/i18n.ts

@ -18,6 +18,7 @@ const locales = {
DATA_IMPORT_AND_EXPORT_TOOLTIP_BASIC: $localize`Switch to Ghostfolio Premium or Ghostfolio Open Source easily`, DATA_IMPORT_AND_EXPORT_TOOLTIP_BASIC: $localize`Switch to Ghostfolio Premium or Ghostfolio Open Source easily`,
DATA_IMPORT_AND_EXPORT_TOOLTIP_OSS: $localize`Switch to Ghostfolio Premium easily`, DATA_IMPORT_AND_EXPORT_TOOLTIP_OSS: $localize`Switch to Ghostfolio Premium easily`,
DATA_SOURCE: $localize`Data Source`, DATA_SOURCE: $localize`Data Source`,
DRAFT: $localize`Draft`,
EMERGENCY_FUND: $localize`Emergency Fund`, EMERGENCY_FUND: $localize`Emergency Fund`,
EXCLUDE_FROM_ANALYSIS: $localize`Exclude from Analysis`, EXCLUDE_FROM_ANALYSIS: $localize`Exclude from Analysis`,
Global: $localize`Global`, Global: $localize`Global`,

13
prisma/migrations/20260808120000_added_draft_tag_to_order/migration.sql

@ -0,0 +1,13 @@
-- Create the "DRAFT" tag if it does not exist yet
INSERT INTO "Tag" ("id", "name")
VALUES ('0c077abd-eca2-4cbb-818c-6cefbf2d169a', 'DRAFT')
ON CONFLICT DO NOTHING;
-- Migrate activities with "isDraft" to the "DRAFT" tag
INSERT INTO "_OrderToTag" ("A", "B")
SELECT
"id",
'0c077abd-eca2-4cbb-818c-6cefbf2d169a'
FROM "Order"
WHERE "isDraft" = true
ON CONFLICT DO NOTHING;

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

4
prisma/seed.mts

@ -10,6 +10,10 @@ const prisma = new PrismaClient({ adapter });
async function main() { async function main() {
await prisma.tag.createMany({ await prisma.tag.createMany({
data: [ data: [
{
id: '0c077abd-eca2-4cbb-818c-6cefbf2d169a',
name: 'DRAFT'
},
{ {
id: '4452656d-9fa4-4bd0-ba38-70492e31d180', id: '4452656d-9fa4-4bd0-ba38-70492e31d180',
name: 'EMERGENCY_FUND' name: 'EMERGENCY_FUND'

Loading…
Cancel
Save