From 5ae3327ba342e832d4cfcd3b31c5df50076132bc Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 5 Jul 2026 14:02:22 +0200 Subject: [PATCH] Add tags support in accounts --- .../api/src/app/account/account.controller.ts | 52 +++++------ apps/api/src/app/account/account.service.ts | 91 +++++++++++++------ .../src/app/activities/activities.service.ts | 2 +- .../src/app/endpoints/tags/tags.controller.ts | 2 +- apps/api/src/app/export/export.service.ts | 3 +- apps/api/src/app/import/import.service.ts | 73 +++++++-------- .../src/app/portfolio/portfolio.service.ts | 9 +- apps/api/src/helper/account.helper.ts | 14 +-- apps/api/src/services/tag/tag.service.ts | 9 +- .../admin-tag/admin-tag.component.html | 20 +++- .../admin-tag/admin-tag.component.ts | 1 + ...eate-or-update-account-dialog.component.ts | 12 ++- .../common/src/lib/dtos/create-account.dto.ts | 3 + .../common/src/lib/dtos/update-account.dto.ts | 3 + libs/common/src/lib/helper.ts | 15 ++- .../accounts-table.component.html | 2 +- .../accounts-table.component.ts | 10 +- .../activities-table.component.ts | 12 +-- 18 files changed, 202 insertions(+), 131 deletions(-) diff --git a/apps/api/src/app/account/account.controller.ts b/apps/api/src/app/account/account.controller.ts index 538aead13..6466a13b2 100644 --- a/apps/api/src/app/account/account.controller.ts +++ b/apps/api/src/app/account/account.controller.ts @@ -156,33 +156,31 @@ export class AccountController { public async createAccount( @Body() data: CreateAccountDto ): Promise { - if (data.platformId) { - const platformId = data.platformId; - delete data.platformId; + const { tags: tagIds, ...accountData } = data; + + if (accountData.platformId) { + const platformId = accountData.platformId; + delete accountData.platformId; return this.accountService.createAccount( { - ...data, + ...accountData, platform: { connect: { id: platformId } }, - tags: data.tags?.map((id) => { - return { id }; - }), user: { connect: { id: this.request.user.id } } }, - this.request.user.id + this.request.user.id, + tagIds ); } else { - delete data.platformId; + delete accountData.platformId; return this.accountService.createAccount( { - ...data, - tags: data.tags?.map((id) => { - return { id }; - }), + ...accountData, user: { connect: { id: this.request.user.id } } }, - this.request.user.id + this.request.user.id, + tagIds ); } } @@ -259,18 +257,17 @@ export class AccountController { ); } - if (data.platformId) { - const platformId = data.platformId; - delete data.platformId; + const { tags: tagIds, ...accountData } = data; + + if (accountData.platformId) { + const platformId = accountData.platformId; + delete accountData.platformId; return this.accountService.updateAccount( { data: { - ...data, + ...accountData, platform: { connect: { id: platformId } }, - tags: data.tags?.map((id) => { - return { id }; - }), user: { connect: { id: this.request.user.id } } }, where: { @@ -280,22 +277,20 @@ export class AccountController { } } }, - this.request.user.id + this.request.user.id, + tagIds ); } else { // platformId is null, remove it - delete data.platformId; + delete accountData.platformId; return this.accountService.updateAccount( { data: { - ...data, + ...accountData, platform: originalAccount.platformId ? { disconnect: true } : undefined, - tags: data.tags?.map((id) => { - return { id }; - }), user: { connect: { id: this.request.user.id } } }, where: { @@ -305,7 +300,8 @@ export class AccountController { } } }, - this.request.user.id + this.request.user.id, + tagIds ); } } diff --git a/apps/api/src/app/account/account.service.ts b/apps/api/src/app/account/account.service.ts index 00bfd21d1..d1f4009a9 100644 --- a/apps/api/src/app/account/account.service.ts +++ b/apps/api/src/app/account/account.service.ts @@ -6,7 +6,7 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { Filter } from '@ghostfolio/common/interfaces'; -import { Injectable } from '@nestjs/common'; +import { HttpException, Injectable } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { Account, @@ -19,6 +19,7 @@ import { } from '@prisma/client'; import { Big } from 'big.js'; import { format } from 'date-fns'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { groupBy } from 'lodash'; import { CashDetails } from './interfaces/cash-details.interface'; @@ -74,17 +75,20 @@ export class AccountService { const { include = {}, skip, take, cursor, where, orderBy } = params; const isBalancesIncluded = !!include.balances; + const isTagsIncluded = !!include.tags; include.balances = { orderBy: { date: 'desc' }, ...(isBalancesIncluded ? {} : { take: 1 }) }; - include.tags = { - include: { - tag: true - } - }; + if (isTagsIncluded) { + include.tags = { + include: { + tag: true + } + }; + } const accounts = await this.prismaService.account.findMany({ cursor, @@ -99,32 +103,38 @@ export class AccountService { const result = { ...account, balance: account.balances[0]?.value ?? 0, - tags: (account.tags as unknown as { tag: Tag }[]).map(({ tag }) => { - return tag; - }) + tags: isTagsIncluded + ? (account.tags as unknown as { tag: Tag }[]).map(({ tag }) => { + return tag; + }) + : undefined }; if (!isBalancesIncluded) { delete result.balances; } + if (!isTagsIncluded) { + delete result.tags; + } + return result; }); } public async createAccount( - data: Prisma.AccountCreateInput & { tags?: { id: string }[] }, - aUserId: string + data: Prisma.AccountCreateInput, + aUserId: string, + tagIds?: string[] ): Promise { - const tags = data.tags; - delete data.tags; + await this.validateTagIds(tagIds, aUserId); const account = await this.prismaService.account.create({ data: { ...data, - tags: tags + tags: tagIds ? { - create: tags.map(({ id: tagId }) => { + create: tagIds.map((tagId) => { return { tag: { connect: { id: tagId } } }; @@ -172,7 +182,8 @@ export class AccountService { const accounts = await this.accounts({ include: { activities: true, - platform: true + platform: true, + tags: true }, orderBy: { name: 'asc' }, where: { userId: aUserId } @@ -249,28 +260,21 @@ export class AccountService { public async updateAccount( params: { where: Prisma.AccountWhereUniqueInput; - data: Prisma.AccountUpdateInput & { tags?: { id: string }[] }; + data: Prisma.AccountUpdateInput; }, - aUserId: string + aUserId: string, + tagIds?: string[] ): Promise { const { data, where } = params; - const tags = data.tags; - delete data.tags; - - await this.accountBalanceService.createOrUpdateAccountBalance({ - accountId: data.id as string, - balance: data.balance as number, - date: format(new Date(), DATE_FORMAT), - userId: aUserId - }); + await this.validateTagIds(tagIds, aUserId); const account = await this.prismaService.account.update({ data: { ...data, - tags: tags + tags: tagIds ? { - create: tags.map(({ id: tagId }) => { + create: tagIds.map((tagId) => { return { tag: { connect: { id: tagId } } }; @@ -282,6 +286,13 @@ export class AccountService { where }); + await this.accountBalanceService.createOrUpdateAccountBalance({ + accountId: account.id, + balance: data.balance as number, + date: format(new Date(), DATE_FORMAT), + userId: aUserId + }); + this.eventEmitter.emit( PortfolioChangedEvent.getName(), new PortfolioChangedEvent({ @@ -329,4 +340,26 @@ export class AccountService { }); } } + + private async validateTagIds(tagIds: string[], userId: string) { + if (!tagIds?.length) { + return; + } + + const uniqueTagIds = Array.from(new Set(tagIds)); + + const tagsCount = await this.prismaService.tag.count({ + where: { + id: { in: uniqueTagIds }, + OR: [{ userId }, { userId: null }] + } + }); + + if (tagsCount !== uniqueTagIds.length) { + throw new HttpException( + getReasonPhrase(StatusCodes.BAD_REQUEST), + StatusCodes.BAD_REQUEST + ); + } + } } diff --git a/apps/api/src/app/activities/activities.service.ts b/apps/api/src/app/activities/activities.service.ts index a2181d853..b404b6a37 100644 --- a/apps/api/src/app/activities/activities.service.ts +++ b/apps/api/src/app/activities/activities.service.ts @@ -570,7 +570,7 @@ export class ActivitiesService { ]; const andConditions: Prisma.OrderWhereInput[] = []; - const where: Prisma.OrderWhereInput = { AND: andConditions, userId }; + const where: Prisma.OrderWhereInput = { userId, AND: andConditions }; if (endDate) { andConditions.push({ date: { lte: endDate } }); diff --git a/apps/api/src/app/endpoints/tags/tags.controller.ts b/apps/api/src/app/endpoints/tags/tags.controller.ts index 925e1e0ed..21e29c6ab 100644 --- a/apps/api/src/app/endpoints/tags/tags.controller.ts +++ b/apps/api/src/app/endpoints/tags/tags.controller.ts @@ -83,7 +83,7 @@ export class TagsController { @HasPermission(permissions.readTags) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async getTags() { - return this.tagService.getTagsWithActivityCount(); + return this.tagService.getTagsWithAccountAndActivityCount(); } @HasPermission(permissions.updateTag) diff --git a/apps/api/src/app/export/export.service.ts b/apps/api/src/app/export/export.service.ts index 371a3f03d..8ebfde13d 100644 --- a/apps/api/src/app/export/export.service.ts +++ b/apps/api/src/app/export/export.service.ts @@ -72,7 +72,8 @@ export class ExportService { where, include: { balances: true, - platform: true + platform: true, + tags: true }, orderBy: { name: 'asc' diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index 840e08586..9b1aa417c 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -10,11 +10,7 @@ import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathe import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config'; -import { - CreateAssetProfileDto, - CreateAccountDto, - CreateOrderDto -} from '@ghostfolio/common/dtos'; +import { CreateAssetProfileDto, CreateOrderDto } from '@ghostfolio/common/dtos'; import { getAssetProfileIdentifier, parseDate @@ -194,9 +190,12 @@ export class ImportService { const tagIdMapping: { [oldTagId: string]: string } = {}; const userCurrency = user.settings.settings.baseCurrency; - if (tagsDto?.length) { - const existingTagsOfUser = await this.tagService.getTagsForUser(user.id); + const existingTagsOfUser = + tagsDto?.length || (!isDryRun && accountsWithBalancesDto?.length) + ? await this.tagService.getTagsForUser(user.id) + : []; + if (tagsDto?.length) { const canCreateOwnTag = hasPermission( user.permissions, permissions.createOwnTag @@ -207,7 +206,7 @@ export class ImportService { return id === tag.id; }); - if (!existingTagOfUser || existingTagOfUser.userId !== null) { + if (!existingTagOfUser) { if (!canCreateOwnTag) { throw new Error( `Insufficient permissions to create custom tag ("${tag.name}")` @@ -233,26 +232,37 @@ export class ImportService { if (existingTag && oldTagId) { tagIdMapping[oldTagId] = newTag.id; } + + existingTagsOfUser.push({ + id: newTag.id, + isUsed: false, + name: newTag.name, + userId: newTag.userId + }); } } } } if (!isDryRun && accountsWithBalancesDto?.length) { - const [existingAccounts, existingPlatforms, existingTags] = - await Promise.all([ - this.accountService.accounts({ - where: { - id: { - in: accountsWithBalancesDto.map(({ id }) => { - return id; - }) - } + const [existingAccounts, existingPlatforms] = await Promise.all([ + this.accountService.accounts({ + where: { + id: { + in: accountsWithBalancesDto.map(({ id }) => { + return id; + }) } - }), - this.platformService.getPlatforms(), - this.tagService.getTagsForUser(user.id) - ]); + } + }), + this.platformService.getPlatforms() + ]); + + const existingTagIds = new Set( + existingTagsOfUser.map(({ id }) => { + return id; + }) + ); for (const accountWithBalances of accountsWithBalancesDto) { // Check if there is any existing account with the same ID @@ -262,10 +272,7 @@ export class ImportService { // If there is no account or if the account belongs to a different user then create a new account if (!accountWithSameId || accountWithSameId.userId !== user.id) { - const account: CreateAccountDto = omit( - accountWithBalances, - 'balances' - ); + const account = omit(accountWithBalances, ['balances', 'tags']); let oldAccountId: string; const platformId = account.platformId; @@ -277,26 +284,19 @@ export class ImportService { delete account.id; } - const tagIds = (account.tags ?? []) + const tagIds = (accountWithBalances.tags ?? []) .map((tagId) => { return tagIdMapping[tagId] ?? tagId; }) .filter((tagId) => { - return existingTags.some(({ id }) => { - return id === tagId; - }); + return existingTagIds.has(tagId); }); - let accountObject: Prisma.AccountCreateInput & { - tags?: { id: string }[]; - } = { + let accountObject: Prisma.AccountCreateInput = { ...account, balances: { create: accountWithBalances.balances ?? [] }, - tags: tagIds.map((id) => { - return { id }; - }), user: { connect: { id: user.id } } }; @@ -313,7 +313,8 @@ export class ImportService { const newAccount = await this.accountService.createAccount( accountObject, - user.id + user.id, + tagIds ); // Store the new to old account ID mappings for updating activities diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index e505f110a..e75e54890 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -3,7 +3,6 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface'; import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { UserService } from '@ghostfolio/api/app/user/user.service'; -import { isAccountExcluded } from '@ghostfolio/api/helper/account.helper'; import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment'; import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account'; @@ -42,6 +41,7 @@ import { DATE_FORMAT, getAssetProfileIdentifier, getSum, + isAccountExcluded, parseDate } from '@ghostfolio/common/helper'; import { @@ -170,7 +170,8 @@ export class PortfolioService { where, include: { activities: { include: { SymbolProfile: true } }, - platform: true + platform: true, + tags: true }, orderBy: { name: 'asc' } }), @@ -2131,7 +2132,7 @@ export class PortfolioService { currentAccounts = await this.accountService.getAccounts(userId); } else if (filters.length === 1 && filters[0].type === 'ACCOUNT') { currentAccounts = await this.accountService.accounts({ - include: { platform: true }, + include: { platform: true, tags: true }, where: { id: filters[0].id } }); } else { @@ -2148,7 +2149,7 @@ export class PortfolioService { ); currentAccounts = await this.accountService.accounts({ - include: { platform: true }, + include: { platform: true, tags: true }, where: { id: { in: accountIds } } }); } diff --git a/apps/api/src/helper/account.helper.ts b/apps/api/src/helper/account.helper.ts index 206ae198a..0800c022e 100644 --- a/apps/api/src/helper/account.helper.ts +++ b/apps/api/src/helper/account.helper.ts @@ -1,6 +1,6 @@ import { TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config'; -import { Prisma, Tag } from '@prisma/client'; +import { Prisma } from '@prisma/client'; export const WHERE_ACCOUNT_NOT_EXCLUDED: Prisma.AccountWhereInput = { isExcluded: false, @@ -10,15 +10,3 @@ export const WHERE_ACCOUNT_NOT_EXCLUDED: Prisma.AccountWhereInput = { } } }; - -export function isAccountExcluded(account: { - isExcluded: boolean; - tags?: Tag[]; -}) { - return ( - account.isExcluded || - account.tags?.some(({ id }) => { - return id === TAG_ID_EXCLUDE_FROM_ANALYSIS; - }) === true - ); -} diff --git a/apps/api/src/services/tag/tag.service.ts b/apps/api/src/services/tag/tag.service.ts index 1c7b5c687..d967ce854 100644 --- a/apps/api/src/services/tag/tag.service.ts +++ b/apps/api/src/services/tag/tag.service.ts @@ -92,20 +92,21 @@ export class TagService { }); } - public async getTagsWithActivityCount() { - const tagsWithOrderCount = await this.prismaService.tag.findMany({ + public async getTagsWithAccountAndActivityCount() { + const tagsWithAccountAndOrderCount = await this.prismaService.tag.findMany({ include: { _count: { - select: { activities: true } + select: { accounts: true, activities: true } } } }); - return tagsWithOrderCount.map(({ _count, id, name, userId }) => { + return tagsWithAccountAndOrderCount.map(({ _count, id, name, userId }) => { return { id, name, userId, + accountCount: _count.accounts, activityCount: _count.activities }; }); diff --git a/apps/client/src/app/components/admin-tag/admin-tag.component.html b/apps/client/src/app/components/admin-tag/admin-tag.component.html index a84cbb283..14b011ddc 100644 --- a/apps/client/src/app/components/admin-tag/admin-tag.component.html +++ b/apps/client/src/app/components/admin-tag/admin-tag.component.html @@ -35,6 +35,24 @@ + + + Accounts + + + + + +