From 251e51a9a57b304d36515f6a93047f577959f2d3 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 6 Aug 2026 07:39:42 +0200 Subject: [PATCH] Setup draft tag --- apps/api/src/app/account/account.service.ts | 4 +-- .../src/app/activities/activities.service.ts | 26 +++++++++++++-- apps/api/src/app/user/user.service.ts | 3 +- apps/api/src/helper/activity.helper.ts | 32 +++++++++++++++++++ apps/api/src/services/tag/tag.service.ts | 20 ++++++++++++ ...eate-or-update-account-dialog.component.ts | 19 +++++++---- libs/common/src/lib/config.ts | 2 ++ libs/common/src/lib/helper.ts | 9 ++++++ libs/ui/src/lib/i18n.ts | 1 + .../migration.sql | 13 ++++++++ prisma/seed.mts | 4 +++ 11 files changed, 122 insertions(+), 11 deletions(-) create mode 100644 apps/api/src/helper/activity.helper.ts create mode 100644 prisma/migrations/20260805130140_added_draft_tag_to_order/migration.sql diff --git a/apps/api/src/app/account/account.service.ts b/apps/api/src/app/account/account.service.ts index 3d0bb91bd..3fcbcff12 100644 --- a/apps/api/src/app/account/account.service.ts +++ b/apps/api/src/app/account/account.service.ts @@ -172,7 +172,7 @@ export class AccountService { tagIds?: string[]; userId: string; }): Promise { - await this.tagService.validateTagIds({ tagIds, userId }); + await this.tagService.validateTagIdsForAccount({ tagIds, userId }); const account = await this.prismaService.account.create({ data: { @@ -317,7 +317,7 @@ export class AccountService { userId: string; where: Prisma.AccountWhereUniqueInput; }): Promise { - await this.tagService.validateTagIds({ tagIds, userId }); + await this.tagService.validateTagIdsForAccount({ tagIds, userId }); const account = await this.prismaService.account.update({ data: { diff --git a/apps/api/src/app/activities/activities.service.ts b/apps/api/src/app/activities/activities.service.ts index 140726aeb..0d0f56fbe 100644 --- a/apps/api/src/app/activities/activities.service.ts +++ b/apps/api/src/app/activities/activities.service.ts @@ -7,6 +7,7 @@ import { isAccountBalanceInFuture, WHERE_ACCOUNT_NOT_EXCLUDED } from '@ghostfolio/api/helper/account.helper'; +import { isDraftTagToBeAssigned } 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'; @@ -21,6 +22,7 @@ import { GATHER_ASSET_PROFILE_PROCESS_JOB_NAME, GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS, NON_INVESTMENT_ACTIVITY_TYPES, + TAG_ID_DRAFT, TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config'; import { @@ -265,13 +267,20 @@ export class ActivitiesService { ? false : isAfter(data.date as Date, endOfToday()); + const tagsToConnect = isDraftTagToBeAssigned({ + date: data.date as Date, + type: data.type + }) + ? uniqBy([...tags, { id: TAG_ID_DRAFT }], 'id') + : tags; + const activity = await this.prismaService.order.create({ data: { ...orderData, account, isDraft, tags: { - connect: tags + connect: tagsToConnect } }, include: { SymbolProfile: true } @@ -1014,13 +1023,26 @@ export class ActivitiesService { delete data.symbol; delete data.tags; + const storedActivity = await this.prismaService.order.findUnique({ + where, + select: { date: true } + }); + + const tagsToSet = isDraftTagToBeAssigned({ + date: data.date as Date, + storedDate: storedActivity?.date, + type: data.type + }) + ? uniqBy([...tags, { id: TAG_ID_DRAFT }], 'id') + : tags; + const activity = await this.prismaService.order.update({ where, data: { ...data, isDraft, tags: { - set: tags + set: tagsToSet } } }); diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 516b68e8e..a055f029e 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -35,6 +35,7 @@ import { PROPERTY_MAX_DAILY_REQUESTS, PROPERTY_REFERRAL_PARTNERS, PROPERTY_SYSTEM_MESSAGE, + TAG_ID_DRAFT, TAG_ID_EXCLUDE_FROM_ANALYSIS, THROTTLE_DAILY_KEY, THROTTLE_DAILY_TTL @@ -195,7 +196,7 @@ export class UserService { subscription.type === SubscriptionType.Basic ) { tags = tags.filter(({ id }) => { - return id === TAG_ID_EXCLUDE_FROM_ANALYSIS; + return [TAG_ID_DRAFT, TAG_ID_EXCLUDE_FROM_ANALYSIS].includes(id); }); } diff --git a/apps/api/src/helper/activity.helper.ts b/apps/api/src/helper/activity.helper.ts new file mode 100644 index 000000000..a1e011cf0 --- /dev/null +++ b/apps/api/src/helper/activity.helper.ts @@ -0,0 +1,32 @@ +import { Type as ActivityType } from '@prisma/client'; +import { endOfToday, isAfter } from 'date-fns'; + +const ACTIVITY_TYPES_NEVER_DRAFT: ActivityType[] = [ + 'FEE', + 'INTEREST', + 'LIABILITY' +]; + +export function isDraftTagToBeAssigned({ + date, + endOfTodayDate = endOfToday(), + storedDate, + type +}: { + date: Date; + endOfTodayDate?: Date; + storedDate?: Date; + type: ActivityType; +}) { + if (ACTIVITY_TYPES_NEVER_DRAFT.includes(type)) { + return false; + } + + if (!isAfter(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; +} diff --git a/apps/api/src/services/tag/tag.service.ts b/apps/api/src/services/tag/tag.service.ts index de052f9a1..7680e5f3d 100644 --- a/apps/api/src/services/tag/tag.service.ts +++ b/apps/api/src/services/tag/tag.service.ts @@ -1,4 +1,5 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { TAG_ID_DRAFT } from '@ghostfolio/common/config'; import { HttpException, Injectable } from '@nestjs/common'; import { Prisma, Tag } from '@prisma/client'; @@ -160,4 +161,23 @@ export class TagService { ); } } + + public async validateTagIdsForAccount({ + tagIds, + userId + }: { + tagIds: string[]; + userId: string; + }) { + // The "DRAFT" tag qualifies an individual activity and cannot be assigned + // to an account + if (tagIds?.includes(TAG_ID_DRAFT)) { + throw new HttpException( + getReasonPhrase(StatusCodes.BAD_REQUEST), + StatusCodes.BAD_REQUEST + ); + } + + return this.validateTagIds({ tagIds, userId }); + } } diff --git a/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts b/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts index c1d171b6f..b46ef3aff 100644 --- a/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts +++ b/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 { TAG_ID_DRAFT } from '@ghostfolio/common/config'; import { CreateAccountDto, UpdateAccountDto } from '@ghostfolio/common/dtos'; import { getStringOrNull } from '@ghostfolio/common/helper'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; @@ -92,12 +93,18 @@ export class GfCreateOrUpdateAccountDialogComponent { ); this.tagsAvailable = - this.data.user?.tags?.map((tag) => { - return { - ...tag, - name: translate(tag.name) - }; - }) ?? []; + 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 { + ...tag, + name: translate(tag.name) + }; + }) ?? []; this.accountForm = this.formBuilder.group({ accountId: [{ disabled: true, value: this.data.account.id }], diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index 23c8aab69..761f7c3ed 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -343,12 +343,14 @@ export const SUPPORTED_LANGUAGE_CODES = [ ] as const; 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_EXCLUDE_FROM_ANALYSIS = 'f2e868af-8333-459f-b161-cbc6544c24bd'; export const TAG_IDS_SYSTEM = [ TAG_ID_DEMO, + TAG_ID_DRAFT, TAG_ID_EMERGENCY_FUND, TAG_ID_EXCLUDE_FROM_ANALYSIS ]; diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts index 44fd89aa2..d67abe03c 100644 --- a/libs/common/src/lib/helper.ts +++ b/libs/common/src/lib/helper.ts @@ -43,6 +43,7 @@ import { ghostfolioFearAndGreedIndexSymbolStocks, ghostfolioPrefix, SEARCH_QUERY_MINIMUM_LENGTH, + TAG_ID_DRAFT, TAG_ID_EXCLUDE_FROM_ANALYSIS, TAG_IDS_SYSTEM } 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) { if (aCurrency === 'USD') { return true; diff --git a/libs/ui/src/lib/i18n.ts b/libs/ui/src/lib/i18n.ts index 9e55a5edb..dce6fdb5b 100644 --- a/libs/ui/src/lib/i18n.ts +++ b/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_OSS: $localize`Switch to Ghostfolio Premium easily`, DATA_SOURCE: $localize`Data Source`, + DRAFT: $localize`Draft`, EMERGENCY_FUND: $localize`Emergency Fund`, EXCLUDE_FROM_ANALYSIS: $localize`Exclude from Analysis`, Global: $localize`Global`, diff --git a/prisma/migrations/20260805130140_added_draft_tag_to_order/migration.sql b/prisma/migrations/20260805130140_added_draft_tag_to_order/migration.sql new file mode 100644 index 000000000..b7bf6b7ed --- /dev/null +++ b/prisma/migrations/20260805130140_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; diff --git a/prisma/seed.mts b/prisma/seed.mts index 81f34d05a..5ec57aad7 100644 --- a/prisma/seed.mts +++ b/prisma/seed.mts @@ -10,6 +10,10 @@ const prisma = new PrismaClient({ adapter }); async function main() { await prisma.tag.createMany({ data: [ + { + id: '0c077abd-eca2-4cbb-818c-6cefbf2d169a', + name: 'DRAFT' + }, { id: '4452656d-9fa4-4bd0-ba38-70492e31d180', name: 'EMERGENCY_FUND'